From b0a1ec2de0178c0adcd76a394f436894049ac5a4 Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 09:38:18 +0800 Subject: [PATCH 1/6] feat: opt-in content-addressed cross-run reuse - run-level reuseAcrossRuns flag (default false, part of requestHash) - agent() consults prior succeeded nodes from other runs in the same workspace store when context (workspace/input/executor/fingerprints) and spec hash match; newest first, schema revalidated per candidate - reused results carry reusedFrom provenance (crossRun marker, original producer preserved) and consume no call budget - store.findCrossRunReuse joins steps x runs with json_extract filters - README documents the semantics and the honest boundary: reuse proves context identity and storage fidelity, not first-run correctness --- .../mcode-dynamic-workflows/README.md | 6 + .../checks/cross-reuse.check.mjs | 106 +++++ .../mcode-dynamic-workflows/dist/main.mjs | 367 +++++++++++------- .../mcode-dynamic-workflows/src/engine.mjs | 20 +- .../mcode-dynamic-workflows/src/store.mjs | 32 +- 5 files changed, 382 insertions(+), 149 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index b49ae56..4449b41 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md @@ -45,6 +45,12 @@ Tracked files are regular workspace files (up to 1 MB each), fingerprinted from The repaired script runs from its beginning; checkpoints are recomputed and unreached branches are not premarked complete. Declare every data/control dependency in `dependsOn`. Untracked files, external evidence and side effects cannot be checked automatically, so stale or incorrect results must not be selected for reuse. Schema-constrained outputs accept native values, complete JSON text, or one complete JSON fence; validation errors preserve the raw output. Each node has an independent schema namespace, including local references, so repeated schema identifiers cannot conflict across nodes or runs. +## Cross-run reuse + +Stored results are normally reused only within a run (resume) or through explicit repair selection. Passing `reuseAcrossRuns: true` to `workflow_start`, or setting it while a draft is pending review, additionally lets a node adopt a stored result from an earlier run of the same project. Adoption requires the node's full spec to hash identically (prompt, model, effort, input, schema, dependencies) and the run context to match on all four keys — workspace, input, executor and tracked-file fingerprints. Adopted outputs are re-validated against the node's current schema; the step records provenance in `reusedFrom.crossRun: true` with the source run and step, emits a `step.reused` event, and does not consume the workflow's agent-call budget. + +Reuse proves only that the context was identical and that the stored result was carried over faithfully — it does not prove the original run's output was semantically correct. For critical nodes, prefer schemas with evidence fields or place an independent verification node downstream, and keep `reuseAcrossRuns` off when in doubt. + ## Data, permissions and network - Every project-scoped tool requires an absolute `workspace`; plugin process cwd is never treated as your project. Canonical project paths isolate runs, templates, history, limits and ports. Never use an unrelated project's path. diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs new file mode 100644 index 0000000..9236efc --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +// SDD contract suite for run-level reuseAcrossRuns (cross-run reuse of succeeded +// agent nodes). The engine/store behavior specified here may not exist yet; this +// file is the contract the implementation must satisfy. +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-xreuse-'));const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute});return {dir,store,engine,cleanup:async()=>{await engine.close();store.close();await rm(dir,{recursive:true,force:true});}};} +async function finish(engine,id){for(let i=0;i<300;i++){if(!engine.active.has(id))return engine.snapshot(id);await delay(20);}throw Error('timeout');} +async function run(engine,script,input={},opts={}){const r=await engine.start({requestId:crypto.randomUUID(),name:'Cross reuse',executor:'demo',script,input,...opts});await engine.approve(r.id,{revision:1});return finish(engine,r.id);} +const probe=`return await ctx.agent({id:'a',prompt:'a'});`; +const chain=`const a=await ctx.agent({id:'a',prompt:'a'});const b=await ctx.agent({id:'b',prompt:'b',dependsOn:['a']});`; +const broken=chain+`throw Error('bad synthesis');`; +const repaired=chain+`return {a:a.output,b:b.output};`; +test('cross-run reuse is opt-in; without the flag a second identical run makes fresh calls and reuses nothing',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const first=await run(f.engine,probe),second=await run(f.engine,probe); + assert.equal(first.status,'succeeded');assert.equal(second.status,'succeeded'); + assert.deepEqual(calls,['a','a']);assert.ok(second.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('an opted-in run adopts the newest succeeded node from an identical context without a new model call',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const source=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const end=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const step=end.steps.find(s=>s.id==='a'); + assert.equal(end.status,'succeeded');assert.equal(end.attempts,0);assert.deepEqual(calls,['a']); + assert.equal(step.attempt,0);assert.equal(step.usage,null);assert.deepEqual(step.usageHistory,[]); + assert.equal(step.reusedFrom.runId,source.id);assert.equal(step.reusedFrom.stepId,'a');assert.equal(step.reusedFrom.crossRun,true);assert.equal(typeof step.reusedFrom.endedAt,'number'); + assert.ok(f.store.events(end.id).some(e=>e.type==='step.reused'&&e.stepId==='a')); + }finally{await f.cleanup();} +}); +test('a changed input hashes to a different context so the opted-in run calls again',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const end=await run(f.engine,probe,{tenant:'other'},{reuseAcrossRuns:true}); + assert.equal(end.status,'succeeded');assert.deepEqual(calls,['a','a']);assert.ok(end.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('a changed executor hashes to a different context so the opted-in run cannot reuse; the fresh call is observable',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await run(f.engine,probe,{},{reuseAcrossRuns:true,executor:'demo'}); + const end=await run(f.engine,probe,{},{reuseAcrossRuns:true,executor:'mcode'}); + assert.equal(end.status,'succeeded');assert.deepEqual(calls,['a','a']);assert.ok(end.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('a changed prompt misses cross-run reuse even in an otherwise identical context',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const end=await run(f.engine,probe.replace("prompt:'a'","prompt:'changed'"),{},{reuseAcrossRuns:true}); + assert.equal(end.status,'succeeded');assert.deepEqual(calls,['a','a']);assert.ok(end.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('adding an output schema changes the node request hash so cross-run reuse misses',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const end=await run(f.engine,`return await ctx.agent({id:'a',prompt:'a',schema:{type:'string'}});`,{},{reuseAcrossRuns:true}); + assert.equal(end.status,'succeeded');assert.deepEqual(calls,['a','a']);assert.ok(end.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('only succeeded nodes are cross-run candidates; a failed source node is called again',async()=>{ + let fail=true;const calls=[],f=await fixture(async s=>{calls.push(s.id);if(s.id==='b'&&fail)throw Error('provider failure');return {output:s.id};});try{ + const source=await run(f.engine,broken,{},{reuseAcrossRuns:true}); + assert.equal(source.status,'failed');fail=false; + const end=await run(f.engine,repaired,{},{reuseAcrossRuns:true}); + assert.equal(end.status,'succeeded');assert.deepEqual(end.result,{a:'a',b:'b'});assert.deepEqual(calls,['a','b','b']); + assert.equal(end.steps.find(s=>s.id==='a').reusedFrom.runId,source.id);assert.equal(end.steps.find(s=>s.id==='b').reusedFrom,undefined);assert.equal(end.attempts,1); + }finally{await f.cleanup();} +}); +test('with several qualifying runs the newest succeeded run wins',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const ids=[];for(let i=0;i<3;i++){const r=await run(f.engine,probe);assert.ok(r.steps.every(s=>!s.reusedFrom));ids.push(r.id);} + assert.deepEqual(calls,['a','a','a']); + const end=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const step=end.steps.find(s=>s.id==='a'); + assert.equal(end.status,'succeeded');assert.equal(end.attempts,0);assert.deepEqual(calls,['a','a','a']); + assert.equal(step.reusedFrom.runId,ids[2]);assert.equal(step.reusedFrom.crossRun,true); + }finally{await f.cleanup();} +}); +test('pure cross-run reuse succeeds on a one-call budget without consuming it',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await run(f.engine,probe,{},{reuseAcrossRuns:true}); + const end=await run(f.engine,probe,{},{reuseAcrossRuns:true,maxCalls:1}); + assert.equal(end.status,'succeeded');assert.equal(end.attempts,0);assert.deepEqual(calls,['a']); + }finally{await f.cleanup();} +}); +test('tracked-file changes between approvals change fingerprints and the context hash so reuse misses',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + await writeFile(join(f.dir,'evidence.txt'),'X'); + await run(f.engine,probe,{files:['evidence.txt']},{reuseAcrossRuns:true}); + await writeFile(join(f.dir,'evidence.txt'),'Y'); + const end=await run(f.engine,probe,{files:['evidence.txt']},{reuseAcrossRuns:true}); + assert.equal(end.status,'succeeded');assert.deepEqual(calls,['a','a']);assert.ok(end.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('reusing a requestId with a flipped reuseAcrossRuns value is rejected as a parameter conflict',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const requestId=crypto.randomUUID(); + await f.engine.start({requestId,name:'Cross reuse',executor:'demo',script:probe,input:{}}); + await assert.rejects(f.engine.start({requestId,name:'Cross reuse',executor:'demo',script:probe,input:{},reuseAcrossRuns:true}),/requestId 已用于不同参数/); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index fd6a073..18460dc 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7714,139 +7714,7 @@ import { spawn as spawn3 } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { randomUUID } from "node:crypto"; -var Store = class { - constructor(dir) { - mkdirSync(dir, { recursive: true, mode: 448 }); - this.lock = join(dir, "owner.lock"); - try { - this.fd = openSync(this.lock, "wx", 384); - } catch (e) { - if (e.code !== "EEXIST") throw e; - let pid; - try { - pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; - } catch { - throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); - } - let alive2 = true; - try { - process.kill(pid, 0); - } catch (err) { - if (err.code === "ESRCH") alive2 = false; - } - if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); - unlinkSync(this.lock); - this.fd = openSync(this.lock, "wx", 384); - } - this.owner = randomUUID(); - try { - writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); - this.db = new DatabaseSync(join(dir, "workflows.sqlite")); - this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); - this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; - CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq);`); - const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); - for (const row of unfinished) { - const run = JSON.parse(row.body); - run.status = "needs_attention"; - run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; - this.save(run); - } - } catch (error2) { - this.db?.close(); - this.releaseLock(); - throw error2; - } - } - transaction(fn) { - this.db.exec("BEGIN IMMEDIATE"); - try { - const r = fn(); - this.db.exec("COMMIT"); - return r; - } catch (e) { - this.db.exec("ROLLBACK"); - throw e; - } - } - templates() { - return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); - } - template(id2) { - const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - saveTemplate(value) { - this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); - } - deleteTemplate(id2) { - return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; - } - setting(key) { - const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); - return row ? JSON.parse(row.body) : void 0; - } - saveSetting(key, value) { - this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); - } - save(run) { - this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); - } - get(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - byRequest(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - list() { - return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); - } - step(runId, id2) { - const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - steps(runId) { - return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); - } - saveStep(runId, step) { - this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); - } - repairCandidate(runId, id2) { - const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - saveRepairCandidate(runId, step) { - this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)); - } - event(runId, type, data2 = {}) { - const event = { ...data2, type, time: Date.now() }; - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - return { seq, ...event }; - } - events(runId, after = 0, limit = 150) { - return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); - } - releaseLock() { - closeSync(this.fd); - try { - if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); - } catch { - } - } - close() { - this.db.close(); - this.releaseLock(); - } -}; +import { createHash as createHash2, randomUUID } from "node:crypto"; // src/common.mjs import { createHash } from "node:crypto"; @@ -13584,6 +13452,197 @@ ${script} return { valid: true, scriptHash: hash(script), dslVersion: 1 }; } +// src/store.mjs +var Store = class { + constructor(dir) { + mkdirSync(dir, { recursive: true, mode: 448 }); + this.lock = join(dir, "owner.lock"); + try { + this.fd = openSync(this.lock, "wx", 384); + } catch (e) { + if (e.code !== "EEXIST") throw e; + let pid; + try { + pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; + } catch { + throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); + } + let alive2 = true; + try { + process.kill(pid, 0); + } catch (err) { + if (err.code === "ESRCH") alive2 = false; + } + if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); + unlinkSync(this.lock); + this.fd = openSync(this.lock, "wx", 384); + } + this.owner = randomUUID(); + this.txDepth = 0; + try { + writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); + this.db = new DatabaseSync(join(dir, "workflows.sqlite")); + this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); + this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); + CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); + const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); + for (const row of unfinished) { + const run = JSON.parse(row.body); + run.status = "needs_attention"; + run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; + this.save(run); + } + } catch (error2) { + this.db?.close(); + this.releaseLock(); + throw error2; + } + } + transaction(fn) { + if (this.txDepth) return fn(); + this.txDepth = 1; + this.db.exec("BEGIN IMMEDIATE"); + try { + const r = fn(); + this.db.exec("COMMIT"); + return r; + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } finally { + this.txDepth = 0; + } + } + templates() { + return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); + } + template(id2) { + const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + saveTemplate(value) { + this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); + } + deleteTemplate(id2) { + return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; + } + setting(key) { + const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); + return row ? JSON.parse(row.body) : void 0; + } + saveSetting(key, value) { + this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); + } + save(run) { + this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); + } + get(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + byRequest(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + list() { + return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); + } + step(runId, id2) { + const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + steps(runId) { + return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); + } + findCrossRunReuse({ contextHash, requestHash, excludeRunId, limit = 20 }) { + return this.db.prepare("SELECT s.body AS stepBody, r.body AS runBody, s.rowid AS ord FROM steps s JOIN runs r ON s.runId = r.id WHERE r.id <> ? AND json_extract(s.body,'$.kind')='agent' AND json_extract(s.body,'$.status')='succeeded' AND json_extract(s.body,'$.requestHash')=? ORDER BY s.rowid DESC LIMIT ?").all(excludeRunId, requestHash, limit).flatMap((r) => { + const step = JSON.parse(r.stepBody), run = JSON.parse(r.runBody); + return hash({ workspace: run.workspace, input: run.input, executor: run.executor, fingerprints: run.fingerprints }) === contextHash ? [{ runId: run.id, stepId: step.id, step }] : []; + }); + } + saveStep(runId, step) { + this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); + } + repairCandidate(runId, id2) { + const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + saveRepairCandidate(runId, step) { + this.transaction(() => { + const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); + this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); + }); + } + event(runId, type, data2 = {}) { + const event = { ...data2, type, time: Date.now() }; + return this.transaction(() => { + const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); + this.chainAdvance("event", "events", "SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => String(r.pos)); + return { seq, ...event }; + }); + } + events(runId, after = 0, limit = 150) { + return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); + } + rowHash(prev, kind, key, body) { + return createHash2("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); + } + chainAdvance(kind, surface, sql, newUpto, keyOf) { + const tail = this.setting(`integrity_${surface}`); + let prev = tail?.head ?? "0".repeat(64); + for (const r of this.db.prepare(sql).all(tail?.upto ?? 0, newUpto)) { + const k = keyOf(r); + prev = this.rowHash(prev, kind, k, r.body); + this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); + } + this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); + } + integrityHeads() { + return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; + } + verifyIntegrity() { + const genesis = "0".repeat(64); + const face = (kind, surface, table, posCol) => { + const skey = `integrity_${surface}`; + const rec = this.setting(skey); + const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); + if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; + const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); + let prev = genesis, firstDivergence = null; + for (const r of rows) { + const row = this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos); + const actual = row ? this.rowHash(prev, kind, r.key, row.body) : null; + if (!firstDivergence && (!row || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; + prev = r.hash; + } + const verified = !firstDivergence && prev === rec.head; + return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained: Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n), firstDivergence }; + }; + return { + events: face("event", "events", "events", "seq"), + repair: face("repair", "repair", "repair_cache", "rowid") + }; + } + releaseLock() { + closeSync(this.fd); + try { + if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); + } catch { + } + } + close() { + this.db.close(); + this.releaseLock(); + } +}; + // src/limits.mjs var DEFAULT_LIMITS = Object.freeze({ maxSteps: 120, stepTimeoutMs: 30 * 6e4, runTimeoutMs: 2 * 60 * 6e4 }); var LEGACY_LIMITS = Object.freeze({ maxSteps: 30, stepTimeoutMs: 10 * 6e4, runTimeoutMs: 30 * 6e4 }); @@ -14237,7 +14296,8 @@ var Engine = class extends EventEmitter { check(Number.isInteger(maxCalls) && maxCalls >= 1 && maxCalls <= 100, "\u8C03\u7528\u6570\u8303\u56F4 1\u2013100"); const limits = resolveLimits(request, this.defaults); const metadata = request.metadata === void 0 ? {} : { metadata: normalizeMetadata(request.metadata) }; - const definition = { ...limits, ...metadata, name: request.name, script: request.script, input: request.input ?? {}, executor: request.executor, concurrency, maxCalls }; + const reuseAcrossRuns = request.reuseAcrossRuns === void 0 ? false : (check(typeof request.reuseAcrossRuns === "boolean", "reuseAcrossRuns \u5FC5\u987B\u4E3A\u5E03\u5C14"), request.reuseAcrossRuns); + const definition = { ...limits, ...metadata, name: request.name, script: request.script, input: request.input ?? {}, executor: request.executor, concurrency, maxCalls, ...reuseAcrossRuns ? { reuseAcrossRuns: true } : {} }; const requestHash = hash(repair ? { ...definition, repair } : definition); const existing = this.store.byRequest(request.requestId); if (existing) { @@ -14294,7 +14354,7 @@ var Engine = class extends EventEmitter { const script = request.script ?? run.script, input = request.input ?? run.input, topology = assertValidDependencies(previewTopology(script, input)); check(input && typeof input === "object" && !Array.isArray(input), "input \u5FC5\u987B\u4E3A JSON object"); boundedJSON(input); - const name = request.name ?? run.name, executor = request.executor ?? run.executor, concurrency = request.concurrency ?? run.concurrency, maxCalls = request.maxCalls ?? run.maxCalls; + const name = request.name ?? run.name, executor = request.executor ?? run.executor, concurrency = request.concurrency ?? run.concurrency, maxCalls = request.maxCalls ?? run.maxCalls, reuseAcrossRuns = request.reuseAcrossRuns === void 0 ? run.reuseAcrossRuns : (check(typeof request.reuseAcrossRuns === "boolean", "reuseAcrossRuns \u5FC5\u987B\u4E3A\u5E03\u5C14"), request.reuseAcrossRuns); check(typeof name === "string" && name.trim().length > 0 && name.length <= 120, "\u540D\u79F0\u987B\u4E3A 1\u2013120 \u5B57\u7B26"); check(["demo", "mcode"].includes(executor), "executor \u987B\u4E3A demo \u6216 mcode"); check(Number.isInteger(concurrency) && concurrency >= 1 && concurrency <= 16, "\u5E76\u53D1\u8303\u56F4 1\u201316"); @@ -14310,7 +14370,7 @@ var Engine = class extends EventEmitter { check(repair && typeof request.reason === "string" && request.reason.trim() && request.reason.length <= 2e3, "\u8BF7\u63D0\u4F9B\u4FEE\u590D\u539F\u56E0\uFF08\u6700\u591A 2000 \u5B57\u7B26\uFF09"); repair = { ...repair, reason: request.reason.trim() }; } - Object.assign(run, ...repair ? [{ repair }] : [], resolveLimits(request, runLimits(run)), metadata, { name, script, input, executor, concurrency, maxCalls, topology, scriptHash: hash(script), revision: run.revision + 1 }); + Object.assign(run, ...repair ? [{ repair }] : [], resolveLimits(request, runLimits(run)), metadata, { name, script, input, executor, concurrency, maxCalls, reuseAcrossRuns, topology, scriptHash: hash(script), revision: run.revision + 1 }); this.save(run); this.emitEvent(id2, "run.updated", { revision: run.revision }); return this.snapshot(id2); @@ -14605,6 +14665,33 @@ var Engine = class extends EventEmitter { return Promise.resolve({ status: "succeeded", output: step2.output, cached: true }); } } + if (ctx.run.reuseAcrossRuns && !previous) { + const ctxHash = ctx.contextHash ?? (ctx.contextHash = hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints })); + for (const candidate2 of this.store.findCrossRunReuse({ contextHash: ctxHash, requestHash, excludeRunId: ctx.run.id })) { + let valid = true; + try { + if (validateOutput) valid = validateOutput(candidate2.step.output); + } catch { + valid = false; + } + if (!valid) continue; + const step2 = { + ...candidate2.step, + attempt: 0, + createdAt: Date.now(), + startedAt: null, + endedAt: candidate2.step.endedAt ?? Date.now(), + usage: null, + usageHistory: [], + sessionId: void 0, + turnId: void 0, + reusedFrom: candidate2.step.reusedFrom ?? { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: candidate2.step.endedAt ?? null, crossRun: true } + }; + this.store.saveStep(ctx.run.id, step2); + this.emitEvent(ctx.run.id, "step.reused", { stepId: spec.id, sourceRunId: candidate2.runId, crossRun: true }); + return Promise.resolve({ status: "succeeded", output: step2.output, cached: true }); + } + } check(ctx.run.attempts < ctx.run.maxCalls, `\u5DF2\u8FBE\u5230\u5DE5\u4F5C\u6D41 Agent \u603B\u8C03\u7528\u4E0A\u9650 ${ctx.run.maxCalls} \u6B21\uFF08\u5305\u62EC\u6062\u590D\u5C1D\u8BD5\uFF09\uFF0C\u8BF7\u63D0\u9AD8\u603B\u8C03\u7528\u9884\u7B97\u540E\u6062\u590D\u3002`); ctx.run.attempts++; this.save(ctx.run); @@ -14723,7 +14810,7 @@ var Engine = class extends EventEmitter { }; // src/http.mjs -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; // web/graph-model.mjs var finished = /* @__PURE__ */ new Set(["succeeded", "completed_with_gaps", "failed", "cancelled"]); @@ -26517,7 +26604,7 @@ async function startStdio(handler, tools = TOOLS) { // src/http.mjs async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import.meta.url), exampleRoot = new URL("../examples/", import.meta.url) } = {}) { - const reportStyleHash = createHash2("sha256").update(REPORT_STYLES).digest("base64"); + const reportStyleHash = createHash3("sha256").update(REPORT_STYLES).digest("base64"); let origin; const sockets = /* @__PURE__ */ new Set(); const server = http.createServer(async (req, res) => { @@ -26610,7 +26697,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import } // src/workspace-router.mjs -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { realpath as realpath2, stat as stat2 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, relative as relative2, join as join3, sep as sep2 } from "node:path"; @@ -27473,7 +27560,7 @@ async function canonicalWorkspace(value, pluginRoot) { return workspace; } function projectDataDir(base, workspace) { - return join3(base, "projects", createHash3("sha256").update(workspace).digest("hex")); + return join3(base, "projects", createHash4("sha256").update(workspace).digest("hex")); } function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) { const connections = /* @__PURE__ */ new Map(); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index aca3ae0..401bea7 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -44,7 +44,9 @@ export class Engine extends EventEmitter { check(Number.isInteger(concurrency)&&concurrency>=1&&concurrency<=16,'并发范围 1–16');check(Number.isInteger(maxCalls)&&maxCalls>=1&&maxCalls<=100,'调用数范围 1–100'); const limits=resolveLimits(request,this.defaults); const metadata=request.metadata===undefined?{}:{metadata:normalizeMetadata(request.metadata)}; - const definition={...limits,...metadata,name:request.name,script:request.script,input:request.input??{},executor:request.executor,concurrency,maxCalls};const requestHash=hash(repair?{...definition,repair}:definition); + // Absent unless explicitly true: an always-present default key would change requestHash and break legacy idempotent replays. + const reuseAcrossRuns=request.reuseAcrossRuns===undefined?false:(check(typeof request.reuseAcrossRuns==='boolean','reuseAcrossRuns 必须为布尔'),request.reuseAcrossRuns); + const definition={...limits,...metadata,name:request.name,script:request.script,input:request.input??{},executor:request.executor,concurrency,maxCalls,...(reuseAcrossRuns?{reuseAcrossRuns:true}:{})};const requestHash=hash(repair?{...definition,repair}:definition); const existing=this.store.byRequest(request.requestId);if(existing){const legacyDefinition={...definition};for(const key of Object.keys(DEFAULT_LIMITS))delete legacyDefinition[key];check(existing.requestHash===requestHash||(existing.maxSteps===undefined&&Object.keys(DEFAULT_LIMITS).every(k=>request[k]===undefined)&&existing.requestHash===hash(legacyDefinition)),'requestId 已用于不同参数');return this.snapshot(existing.id);} const fingerprints={};const topology=assertValidDependencies(previewTopology(request.script,request.input??{})); check(!this.closing,'服务正在关闭'); @@ -71,14 +73,14 @@ export class Engine extends EventEmitter { check(Number.isInteger(request.revision)&&request.revision===run.revision,'审核版本已更新,请刷新后再修改'); const script=request.script??run.script,input=request.input??run.input,topology=assertValidDependencies(previewTopology(script,input)); check(input&&typeof input==='object'&&!Array.isArray(input),'input 必须为 JSON object');boundedJSON(input); - const name=request.name??run.name,executor=request.executor??run.executor,concurrency=request.concurrency??run.concurrency,maxCalls=request.maxCalls??run.maxCalls; + const name=request.name??run.name,executor=request.executor??run.executor,concurrency=request.concurrency??run.concurrency,maxCalls=request.maxCalls??run.maxCalls,reuseAcrossRuns=request.reuseAcrossRuns===undefined?run.reuseAcrossRuns:(check(typeof request.reuseAcrossRuns==='boolean','reuseAcrossRuns 必须为布尔'),request.reuseAcrossRuns); check(typeof name==='string'&&name.trim().length>0&&name.length<=120,'名称须为 1–120 字符');check(['demo','mcode'].includes(executor),'executor 须为 demo 或 mcode'); check(Number.isInteger(concurrency)&&concurrency>=1&&concurrency<=16,'并发范围 1–16');check(Number.isInteger(maxCalls)&&maxCalls>=1&&maxCalls<=100,'调用数范围 1–100'); const metadata=request.metadata===undefined?{}:{metadata:normalizeMetadata(request.metadata)}; let repair=run.repair; if(request.reuseStepIds!==undefined){const ids=request.reuseStepIds;check(repair&&Array.isArray(ids)&&ids.length<=100&&new Set(ids).size===ids.length&&ids.every(id=>typeof id==='string'&&(repair.candidateStepIds??repair.reuseStepIds).includes(id)),'只能选择修复草稿已冻结的候选节点');repair={...repair,reuseStepIds:ids};} if(request.reason!==undefined){check(repair&&typeof request.reason==='string'&&request.reason.trim()&&request.reason.length<=2000,'请提供修复原因(最多 2000 字符)');repair={...repair,reason:request.reason.trim()};} - Object.assign(run,...(repair?[{repair}]:[]),resolveLimits(request,runLimits(run)),metadata,{name,script,input,executor,concurrency,maxCalls,topology,scriptHash:hash(script),revision:run.revision+1}); + Object.assign(run,...(repair?[{repair}]:[]),resolveLimits(request,runLimits(run)),metadata,{name,script,input,executor,concurrency,maxCalls,reuseAcrossRuns,topology,scriptHash:hash(script),revision:run.revision+1}); this.save(run);this.emitEvent(id,'run.updated',{revision:run.revision});return this.snapshot(id); } saveTemplate(id,{name,revision}={}) { @@ -196,6 +198,18 @@ export class Engine extends EventEmitter { this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:step.id,sourceRunId:repair.sourceRunId}); return Promise.resolve({status:'succeeded',output:step.output,cached:true});} } + if(ctx.run.reuseAcrossRuns&&!previous){ + // Cross-run reuse: adopt an earlier run's stored result when the node spec and the context (workspace, input, executor, tracked files) are identical. + const ctxHash=ctx.contextHash??(ctx.contextHash=hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints})); + for(const candidate of this.store.findCrossRunReuse({contextHash:ctxHash,requestHash,excludeRunId:ctx.run.id})){ + let valid=true;try{if(validateOutput)valid=validateOutput(candidate.step.output);}catch{valid=false;} + if(!valid)continue; + const step={...candidate.step,attempt:0,createdAt:Date.now(),startedAt:null,endedAt:candidate.step.endedAt??Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined, + reusedFrom:candidate.step.reusedFrom??{runId:candidate.runId,stepId:candidate.stepId,endedAt:candidate.step.endedAt??null,crossRun:true}}; + this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:spec.id,sourceRunId:candidate.runId,crossRun:true}); + return Promise.resolve({status:'succeeded',output:step.output,cached:true}); + } + } check(ctx.run.attemptsJSON.parse(r.body));} template(id) {const r=this.db.prepare('SELECT body FROM templates WHERE id=?').get(id);return r?JSON.parse(r.body):null;} saveTemplate(value) {this.db.prepare('INSERT INTO templates VALUES(?,?)').run(value.id,JSON.stringify(value));} @@ -47,11 +49,29 @@ export class Store { list() {return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map(r=>JSON.parse(r.body));} step(runId,id) {const r=this.db.prepare('SELECT body FROM steps WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} steps(runId) {return this.db.prepare('SELECT body FROM steps WHERE runId=? ORDER BY rowid').all(runId).map(r=>JSON.parse(r.body));} + findCrossRunReuse({contextHash,requestHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT s.body AS stepBody, r.body AS runBody, s.rowid AS ord FROM steps s JOIN runs r ON s.runId = r.id WHERE r.id <> ? AND json_extract(s.body,'$.kind')='agent' AND json_extract(s.body,'$.status')='succeeded' AND json_extract(s.body,'$.requestHash')=? ORDER BY s.rowid DESC LIMIT ?").all(excludeRunId,requestHash,limit).flatMap(r=>{const step=JSON.parse(r.stepBody),run=JSON.parse(r.runBody);return hash({workspace:run.workspace,input:run.input,executor:run.executor,fingerprints:run.fingerprints})===contextHash?[{runId:run.id,stepId:step.id,step}]:[];});} saveStep(runId,step) {this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body').run(runId,step.id,JSON.stringify(step));} repairCandidate(runId,id) {const r=this.db.prepare('SELECT body FROM repair_cache WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} - saveRepairCandidate(runId,step) {this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step));} - event(runId,type,data={}) {const event={...data,type,time:Date.now()};const seq=Number(this.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run(runId,JSON.stringify(event)).lastInsertRowid);return {seq,...event};} + saveRepairCandidate(runId,step) {this.transaction(()=>{const rowid=Number(this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step)).lastInsertRowid);this.chainAdvance('repair','repair','SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid',rowid,r=>`${r.runId}/${r.id}`);});} + event(runId,type,data={}) {const event={...data,type,time:Date.now()};return this.transaction(()=>{const seq=Number(this.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run(runId,JSON.stringify(event)).lastInsertRowid);this.chainAdvance('event','events','SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq',seq,r=>String(r.pos));return {seq,...event};});} events(runId,after=0,limit=150) {return this.db.prepare('SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?').all(runId,after,limit).map(e=>({seq:e.seq,...JSON.parse(e.body)}));} + rowHash(prev,kind,key,body) {return createHash('sha256').update(`${prev}:${kind}:${key}:${body}`).digest('hex');} + chainAdvance(kind,surface,sql,newUpto,keyOf) {const tail=this.setting(`integrity_${surface}`);let prev=tail?.head??'0'.repeat(64);for(const r of this.db.prepare(sql).all(tail?.upto??0,newUpto)){const k=keyOf(r);prev=this.rowHash(prev,kind,k,r.body);this.db.prepare('INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)').run(surface,r.pos,k,prev);}this.saveSetting(`integrity_${surface}`,{head:prev,upto:newUpto});} + integrityHeads() {return {events:this.setting('integrity_events')??null,repair:this.setting('integrity_repair')??null};} + verifyIntegrity() { + const genesis='0'.repeat(64);const face=(kind,surface,table,posCol)=>{ + const skey=`integrity_${surface}`;const rec=this.setting(skey);const total=Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); + if(!rec)return {head:null,upto:0,verified:null,checked:0,unchained:total,firstDivergence:null}; + const rows=this.db.prepare('SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos').all(surface); + let prev=genesis,firstDivergence=null; + for(const r of rows){const row=this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos);const actual=row?this.rowHash(prev,kind,r.key,row.body):null; + if(!firstDivergence&&(!row||actual!==r.hash))firstDivergence={key:r.key,expectedHead:r.hash,actualHead:actual}; + prev=r.hash;} + const verified=!firstDivergence&&prev===rec.head; + return {head:rec.head,upto:rec.upto,verified,checked:rows.length,unchained:Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n),firstDivergence};}; + return {events:face('event','events','events','seq'), + repair:face('repair','repair','repair_cache','rowid')}; + } releaseLock() {closeSync(this.fd);try{if(JSON.parse(readFileSync(this.lock,'utf8')).owner===this.owner)unlinkSync(this.lock);}catch{}} close() {this.db.close();this.releaseLock();} } From 1b1b1a6e9de4860533dfce3e2f3d5f1f3c6dcf87 Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 13:22:20 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20linea?= =?UTF-8?q?ge,=20schema,=20model=20gate,=20LIMIT=20pushdown,=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reuseAcrossRuns added to workflow_start/workflow_update MCP schemas (additionalProperties:false previously made the flag undiscoverable) - lineageHash per step (requestHash + dependency lineage closure); cross-run lookup requires lineage equality — a changed/rerun upstream now invalidates downstream reuse (maintainer's reproduced R1/R2 case, regression-tested) - mcode nodes without an explicit spec.model are never cross-run candidates (resolved CLI default model/config is not part of any cache key) - contextHash persisted on steps and filtered in SQL; LIMIT 20 can no longer hide older valid candidates behind newer other-context rows - adoption always writes immediate-source reusedFrom (crossRun:true) plus originalProducer (first producer), matching the emitted event - checkpoints carry lineageHash so value changes break downstream lineage --- local-ci.mjs | 62 +++ .../mcode-dynamic-workflows/README.md | 2 +- .../checks/cross-reuse.check.mjs | 49 ++ .../mcode-dynamic-workflows/dist/main.mjs | 419 +++++++++--------- .../mcode-dynamic-workflows/src/engine.mjs | 36 +- .../mcode-dynamic-workflows/src/store.mjs | 7 +- .../mcode-dynamic-workflows/src/tools.mjs | 4 +- remote-ci.sh | 37 ++ 8 files changed, 398 insertions(+), 218 deletions(-) create mode 100644 local-ci.mjs create mode 100755 remote-ci.sh diff --git a/local-ci.mjs b/local-ci.mjs new file mode 100644 index 0000000..961090e --- /dev/null +++ b/local-ci.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// local-ci.mjs — Local mirror of the repository CI gates. UNTRACKED helper: never commit. +// Mirrors: +// CI / validate (ubuntu-latest) -> clean-checkout `npm run check` (validate + full suite) +// Dynamic Workflow source-and-package -> build reproducibility + packaged smoke +// plugin extras -> verify-claims (when present on the branch) +// GitHub-only (cannot run locally): CodeQL, Windows process-lifecycle job. +// Usage: node local-ci.mjs [--quick] (--quick skips the full root suite) +import { spawnSync } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const root = resolve(import.meta.dirname); +const plugin = join(root, 'plugins/hetaoBackend/mcode-dynamic-workflows'); +const nm = join(plugin, 'node_modules'); +const quick = process.argv.includes('--quick'); +let failed = ''; + +const step = (name, cwd, fn) => { + process.stdout.write(`\n=== ${name} ===\n`); + if (failed) { console.log(`SKIP ${name} (earlier failure: ${failed})`); return; } + const code = fn(); + if (code !== 0) failed = name; + console.log(`${code === 0 ? 'PASS' : 'FAIL'} ${name}`); +}; +const run = (cmd, args, cwd = root) => spawnSync(cmd, args, { cwd, stdio: 'inherit' }).status ?? 1; + +const major = Number(process.versions.node.split('.')[0]); +if (major !== 22) { + console.log(`NOTE: CI runs Node 22 (ubuntu-latest); local Node is ${process.versions.node}. Version drift possible.`); +} +if (!existsSync(join(plugin, 'package.json'))) { + console.error(`plugin not found: ${plugin}`); process.exit(2); +} + +step('plugin source suite (node --test checks/*.check.mjs)', plugin, + () => run('npm', ['test'], plugin)); +step('packaged MCP smoke (test:package)', plugin, + () => run('npm', ['run', 'test:package'], plugin)); +step('bundle build (build.mjs)', plugin, + () => run('npm', ['run', 'build'], plugin)); +step('bundle reproducibility (git diff --exit-code)', plugin, + () => run('git', ['diff', '--exit-code', '--', 'dist', 'web', 'THIRD_PARTY_NOTICES.txt'], plugin)); +if (existsSync(join(plugin, 'scripts/verify-claims.mjs'))) { + step('mechanical claims (verify-claims)', plugin, + () => run('node', ['scripts/verify-claims.mjs'], plugin)); +} +// Clean-checkout simulation: the validator rejects plugin node_modules (symlink rule), +// and the root suite must pass without it — same as the CI runner. +step('stash plugin node_modules (clean-checkout simulate)', root, () => { + if (existsSync(nm)) rmSync(nm, { recursive: true, force: true }); + return 0; +}); +if (!quick) step('root gate: npm run check (validate + full suite)', root, + () => run('npm', ['run', 'check'], root)); +else console.log('\n=== SKIP root gate (--quick) ==='); +step('restore plugin node_modules (npm ci)', plugin, + () => run('npm', ['ci'], plugin)); + +console.log(`\n=== local-ci summary: ${failed ? `FAILED at ${failed}` : 'all gates green'} ===`); +if (failed) console.log('GitHub-only gates not mirrored here: CodeQL, Windows process-lifecycle.'); +process.exit(failed ? 1 : 0); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index 4449b41..495e8f5 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md @@ -47,7 +47,7 @@ The repaired script runs from its beginning; checkpoints are recomputed and unre ## Cross-run reuse -Stored results are normally reused only within a run (resume) or through explicit repair selection. Passing `reuseAcrossRuns: true` to `workflow_start`, or setting it while a draft is pending review, additionally lets a node adopt a stored result from an earlier run of the same project. Adoption requires the node's full spec to hash identically (prompt, model, effort, input, schema, dependencies) and the run context to match on all four keys — workspace, input, executor and tracked-file fingerprints. Adopted outputs are re-validated against the node's current schema; the step records provenance in `reusedFrom.crossRun: true` with the source run and step, emits a `step.reused` event, and does not consume the workflow's agent-call budget. +Stored results are normally reused only within a run (resume) or through explicit repair selection. Passing `reuseAcrossRuns: true` to `workflow_start`, or setting it while a draft is pending review, additionally lets a node adopt a stored result from an earlier run of the same project. Adoption requires the node's full spec to hash identically (prompt, model, effort, input, schema, dependencies) and the run context to match on all four keys — workspace, input, executor and tracked-file fingerprints. The node's upstream lineage must match too: every step carries a lineage hash over its own spec and the lineage hashes of its declared dependencies in order, so a changed upstream (for example an edited upstream prompt) invalidates downstream candidates even when their own specs are unchanged. mcode nodes must declare an explicit model to be cross-run candidates — the default model comes from the CLI environment and is not part of the match key. Only runs executed with this feature stamp the context and lineage hashes on their steps, so legacy runs are not candidates. Adopted outputs are re-validated against the node's current schema; the step records provenance in `reusedFrom` — the immediate source run and step, `crossRun: true` — and in `originalProducer`, the run and step that originally produced the output, so chained adoptions stay traceable to their origin; it emits a `step.reused` event, and does not consume the workflow's agent-call budget. Reuse proves only that the context was identical and that the stored result was carried over faithfully — it does not prove the original run's output was semantically correct. For critical nodes, prefer schemas with evidence fields or place an independent verification node downstream, and keep `reuseAcrossRuns` off when in doubt. diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs index 9236efc..68acb79 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs @@ -6,6 +6,7 @@ import {join} from 'node:path'; import {setTimeout as delay} from 'node:timers/promises'; import {Store} from '../src/store.mjs'; import {Engine} from '../src/engine.mjs'; +import {TOOLS} from '../src/tools.mjs'; // SDD contract suite for run-level reuseAcrossRuns (cross-run reuse of succeeded // agent nodes). The engine/store behavior specified here may not exist yet; this // file is the contract the implementation must satisfy. @@ -104,3 +105,51 @@ test('reusing a requestId with a flipped reuseAcrossRuns value is rejected as a await assert.rejects(f.engine.start({requestId,name:'Cross reuse',executor:'demo',script:probe,input:{},reuseAcrossRuns:true}),/requestId 已用于不同参数/); }finally{await f.cleanup();} }); +test('a changed upstream prompt invalidates the downstream candidate even though its own spec is unchanged',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.prompt};});try{ + const first=await run(f.engine,repaired,{},{reuseAcrossRuns:true}); + assert.equal(first.status,'succeeded');assert.deepEqual(first.result,{a:'a',b:'b'});assert.deepEqual(calls,['a','b']); + const second=await run(f.engine,repaired.replace("prompt:'a'","prompt:'new'"),{},{reuseAcrossRuns:true}); + assert.equal(second.status,'succeeded'); + assert.deepEqual(calls,['a','b','a','b']); + assert.deepEqual(second.result,{a:'new',b:'b'}); + assert.ok(second.steps.every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('mcode nodes without an explicit model are never cross-run candidates; with an explicit model they are',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.model??'default'};});try{ + const opts={reuseAcrossRuns:true,executor:'mcode'}; + const withModel=`return await ctx.agent({id:'a',prompt:'a',model:'m2'});`; + const r1=await run(f.engine,withModel,{},opts),r2=await run(f.engine,withModel,{},opts); + assert.equal(r1.status,'succeeded');assert.equal(r2.status,'succeeded'); + assert.deepEqual(calls,['a']);assert.equal(r2.attempts,0); + assert.equal(r2.steps.find(s=>s.id==='a').reusedFrom.crossRun,true); + const r3=await run(f.engine,probe,{},opts),r4=await run(f.engine,probe,{},opts); + assert.equal(r3.status,'succeeded');assert.equal(r4.status,'succeeded'); + assert.equal(r3.attempts,1);assert.equal(r4.attempts,1);assert.deepEqual(calls,['a','a','a']); + assert.ok([...r3.steps,...r4.steps].every(s=>!s.reusedFrom)); + }finally{await f.cleanup();} +}); +test('workflow_start and workflow_update expose reuseAcrossRuns as a boolean parameter',()=>{ + for(const name of ['workflow_start','workflow_update']){ + const tool=TOOLS.find(t=>t.name===name); + assert.ok(tool,`${name} missing from TOOLS`); + assert.equal(tool.inputSchema.additionalProperties,false); + assert.equal(tool.inputSchema.properties.reuseAcrossRuns.type,'boolean'); + assert.equal(typeof tool.inputSchema.properties.reuseAcrossRuns.description,'string'); + } +}); +test('chained adoption keeps reusedFrom on the immediate source and originalProducer on the first producer',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const run1=await run(f.engine,repaired,{},{reuseAcrossRuns:true}); + const run2=await run(f.engine,repaired,{},{reuseAcrossRuns:true}); + const run3=await run(f.engine,repaired,{},{reuseAcrossRuns:true}); + assert.deepEqual(calls,['a','b']);assert.equal(run3.attempts,0); + const b1=run1.steps.find(s=>s.id==='b'),b2=run2.steps.find(s=>s.id==='b'),b3=run3.steps.find(s=>s.id==='b'); + assert.equal(b2.reusedFrom.runId,run1.id);assert.equal(b2.reusedFrom.crossRun,true); + assert.equal(b2.originalProducer.runId,run1.id);assert.equal(b2.originalProducer.stepId,'b'); + assert.equal(b3.reusedFrom.runId,run2.id);assert.equal(b3.reusedFrom.stepId,'b');assert.equal(b3.reusedFrom.crossRun,true); + assert.equal(b3.originalProducer.runId,run1.id);assert.equal(b3.originalProducer.stepId,'b'); + assert.equal(b3.usage,null);assert.deepEqual(b3.usageHistory,[]); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 18460dc..ee9e9f3 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7714,10 +7714,203 @@ import { spawn as spawn3 } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { createHash as createHash2, randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +var Store = class { + constructor(dir) { + mkdirSync(dir, { recursive: true, mode: 448 }); + this.lock = join(dir, "owner.lock"); + try { + this.fd = openSync(this.lock, "wx", 384); + } catch (e) { + if (e.code !== "EEXIST") throw e; + let pid; + try { + pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; + } catch { + throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); + } + let alive2 = true; + try { + process.kill(pid, 0); + } catch (err) { + if (err.code === "ESRCH") alive2 = false; + } + if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); + unlinkSync(this.lock); + this.fd = openSync(this.lock, "wx", 384); + } + this.owner = randomUUID(); + this.txDepth = 0; + try { + writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); + this.db = new DatabaseSync(join(dir, "workflows.sqlite")); + this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); + this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); + CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); + const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); + for (const row of unfinished) { + const run = JSON.parse(row.body); + run.status = "needs_attention"; + run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; + this.save(run); + } + } catch (error2) { + this.db?.close(); + this.releaseLock(); + throw error2; + } + } + transaction(fn) { + if (this.txDepth) return fn(); + this.txDepth = 1; + this.db.exec("BEGIN IMMEDIATE"); + try { + const r = fn(); + this.db.exec("COMMIT"); + return r; + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } finally { + this.txDepth = 0; + } + } + templates() { + return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); + } + template(id2) { + const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + saveTemplate(value) { + this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); + } + deleteTemplate(id2) { + return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; + } + setting(key) { + const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); + return row ? JSON.parse(row.body) : void 0; + } + saveSetting(key, value) { + this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); + } + save(run) { + this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); + } + get(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + byRequest(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + list() { + return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); + } + step(runId, id2) { + const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + steps(runId) { + return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); + } + // All match keys (contextHash, lineageHash) are stamped on the step body at + // creation, so filtering happens in SQL and LIMIT applies after the full match. + // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is + // an opt-in feature and older steps are not candidates. + findCrossRunReuse({ contextHash, requestHash, lineageHash, excludeRunId, limit = 20 }) { + return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId, requestHash, contextHash, lineageHash, limit).map((r) => { + const step = JSON.parse(r.stepBody); + return { runId: r.runId, stepId: step.id, step }; + }); + } + saveStep(runId, step) { + this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); + } + repairCandidate(runId, id2) { + const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + saveRepairCandidate(runId, step) { + this.transaction(() => { + const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); + this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); + }); + } + event(runId, type, data2 = {}) { + const event = { ...data2, type, time: Date.now() }; + return this.transaction(() => { + const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); + this.chainAdvance("event", "events", "SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => String(r.pos)); + return { seq, ...event }; + }); + } + events(runId, after = 0, limit = 150) { + return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); + } + rowHash(prev, kind, key, body) { + return createHash("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); + } + chainAdvance(kind, surface, sql, newUpto, keyOf) { + const tail = this.setting(`integrity_${surface}`); + let prev = tail?.head ?? "0".repeat(64); + for (const r of this.db.prepare(sql).all(tail?.upto ?? 0, newUpto)) { + const k = keyOf(r); + prev = this.rowHash(prev, kind, k, r.body); + this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); + } + this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); + } + integrityHeads() { + return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; + } + verifyIntegrity() { + const genesis = "0".repeat(64); + const face = (kind, surface, table, posCol) => { + const skey = `integrity_${surface}`; + const rec = this.setting(skey); + const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); + if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; + const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); + let prev = genesis, firstDivergence = null; + for (const r of rows) { + const row = this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos); + const actual = row ? this.rowHash(prev, kind, r.key, row.body) : null; + if (!firstDivergence && (!row || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; + prev = r.hash; + } + const verified = !firstDivergence && prev === rec.head; + return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained: Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n), firstDivergence }; + }; + return { + events: face("event", "events", "events", "seq"), + repair: face("repair", "repair", "repair_cache", "rowid") + }; + } + releaseLock() { + closeSync(this.fd); + try { + if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); + } catch { + } + } + close() { + this.db.close(); + this.releaseLock(); + } +}; // src/common.mjs -import { createHash } from "node:crypto"; +import { createHash as createHash2 } from "node:crypto"; // node_modules/acorn/dist/acorn.mjs var astralIdentifierCodes = [509, 0, 227, 0, 150, 4, 294, 9, 1368, 2, 2, 1, 6, 3, 41, 2, 5, 0, 166, 1, 574, 3, 9, 9, 7, 9, 32, 4, 318, 1, 78, 5, 71, 10, 50, 3, 123, 2, 54, 14, 32, 10, 3, 1, 11, 3, 46, 10, 8, 0, 46, 9, 7, 2, 37, 13, 2, 9, 6, 1, 45, 0, 13, 2, 49, 13, 9, 3, 2, 11, 83, 11, 7, 0, 3, 0, 158, 11, 6, 9, 7, 3, 56, 1, 2, 6, 3, 1, 3, 2, 10, 0, 11, 1, 3, 6, 4, 4, 68, 8, 2, 0, 3, 0, 2, 3, 2, 4, 2, 0, 15, 1, 83, 17, 10, 9, 5, 0, 82, 19, 13, 9, 214, 6, 3, 8, 28, 1, 83, 16, 16, 9, 82, 12, 9, 9, 7, 19, 58, 14, 5, 9, 243, 14, 166, 9, 71, 5, 2, 1, 3, 3, 2, 0, 2, 1, 13, 9, 120, 6, 3, 6, 4, 0, 29, 9, 41, 6, 2, 3, 9, 0, 10, 10, 47, 15, 199, 7, 137, 9, 54, 7, 2, 7, 17, 9, 57, 21, 2, 13, 123, 5, 4, 0, 2, 1, 2, 6, 2, 0, 9, 9, 49, 4, 2, 1, 2, 4, 9, 9, 55, 9, 266, 3, 10, 1, 2, 0, 49, 6, 4, 4, 14, 10, 5350, 0, 7, 14, 11465, 27, 2343, 9, 87, 9, 39, 4, 60, 6, 26, 9, 535, 9, 470, 0, 2, 54, 8, 3, 82, 0, 12, 1, 19628, 1, 4178, 9, 519, 45, 3, 22, 543, 4, 4, 5, 9, 7, 3, 6, 31, 3, 149, 2, 1418, 49, 513, 54, 5, 49, 9, 0, 15, 0, 23, 4, 2, 14, 1361, 6, 2, 16, 3, 6, 2, 1, 2, 4, 101, 0, 161, 6, 10, 9, 357, 0, 62, 13, 499, 13, 245, 1, 2, 9, 233, 0, 3, 0, 8, 1, 6, 0, 475, 6, 110, 6, 6, 9, 4759, 9, 787719, 239]; @@ -13417,7 +13610,7 @@ function parse3(input, options) { } // src/common.mjs -var hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); +var hash = (value) => createHash2("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); function stable(value) { return JSON.stringify(canonical(value)); } @@ -13452,197 +13645,6 @@ ${script} return { valid: true, scriptHash: hash(script), dslVersion: 1 }; } -// src/store.mjs -var Store = class { - constructor(dir) { - mkdirSync(dir, { recursive: true, mode: 448 }); - this.lock = join(dir, "owner.lock"); - try { - this.fd = openSync(this.lock, "wx", 384); - } catch (e) { - if (e.code !== "EEXIST") throw e; - let pid; - try { - pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; - } catch { - throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); - } - let alive2 = true; - try { - process.kill(pid, 0); - } catch (err) { - if (err.code === "ESRCH") alive2 = false; - } - if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); - unlinkSync(this.lock); - this.fd = openSync(this.lock, "wx", 384); - } - this.owner = randomUUID(); - this.txDepth = 0; - try { - writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); - this.db = new DatabaseSync(join(dir, "workflows.sqlite")); - this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); - this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; - CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); - CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); - const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); - for (const row of unfinished) { - const run = JSON.parse(row.body); - run.status = "needs_attention"; - run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; - this.save(run); - } - } catch (error2) { - this.db?.close(); - this.releaseLock(); - throw error2; - } - } - transaction(fn) { - if (this.txDepth) return fn(); - this.txDepth = 1; - this.db.exec("BEGIN IMMEDIATE"); - try { - const r = fn(); - this.db.exec("COMMIT"); - return r; - } catch (e) { - this.db.exec("ROLLBACK"); - throw e; - } finally { - this.txDepth = 0; - } - } - templates() { - return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); - } - template(id2) { - const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - saveTemplate(value) { - this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); - } - deleteTemplate(id2) { - return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; - } - setting(key) { - const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); - return row ? JSON.parse(row.body) : void 0; - } - saveSetting(key, value) { - this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); - } - save(run) { - this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); - } - get(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - byRequest(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - list() { - return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); - } - step(runId, id2) { - const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - steps(runId) { - return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); - } - findCrossRunReuse({ contextHash, requestHash, excludeRunId, limit = 20 }) { - return this.db.prepare("SELECT s.body AS stepBody, r.body AS runBody, s.rowid AS ord FROM steps s JOIN runs r ON s.runId = r.id WHERE r.id <> ? AND json_extract(s.body,'$.kind')='agent' AND json_extract(s.body,'$.status')='succeeded' AND json_extract(s.body,'$.requestHash')=? ORDER BY s.rowid DESC LIMIT ?").all(excludeRunId, requestHash, limit).flatMap((r) => { - const step = JSON.parse(r.stepBody), run = JSON.parse(r.runBody); - return hash({ workspace: run.workspace, input: run.input, executor: run.executor, fingerprints: run.fingerprints }) === contextHash ? [{ runId: run.id, stepId: step.id, step }] : []; - }); - } - saveStep(runId, step) { - this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); - } - repairCandidate(runId, id2) { - const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - saveRepairCandidate(runId, step) { - this.transaction(() => { - const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); - this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); - }); - } - event(runId, type, data2 = {}) { - const event = { ...data2, type, time: Date.now() }; - return this.transaction(() => { - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - this.chainAdvance("event", "events", "SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => String(r.pos)); - return { seq, ...event }; - }); - } - events(runId, after = 0, limit = 150) { - return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); - } - rowHash(prev, kind, key, body) { - return createHash2("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); - } - chainAdvance(kind, surface, sql, newUpto, keyOf) { - const tail = this.setting(`integrity_${surface}`); - let prev = tail?.head ?? "0".repeat(64); - for (const r of this.db.prepare(sql).all(tail?.upto ?? 0, newUpto)) { - const k = keyOf(r); - prev = this.rowHash(prev, kind, k, r.body); - this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); - } - this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); - } - integrityHeads() { - return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; - } - verifyIntegrity() { - const genesis = "0".repeat(64); - const face = (kind, surface, table, posCol) => { - const skey = `integrity_${surface}`; - const rec = this.setting(skey); - const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); - if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; - const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); - let prev = genesis, firstDivergence = null; - for (const r of rows) { - const row = this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos); - const actual = row ? this.rowHash(prev, kind, r.key, row.body) : null; - if (!firstDivergence && (!row || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; - prev = r.hash; - } - const verified = !firstDivergence && prev === rec.head; - return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained: Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n), firstDivergence }; - }; - return { - events: face("event", "events", "events", "seq"), - repair: face("repair", "repair", "repair_cache", "rowid") - }; - } - releaseLock() { - closeSync(this.fd); - try { - if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); - } catch { - } - } - close() { - this.db.close(); - this.releaseLock(); - } -}; - // src/limits.mjs var DEFAULT_LIMITS = Object.freeze({ maxSteps: 120, stepTimeoutMs: 30 * 6e4, runTimeoutMs: 2 * 60 * 6e4 }); var LEGACY_LIMITS = Object.freeze({ maxSteps: 30, stepTimeoutMs: 10 * 6e4, runTimeoutMs: 30 * 6e4 }); @@ -14605,7 +14607,8 @@ var Engine = class extends EventEmitter { check(old.requestHash === hash(payload), "checkpoint \u53C2\u6570\u51B2\u7A81"); return old.output; } - const step = { id: key, kind: "checkpoint", status: "succeeded", output: payload.value, requestHash: hash(payload), label: payload.id, dependsOn: [], createdAt: Date.now() }; + const checkpointHash = hash(payload); + const step = { id: key, kind: "checkpoint", status: "succeeded", output: payload.value, requestHash: checkpointHash, lineageHash: hash({ requestHash: checkpointHash, deps: [] }), label: payload.id, dependsOn: [], createdAt: Date.now() }; this.store.saveStep(ctx.run.id, step); this.emitEvent(ctx.run.id, "checkpoint", { stepId: key }); return payload.value; @@ -14635,6 +14638,11 @@ var Engine = class extends EventEmitter { check(cached2.hash === requestHash, "\u91CD\u590D step id \u53C2\u6570\u51B2\u7A81"); return cached2.promise; } + const ctxHash = ctx.contextHash ?? (ctx.contextHash = hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints })); + const lineageHash = hash({ requestHash, deps: deps.map((id2) => { + const dep = this.store.step(ctx.run.id, id2); + return { id: id2, lineageHash: dep?.lineageHash ?? null }; + }) }); const repair = ctx.run.repair, candidate = !previous && repair?.reuseStepIds.includes(spec.id) ? this.store.repairCandidate(ctx.run.id, spec.id) : null; if (candidate && candidate.requestHash === requestHash && repair.contextHash === hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints }) && deps.every((id2) => { const dep = this.store.step(ctx.run.id, id2); @@ -14658,6 +14666,7 @@ var Engine = class extends EventEmitter { usageHistory: [], sessionId: void 0, turnId: void 0, + contextHash: ctxHash, reusedFrom: { runId: repair.sourceRunId, stepId: spec.id, endedAt: candidate.endedAt ?? null } }; this.store.saveStep(ctx.run.id, step2); @@ -14665,9 +14674,8 @@ var Engine = class extends EventEmitter { return Promise.resolve({ status: "succeeded", output: step2.output, cached: true }); } } - if (ctx.run.reuseAcrossRuns && !previous) { - const ctxHash = ctx.contextHash ?? (ctx.contextHash = hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints })); - for (const candidate2 of this.store.findCrossRunReuse({ contextHash: ctxHash, requestHash, excludeRunId: ctx.run.id })) { + if (ctx.run.reuseAcrossRuns && !previous && !(ctx.run.executor === "mcode" && !spec.model)) { + for (const candidate2 of this.store.findCrossRunReuse({ contextHash: ctxHash, requestHash, lineageHash, excludeRunId: ctx.run.id })) { let valid = true; try { if (validateOutput) valid = validateOutput(candidate2.step.output); @@ -14675,17 +14683,20 @@ var Engine = class extends EventEmitter { valid = false; } if (!valid) continue; + const source = candidate2.step; const step2 = { - ...candidate2.step, + ...source, attempt: 0, createdAt: Date.now(), startedAt: null, - endedAt: candidate2.step.endedAt ?? Date.now(), + endedAt: source.endedAt ?? Date.now(), usage: null, usageHistory: [], sessionId: void 0, turnId: void 0, - reusedFrom: candidate2.step.reusedFrom ?? { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: candidate2.step.endedAt ?? null, crossRun: true } + contextHash: ctxHash, + reusedFrom: { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: source.endedAt ?? null, crossRun: true }, + originalProducer: source.originalProducer ?? (source.reusedFrom ? { ...source.reusedFrom } : { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: source.endedAt ?? null, crossRun: true }) }; this.store.saveStep(ctx.run.id, step2); this.emitEvent(ctx.run.id, "step.reused", { stepId: spec.id, sourceRunId: candidate2.runId, crossRun: true }); @@ -14695,7 +14706,7 @@ var Engine = class extends EventEmitter { check(ctx.run.attempts < ctx.run.maxCalls, `\u5DF2\u8FBE\u5230\u5DE5\u4F5C\u6D41 Agent \u603B\u8C03\u7528\u4E0A\u9650 ${ctx.run.maxCalls} \u6B21\uFF08\u5305\u62EC\u6062\u590D\u5C1D\u8BD5\uFF09\uFF0C\u8BF7\u63D0\u9AD8\u603B\u8C03\u7528\u9884\u7B97\u540E\u6062\u590D\u3002`); ctx.run.attempts++; this.save(ctx.run); - const step = { id: spec.id, ...typeof planId === "string" ? { planId } : {}, label: spec.label ?? spec.id, phase: spec.phase ?? null, kind: "agent", dependsOn: deps, requestHash, status: "queued", attempt: (previous?.attempt ?? 0) + 1, createdAt: previous?.createdAt ?? Date.now(), startedAt: null, endedAt: null, prompt: spec.prompt, input: spec.input ?? null, output: null, error: null, errorDetails: null, ...runLimits(ctx.run), timeoutMs: runLimits(ctx.run).stepTimeoutMs, usage: null, usageHistory: [...previous?.usageHistory ?? [], ...previous?.usage ? [previous.usage] : []] }; + const step = { id: spec.id, ...typeof planId === "string" ? { planId } : {}, label: spec.label ?? spec.id, phase: spec.phase ?? null, kind: "agent", dependsOn: deps, requestHash, contextHash: ctxHash, lineageHash, status: "queued", attempt: (previous?.attempt ?? 0) + 1, createdAt: previous?.createdAt ?? Date.now(), startedAt: null, endedAt: null, prompt: spec.prompt, input: spec.input ?? null, output: null, error: null, errorDetails: null, ...runLimits(ctx.run), timeoutMs: runLimits(ctx.run).stepTimeoutMs, usage: null, usageHistory: [...previous?.usageHistory ?? [], ...previous?.usage ? [previous.usage] : []] }; this.store.saveStep(ctx.run.id, step); this.emitEvent(ctx.run.id, "step.queued", { stepId: step.id }); const promise = (async () => { @@ -26516,8 +26527,8 @@ var string3 = { type: "string" }; var id = { runId: string3 }; var TOOLS = [ { name: "workflow_validate", description: "\u9759\u6001\u68C0\u67E5\u811A\u672C\u5E76\u751F\u6210\u7ED3\u6784\u62D3\u6251\uFF0C\u4E0D\u6267\u884C\u811A\u672C\u6216 Agent\u3002DSL: await ctx.phase({id,label}); await ctx.agent({id,label,phase,dependsOn,prompt,input,schema}); ctx.map(items,fn); ctx.log(message,{stepId,phase}); ctx.checkpoint(id,value)\u3002dependsOn \u53EF\u4E3A\u5355\u4E2A ID \u5B57\u7B26\u4E32\u6216 ID \u6570\u7EC4\uFF0C\u63A8\u8350\u6570\u7EC4\uFF1B\u4F9D\u8D56\u987B\u5148\u6210\u529F\u3002agent \u8FD4\u56DE status/output/error\uFF1B\u987B\u663E\u5F0F\u5904\u7406\u5931\u8D25\u3002", inputSchema: obj({ script: string3 }, ["script"]) }, - { name: "workflow_start", description: "\u521B\u5EFA\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u548C\u7ED3\u6784\u62D3\u6251\uFF0C\u4E0D\u6267\u884C Agent\u3002\u5FC5\u987B\u63D0\u4F9B\u9762\u677F\u8BA9\u7528\u6237\u5BA1\u9605\u3001\u4FEE\u6539\u5E76\u70B9\u51FB\u5F00\u59CB\u6267\u884C\u3002mcode \u6A21\u5F0F\u4F1A\u542F\u52A8\u771F\u5B9E MCode\uFF0C\u4F1A\u4F7F\u7528\u5DF2\u767B\u5F55\u8EAB\u4EFD\u4E0E smart \u6743\u9650\uFF0C\u4E0D\u63D0\u4F9B\u53EA\u8BFB OS \u6C99\u7BB1\u3002demo \u6A21\u5F0F\u4E0D\u8C03\u7528\u6A21\u578B\u3002\u663E\u5F0F requestId \u5E42\u7B49\u3002", inputSchema: obj({ requestId: string3, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, ...LIMIT_SCHEMAS }, ["requestId", "name", "script", "executor"]) }, - { name: "workflow_update", description: "\u4FEE\u6539\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u7684\u811A\u672C\u3001\u8F93\u5165\u6216\u9884\u7B97\u5E76\u91CD\u5EFA\u62D3\u6251\uFF0C\u4FDD\u5B58\u540E\u4ECD\u5F85\u5BA1\u6838\uFF1Brevision \u5FC5\u987B\u5339\u914D\u5F53\u524D\u7248\u672C\u3002\u4E0D\u53EF\u4FEE\u6539\u5DF2\u5F00\u59CB\u7684\u8FD0\u884C\u3002", inputSchema: obj({ ...id, revision: { type: "integer", minimum: 1 }, reason: { type: "string", maxLength: 2e3 }, reuseStepIds: { type: "array", items: string3, maxItems: 100, uniqueItems: true }, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, ...LIMIT_SCHEMAS }, ["runId", "revision"]) }, + { name: "workflow_start", description: "\u521B\u5EFA\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u548C\u7ED3\u6784\u62D3\u6251\uFF0C\u4E0D\u6267\u884C Agent\u3002\u5FC5\u987B\u63D0\u4F9B\u9762\u677F\u8BA9\u7528\u6237\u5BA1\u9605\u3001\u4FEE\u6539\u5E76\u70B9\u51FB\u5F00\u59CB\u6267\u884C\u3002mcode \u6A21\u5F0F\u4F1A\u542F\u52A8\u771F\u5B9E MCode\uFF0C\u4F1A\u4F7F\u7528\u5DF2\u767B\u5F55\u8EAB\u4EFD\u4E0E smart \u6743\u9650\uFF0C\u4E0D\u63D0\u4F9B\u53EA\u8BFB OS \u6C99\u7BB1\u3002demo \u6A21\u5F0F\u4E0D\u8C03\u7528\u6A21\u578B\u3002\u663E\u5F0F requestId \u5E42\u7B49\u3002", inputSchema: obj({ requestId: string3, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, reuseAcrossRuns: { type: "boolean", description: "Opt-in: adopt succeeded nodes from prior runs in the same workspace when context and spec hashes match" }, ...LIMIT_SCHEMAS }, ["requestId", "name", "script", "executor"]) }, + { name: "workflow_update", description: "\u4FEE\u6539\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u7684\u811A\u672C\u3001\u8F93\u5165\u6216\u9884\u7B97\u5E76\u91CD\u5EFA\u62D3\u6251\uFF0C\u4FDD\u5B58\u540E\u4ECD\u5F85\u5BA1\u6838\uFF1Brevision \u5FC5\u987B\u5339\u914D\u5F53\u524D\u7248\u672C\u3002\u4E0D\u53EF\u4FEE\u6539\u5DF2\u5F00\u59CB\u7684\u8FD0\u884C\u3002", inputSchema: obj({ ...id, revision: { type: "integer", minimum: 1 }, reason: { type: "string", maxLength: 2e3 }, reuseStepIds: { type: "array", items: string3, maxItems: 100, uniqueItems: true }, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, reuseAcrossRuns: { type: "boolean", description: "Opt-in: adopt succeeded nodes from prior runs in the same workspace when context and spec hashes match" }, ...LIMIT_SCHEMAS }, ["runId", "revision"]) }, { name: "workflow_repair", description: "\u57FA\u4E8E\u505C\u6B62\u540E\u7684\u8FD0\u884C\u521B\u5EFA\u4FEE\u590D\u8349\u7A3F\uFF0C\u4FDD\u7559\u6E90\u8FD0\u884C\uFF1B\u63D0\u4F9B\u5B8C\u6574\u4FEE\u590D\u811A\u672C\u3001\u5931\u8D25\u539F\u56E0\u4E0E sourceUpdatedAt\u3002\u663E\u5F0F reuseStepIds \u4EC5\u9009\u62E9\u786E\u8BA4\u4ECD\u9002\u7528\u7684\u6210\u529F\u8282\u70B9\uFF0C\u9ED8\u8BA4\u4E0D\u590D\u7528\u3002\u8FD0\u884C\u65F6\u91CD\u65B0\u6821\u9A8C\u8F93\u5165\u3001\u6587\u4EF6\u3001\u53C2\u6570\u4E0E\u4F9D\u8D56\uFF1B\u53D8\u66F4\u6216\u91CD\u8DD1\u7684\u4E0A\u6E38\u4F7F\u4E0B\u6E38\u5931\u6548\u3002\u5FC5\u987B\u6253\u5F00\u9762\u677F\u4EA4\u7528\u6237\u5BA1\u6838\u540E\u5F00\u59CB\uFF0C\u4E0D\u80FD\u81EA\u52A8\u6267\u884C\u3002", inputSchema: obj({ ...id, requestId: string3, sourceUpdatedAt: { type: "integer" }, script: string3, reason: { type: "string", maxLength: 2e3 }, reuseStepIds: { type: "array", items: string3, maxItems: 100, uniqueItems: true }, input: { type: "object" }, ...LIMIT_SCHEMAS, maxCalls: { type: "integer", minimum: 1, maximum: 100 } }, ["runId", "requestId", "sourceUpdatedAt", "script", "reason"]) }, { name: "workflow_status", description: "\u8BFB\u53D6\u8FD0\u884C\u72B6\u6001\u3001\u9636\u6BB5\u548C\u8282\u70B9\uFF1B\u8F93\u51FA\u4E0D\u542B\u5B8C\u6574 prompt/result\u3002\u65E0 runId \u65F6\u5217\u51FA\u6700\u8FD1\u8FD0\u884C\u3002", inputSchema: obj(id) }, { name: "workflow_results", description: "\u5206\u9875\u8BFB\u53D6\u8282\u70B9\u7ED3\u679C\uFF1B\u7EC8\u6001\u62A5\u544A\u4E0E\u5931\u8D25\u660E\u786E\u5206\u5F00\u3002", inputSchema: obj({ ...id, includeDefinition: { type: "boolean" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["runId"]) }, diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index 401bea7..6275e84 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -166,7 +166,8 @@ export class Engine extends EventEmitter { for(const key of ['stepId','phase'])if(payload[key]!==undefined){check(typeof payload[key]==='string'&&/^[A-Za-z0-9_:./-]{1,150}$/.test(payload[key]),'日志关联 ID 无效');detail[key]=payload[key];} ctx.run.latestProgress={...detail,time:Date.now()};this.save(ctx.run);this.emitEvent(ctx.run.id,'log',detail);return; } - if(method==='checkpoint'){check(typeof payload.id==='string'&&payload.id.length<=100,'checkpoint 必须有 ID');const key=`checkpoint:${payload.id}`,old=this.store.step(ctx.run.id,key);if(old){check(old.requestHash===hash(payload),'checkpoint 参数冲突');return old.output;}const step={id:key,kind:'checkpoint',status:'succeeded',output:payload.value,requestHash:hash(payload),label:payload.id,dependsOn:[],createdAt:Date.now()};this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'checkpoint',{stepId:key});return payload.value;} + if(method==='checkpoint'){check(typeof payload.id==='string'&&payload.id.length<=100,'checkpoint 必须有 ID');const key=`checkpoint:${payload.id}`,old=this.store.step(ctx.run.id,key);if(old){check(old.requestHash===hash(payload),'checkpoint 参数冲突');return old.output;}const checkpointHash=hash(payload);// Checkpoints recompute every run; their lineage hash covers the recomputed value so a changed value breaks downstream cross-run lineage. +const step={id:key,kind:'checkpoint',status:'succeeded',output:payload.value,requestHash:checkpointHash,lineageHash:hash({requestHash:checkpointHash,deps:[]}),label:payload.id,dependsOn:[],createdAt:Date.now()};this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'checkpoint',{stepId:key});return payload.value;} throw new Error('未知脚本操作'); } agent(ctx,spec,planId){ @@ -184,6 +185,13 @@ export class Engine extends EventEmitter { const requestHash=hash(spec),previous=this.store.step(ctx.run.id,spec.id),cached=ctx.calls.get(spec.id); if(previous){check(previous.requestHash===requestHash,`步骤 ${spec.id} 使用了不同参数,恢复已停止`);if(previous.status==='succeeded')return Promise.resolve({status:'succeeded',output:previous.output,cached:true});} if(cached){check(cached.hash===requestHash,'重复 step id 参数冲突');return cached.promise;} + // Computed once per dispatch attempt, before any candidate lookup. contextHash is + // stamped on every new step so the store can filter cross-run candidates in SQL + // before LIMIT; lineageHash pins the node to its own spec plus the lineage hashes + // of its succeeded dependencies (in dependsOn order), so a changed upstream + // invalidates downstream candidates even when the downstream spec is unchanged. + const ctxHash=ctx.contextHash??(ctx.contextHash=hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints})); + const lineageHash=hash({requestHash,deps:deps.map(id=>{const dep=this.store.step(ctx.run.id,id);return {id,lineageHash:dep?.lineageHash??null};})}); const repair=ctx.run.repair,candidate=!previous&&repair?.reuseStepIds.includes(spec.id)?this.store.repairCandidate(ctx.run.id,spec.id):null; if(candidate&&candidate.requestHash===requestHash &&repair.contextHash===hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints}) @@ -193,25 +201,35 @@ export class Engine extends EventEmitter { ||(dep?.kind==='checkpoint'&&dep.requestHash===this.store.step(repair.sourceRunId,id)?.requestHash);})){ // Recheck the output against today's validator, including legacy candidates. let valid=true;try{if(validateOutput)valid=validateOutput(candidate.output);}catch{valid=false;} - if(valid){const step={...candidate,...(typeof planId==='string'?{planId}:{}),attempt:0,createdAt:Date.now(),startedAt:null,endedAt:Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined, + if(valid){const step={...candidate,...(typeof planId==='string'?{planId}:{}),attempt:0,createdAt:Date.now(),startedAt:null,endedAt:Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined,contextHash:ctxHash, reusedFrom:{runId:repair.sourceRunId,stepId:spec.id,endedAt:candidate.endedAt??null}}; this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:step.id,sourceRunId:repair.sourceRunId}); return Promise.resolve({status:'succeeded',output:step.output,cached:true});} } - if(ctx.run.reuseAcrossRuns&&!previous){ - // Cross-run reuse: adopt an earlier run's stored result when the node spec and the context (workspace, input, executor, tracked files) are identical. - const ctxHash=ctx.contextHash??(ctx.contextHash=hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints})); - for(const candidate of this.store.findCrossRunReuse({contextHash:ctxHash,requestHash,excludeRunId:ctx.run.id})){ + if(ctx.run.reuseAcrossRuns&&!previous&&!(ctx.run.executor==='mcode'&&!spec.model)){ + // Cross-run reuse: adopt an earlier run's stored result when the node spec, its + // upstream lineage and the run context (workspace, input, executor, tracked + // files) all hash identically. All three keys are stamped on candidate steps at + // creation, so the store filters in SQL before LIMIT. mcode nodes without an + // explicit model are never candidates: the effective model comes from the CLI + // environment and is not part of the match key. + for(const candidate of this.store.findCrossRunReuse({contextHash:ctxHash,requestHash,lineageHash,excludeRunId:ctx.run.id})){ let valid=true;try{if(validateOutput)valid=validateOutput(candidate.step.output);}catch{valid=false;} if(!valid)continue; - const step={...candidate.step,attempt:0,createdAt:Date.now(),startedAt:null,endedAt:candidate.step.endedAt??Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined, - reusedFrom:candidate.step.reusedFrom??{runId:candidate.runId,stepId:candidate.stepId,endedAt:candidate.step.endedAt??null,crossRun:true}}; + const source=candidate.step; + // lineageHash is adopted from the source (dependencies already matched, so the + // lineage is equivalent); contextHash is restamped with this run's. Provenance + // always records the immediate source in reusedFrom and the first producer in + // originalProducer, so chained adoptions stay consistent with the emitted event. + const step={...source,attempt:0,createdAt:Date.now(),startedAt:null,endedAt:source.endedAt??Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined,contextHash:ctxHash, + reusedFrom:{runId:candidate.runId,stepId:candidate.stepId,endedAt:source.endedAt??null,crossRun:true}, + originalProducer:source.originalProducer??(source.reusedFrom?{...source.reusedFrom}:{runId:candidate.runId,stepId:candidate.stepId,endedAt:source.endedAt??null,crossRun:true})}; this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:spec.id,sourceRunId:candidate.runId,crossRun:true}); return Promise.resolve({status:'succeeded',output:step.output,cached:true}); } } check(ctx.run.attempts{ let release;try{release=await this.acquire(ctx.controller.signal,ctx,step.id);ctx.controller.signal.throwIfAborted();step.status='running';step.startedAt=Date.now();this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.started',{stepId:step.id}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 4968060..27bfd83 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -2,7 +2,6 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; -import { hash } from './common.mjs'; export class Store { constructor(dir) { mkdirSync(dir,{recursive:true,mode:0o700}); this.lock=join(dir,'owner.lock'); @@ -49,7 +48,11 @@ export class Store { list() {return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map(r=>JSON.parse(r.body));} step(runId,id) {const r=this.db.prepare('SELECT body FROM steps WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} steps(runId) {return this.db.prepare('SELECT body FROM steps WHERE runId=? ORDER BY rowid').all(runId).map(r=>JSON.parse(r.body));} - findCrossRunReuse({contextHash,requestHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT s.body AS stepBody, r.body AS runBody, s.rowid AS ord FROM steps s JOIN runs r ON s.runId = r.id WHERE r.id <> ? AND json_extract(s.body,'$.kind')='agent' AND json_extract(s.body,'$.status')='succeeded' AND json_extract(s.body,'$.requestHash')=? ORDER BY s.rowid DESC LIMIT ?").all(excludeRunId,requestHash,limit).flatMap(r=>{const step=JSON.parse(r.stepBody),run=JSON.parse(r.runBody);return hash({workspace:run.workspace,input:run.input,executor:run.executor,fingerprints:run.fingerprints})===contextHash?[{runId:run.id,stepId:step.id,step}]:[];});} + // All match keys (contextHash, lineageHash) are stamped on the step body at + // creation, so filtering happens in SQL and LIMIT applies after the full match. + // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is + // an opt-in feature and older steps are not candidates. + findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} saveStep(runId,step) {this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body').run(runId,step.id,JSON.stringify(step));} repairCandidate(runId,id) {const r=this.db.prepare('SELECT body FROM repair_cache WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} saveRepairCandidate(runId,step) {this.transaction(()=>{const rowid=Number(this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step)).lastInsertRowid);this.chainAdvance('repair','repair','SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid',rowid,r=>`${r.runId}/${r.id}`);});} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index daa3b54..1c181bf 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs @@ -10,8 +10,8 @@ const obj=(properties,required=[])=>({type:'object',properties,required,addition const string={type:'string'};const id={runId:string}; export const TOOLS=[ {name:'workflow_validate',description:'静态检查脚本并生成结构拓扑,不执行脚本或 Agent。DSL: await ctx.phase({id,label}); await ctx.agent({id,label,phase,dependsOn,prompt,input,schema}); ctx.map(items,fn); ctx.log(message,{stepId,phase}); ctx.checkpoint(id,value)。dependsOn 可为单个 ID 字符串或 ID 数组,推荐数组;依赖须先成功。agent 返回 status/output/error;须显式处理失败。',inputSchema:obj({script:string},['script'])}, - {name:'workflow_start',description:'创建待审核工作流和结构拓扑,不执行 Agent。必须提供面板让用户审阅、修改并点击开始执行。mcode 模式会启动真实 MCode,会使用已登录身份与 smart 权限,不提供只读 OS 沙箱。demo 模式不调用模型。显式 requestId 幂等。',inputSchema:obj({requestId:string,name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},...LIMIT_SCHEMAS},['requestId','name','script','executor'])}, - {name:'workflow_update',description:'修改待审核工作流的脚本、输入或预算并重建拓扑,保存后仍待审核;revision 必须匹配当前版本。不可修改已开始的运行。',inputSchema:obj({...id,revision:{type:'integer',minimum:1},reason:{type:'string',maxLength:2000},reuseStepIds:{type:'array',items:string,maxItems:100,uniqueItems:true},name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},...LIMIT_SCHEMAS},['runId','revision'])}, + {name:'workflow_start',description:'创建待审核工作流和结构拓扑,不执行 Agent。必须提供面板让用户审阅、修改并点击开始执行。mcode 模式会启动真实 MCode,会使用已登录身份与 smart 权限,不提供只读 OS 沙箱。demo 模式不调用模型。显式 requestId 幂等。',inputSchema:obj({requestId:string,name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},reuseAcrossRuns:{type:'boolean',description:'Opt-in: adopt succeeded nodes from prior runs in the same workspace when context and spec hashes match'},...LIMIT_SCHEMAS},['requestId','name','script','executor'])}, + {name:'workflow_update',description:'修改待审核工作流的脚本、输入或预算并重建拓扑,保存后仍待审核;revision 必须匹配当前版本。不可修改已开始的运行。',inputSchema:obj({...id,revision:{type:'integer',minimum:1},reason:{type:'string',maxLength:2000},reuseStepIds:{type:'array',items:string,maxItems:100,uniqueItems:true},name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},reuseAcrossRuns:{type:'boolean',description:'Opt-in: adopt succeeded nodes from prior runs in the same workspace when context and spec hashes match'},...LIMIT_SCHEMAS},['runId','revision'])}, {name:'workflow_repair',description:'基于停止后的运行创建修复草稿,保留源运行;提供完整修复脚本、失败原因与 sourceUpdatedAt。显式 reuseStepIds 仅选择确认仍适用的成功节点,默认不复用。运行时重新校验输入、文件、参数与依赖;变更或重跑的上游使下游失效。必须打开面板交用户审核后开始,不能自动执行。',inputSchema:obj({...id,requestId:string,sourceUpdatedAt:{type:'integer'},script:string,reason:{type:'string',maxLength:2000},reuseStepIds:{type:'array',items:string,maxItems:100,uniqueItems:true},input:{type:'object'},...LIMIT_SCHEMAS,maxCalls:{type:'integer',minimum:1,maximum:100}},['runId','requestId','sourceUpdatedAt','script','reason'])}, {name:'workflow_status',description:'读取运行状态、阶段和节点;输出不含完整 prompt/result。无 runId 时列出最近运行。',inputSchema:obj(id)}, {name:'workflow_results',description:'分页读取节点结果;终态报告与失败明确分开。',inputSchema:obj({...id,includeDefinition:{type:'boolean'},offset:{type:'integer',minimum:0},limit:{type:'integer',minimum:1,maximum:20}},['runId'])}, diff --git a/remote-ci.sh b/remote-ci.sh new file mode 100755 index 0000000..232e1b5 --- /dev/null +++ b/remote-ci.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# remote-ci.sh — Run the full repository gate on the Ubuntu server (ssh: siinfer), +# mirroring CI / validate (ubuntu-latest) as closely as the host allows. +# Differences vs GitHub ubuntu-latest: Node 24 here vs 22 there (within the plugin's +# declared support), and no CodeQL/Windows jobs. +# Usage: ./remote-ci.sh [branch] (default: current branch of the local clone) +set -euo pipefail +BRANCH="${1:-$(git -C "$(dirname "$0")" rev-parse --abbrev-ref HEAD)}" +LOCAL_DIR="$(dirname "$0")" +HOST="${SIH_REMOTE:-siinfer}" +REMOTE_BASE="${SIH_REMOTE_DIR:-remote-ci/MiniMax-Code-Plugins}" +SHA="$(git -C "$LOCAL_DIR" rev-parse "$BRANCH")" + +echo "[remote-ci] branch=$BRANCH sha=${SHA:0:8} host=$HOST" + +# 1. Ship the exact tree (tar over ssh; no GitHub round-trip, works for unpushed heads). +# COPYFILE_DISABLE silences macOS tar provenance xattrs. +ssh "$HOST" "rm -rf \"\$HOME/$REMOTE_BASE\" && mkdir -p \"\$HOME/$REMOTE_BASE\"" +COPYFILE_DISABLE=1 tar -C "$LOCAL_DIR" --exclude=.git --exclude=node_modules \ + --exclude='*/node_modules' -cf - . \ + | ssh "$HOST" "cd \"\$HOME/$REMOTE_BASE\" && tar -xf -" + +# 2. Install dev deps for the plugin (pinned) plus the CI runner's Python deps +# (Pillow — see .github/workflows/ci.yml), then run the full gate from a clean +# tree (node_modules removed — same as the CI runner's fresh checkout). +ssh "$HOST" 'set -e + cd "$HOME/'"$REMOTE_BASE"'" + echo "[remote-ci] node $(node --version)" + (cd plugins/hetaoBackend/mcode-dynamic-workflows && npm ci --no-audit --no-fund >/dev/null 2>&1) + python3 -m venv .venv-ci 2>/dev/null || true + .venv-ci/bin/pip install --quiet --disable-pip-version-check Pillow 2>/dev/null \ + || echo "[remote-ci] WARN: Pillow install failed; octopus tests may fail" + export PATH="$PWD/.venv-ci/bin:$PATH" + rm -rf plugins/hetaoBackend/mcode-dynamic-workflows/node_modules + npm run check' + +echo "[remote-ci] FULL GATE GREEN on $HOST (${SHA:0:8})" From 0a3dc7cfe33b451e596d567dd5f470bc5f96199f Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 13:35:42 +0800 Subject: [PATCH 3/6] fix: strip ledger contamination, bind upstream output into lineage, add MCP contract test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.mjs rebuilt from main: findCrossRunReuse only, no integrity-ledger code (the ledger belongs to #48; the previous round accidentally carried it) - lineageHash now includes each succeeded dependency's output hash, so an upstream that re-executes with different output (mcode node without an explicit model, tracked-file change during execution) invalidates downstream adoption — regression covers the maintainer's divergence shape - checks/cross-reuse-mcp.check.mjs: packaged MCP advertises reuseAcrossRuns on workflow_start/workflow_update and accepts/rejects it through the public tool surface (additionalProperties:false contract) --- .../checks/cross-reuse-mcp.check.mjs | 14 ++++ .../checks/cross-reuse.check.mjs | 14 ++++ .../mcode-dynamic-workflows/dist/main.mjs | 75 +++---------------- .../mcode-dynamic-workflows/src/engine.mjs | 7 +- .../mcode-dynamic-workflows/src/store.mjs | 30 ++------ 5 files changed, 50 insertions(+), 90 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs new file mode 100644 index 0000000..a036360 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs @@ -0,0 +1,14 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';import {mkdtemp,rm,writeFile} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path'; +test('packaged MCP advertises reuseAcrossRuns and accepts it through the public tool surface',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-cross-mcp-'));await writeFile(join(dir,'settings.json'),JSON.stringify({workspace:dir,dataDir:dir})); + const client=new Client({name:'cross-reuse-mcp-test',version:'1'});const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--settings',join(dir,'settings.json')],stderr:'pipe'}); + try{ + await client.connect(transport);const {tools}=await client.listTools(); + for(const name of ['workflow_start','workflow_update']){const tool=tools.find(t=>t.name===name);assert.ok(tool,`${name} listed`);assert.equal(tool.inputSchema.properties.reuseAcrossRuns?.type,'boolean',`${name} schema must advertise reuseAcrossRuns (additionalProperties:false)`);} + const started=await client.callTool({name:'workflow_start',arguments:{requestId:'cross-mcp',name:'Cross-run via public surface',executor:'demo',reuseAcrossRuns:true,script:'return await ctx.agent({id:"a",prompt:"p"});'}}); + assert.ok(!started.isError,started.content?.[0]?.text);const run=JSON.parse(started.content[0].text); + assert.equal(run.reuseAcrossRuns,true,'the flag must survive the public tool surface'); + const rejected=await client.callTool({name:'workflow_start',arguments:{requestId:'cross-mcp-bad',name:'Bad flag type',executor:'demo',reuseAcrossRuns:'yes',script:'return 1;'}}); + assert.ok(rejected.isError,'a non-boolean flag must be rejected by the public surface'); + }finally{await client.close();await transport.close();await rm(dir,{recursive:true,force:true});} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs index 68acb79..351d58b 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs @@ -153,3 +153,17 @@ test('chained adoption keeps reusedFrom on the immediate source and originalProd assert.equal(b3.usage,null);assert.deepEqual(b3.usageHistory,[]); }finally{await f.cleanup();} }); +test('an upstream that re-executes with a different output invalidates downstream adoption',async()=>{ + // Maintainer's divergence shape: the upstream is not a cross-run candidate itself + // (mcode node without an explicit model re-executes every run) and its output + // differs between runs; the eligible downstream must not adopt the stale result. + let aOutput='old';const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id==='a'?aOutput:s.id};});try{ + const script=`const a=await ctx.agent({id:'a',prompt:'write'});const b=await ctx.agent({id:'b',prompt:'read',model:'m2',dependsOn:['a'],input:{content:a.output}});return {a:a.output,b:b.output};`; + const run1=await run(f.engine,script,{},{executor:'mcode'}); + aOutput='new'; + const run2=await run(f.engine,script,{},{executor:'mcode',reuseAcrossRuns:true}); + assert.deepEqual(calls,['a','b','a','b'],'a re-executes (no model => never a candidate); b must not adopt the stale run-1 result'); + assert.deepEqual(run2.result,{a:'new',b:'b'}); + assert.ok(!run2.steps.find(s=>s.id==='b').reusedFrom,'divergent upstream output must break lineage'); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index ee9e9f3..5214cb1 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7714,7 +7714,7 @@ import { spawn as spawn3 } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; var Store = class { constructor(dir) { mkdirSync(dir, { recursive: true, mode: 448 }); @@ -7740,7 +7740,6 @@ var Store = class { this.fd = openSync(this.lock, "wx", 384); } this.owner = randomUUID(); - this.txDepth = 0; try { writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); this.db = new DatabaseSync(join(dir, "workflows.sqlite")); @@ -7752,8 +7751,7 @@ var Store = class { CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); - CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); + CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq);`); const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); for (const row of unfinished) { const run = JSON.parse(row.body); @@ -7768,8 +7766,6 @@ var Store = class { } } transaction(fn) { - if (this.txDepth) return fn(); - this.txDepth = 1; this.db.exec("BEGIN IMMEDIATE"); try { const r = fn(); @@ -7778,8 +7774,6 @@ var Store = class { } catch (e) { this.db.exec("ROLLBACK"); throw e; - } finally { - this.txDepth = 0; } } templates() { @@ -7841,61 +7835,16 @@ var Store = class { return r ? JSON.parse(r.body) : null; } saveRepairCandidate(runId, step) { - this.transaction(() => { - const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); - this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); - }); + this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)); } event(runId, type, data2 = {}) { const event = { ...data2, type, time: Date.now() }; - return this.transaction(() => { - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - this.chainAdvance("event", "events", "SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => String(r.pos)); - return { seq, ...event }; - }); + const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); + return { seq, ...event }; } events(runId, after = 0, limit = 150) { return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); } - rowHash(prev, kind, key, body) { - return createHash("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); - } - chainAdvance(kind, surface, sql, newUpto, keyOf) { - const tail = this.setting(`integrity_${surface}`); - let prev = tail?.head ?? "0".repeat(64); - for (const r of this.db.prepare(sql).all(tail?.upto ?? 0, newUpto)) { - const k = keyOf(r); - prev = this.rowHash(prev, kind, k, r.body); - this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); - } - this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); - } - integrityHeads() { - return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; - } - verifyIntegrity() { - const genesis = "0".repeat(64); - const face = (kind, surface, table, posCol) => { - const skey = `integrity_${surface}`; - const rec = this.setting(skey); - const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); - if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; - const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); - let prev = genesis, firstDivergence = null; - for (const r of rows) { - const row = this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos); - const actual = row ? this.rowHash(prev, kind, r.key, row.body) : null; - if (!firstDivergence && (!row || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; - prev = r.hash; - } - const verified = !firstDivergence && prev === rec.head; - return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained: Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n), firstDivergence }; - }; - return { - events: face("event", "events", "events", "seq"), - repair: face("repair", "repair", "repair_cache", "rowid") - }; - } releaseLock() { closeSync(this.fd); try { @@ -7910,7 +7859,7 @@ var Store = class { }; // src/common.mjs -import { createHash as createHash2 } from "node:crypto"; +import { createHash } from "node:crypto"; // node_modules/acorn/dist/acorn.mjs var astralIdentifierCodes = [509, 0, 227, 0, 150, 4, 294, 9, 1368, 2, 2, 1, 6, 3, 41, 2, 5, 0, 166, 1, 574, 3, 9, 9, 7, 9, 32, 4, 318, 1, 78, 5, 71, 10, 50, 3, 123, 2, 54, 14, 32, 10, 3, 1, 11, 3, 46, 10, 8, 0, 46, 9, 7, 2, 37, 13, 2, 9, 6, 1, 45, 0, 13, 2, 49, 13, 9, 3, 2, 11, 83, 11, 7, 0, 3, 0, 158, 11, 6, 9, 7, 3, 56, 1, 2, 6, 3, 1, 3, 2, 10, 0, 11, 1, 3, 6, 4, 4, 68, 8, 2, 0, 3, 0, 2, 3, 2, 4, 2, 0, 15, 1, 83, 17, 10, 9, 5, 0, 82, 19, 13, 9, 214, 6, 3, 8, 28, 1, 83, 16, 16, 9, 82, 12, 9, 9, 7, 19, 58, 14, 5, 9, 243, 14, 166, 9, 71, 5, 2, 1, 3, 3, 2, 0, 2, 1, 13, 9, 120, 6, 3, 6, 4, 0, 29, 9, 41, 6, 2, 3, 9, 0, 10, 10, 47, 15, 199, 7, 137, 9, 54, 7, 2, 7, 17, 9, 57, 21, 2, 13, 123, 5, 4, 0, 2, 1, 2, 6, 2, 0, 9, 9, 49, 4, 2, 1, 2, 4, 9, 9, 55, 9, 266, 3, 10, 1, 2, 0, 49, 6, 4, 4, 14, 10, 5350, 0, 7, 14, 11465, 27, 2343, 9, 87, 9, 39, 4, 60, 6, 26, 9, 535, 9, 470, 0, 2, 54, 8, 3, 82, 0, 12, 1, 19628, 1, 4178, 9, 519, 45, 3, 22, 543, 4, 4, 5, 9, 7, 3, 6, 31, 3, 149, 2, 1418, 49, 513, 54, 5, 49, 9, 0, 15, 0, 23, 4, 2, 14, 1361, 6, 2, 16, 3, 6, 2, 1, 2, 4, 101, 0, 161, 6, 10, 9, 357, 0, 62, 13, 499, 13, 245, 1, 2, 9, 233, 0, 3, 0, 8, 1, 6, 0, 475, 6, 110, 6, 6, 9, 4759, 9, 787719, 239]; @@ -13610,7 +13559,7 @@ function parse3(input, options) { } // src/common.mjs -var hash = (value) => createHash2("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); +var hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); function stable(value) { return JSON.stringify(canonical(value)); } @@ -14641,7 +14590,7 @@ var Engine = class extends EventEmitter { const ctxHash = ctx.contextHash ?? (ctx.contextHash = hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints })); const lineageHash = hash({ requestHash, deps: deps.map((id2) => { const dep = this.store.step(ctx.run.id, id2); - return { id: id2, lineageHash: dep?.lineageHash ?? null }; + return { id: id2, lineageHash: dep?.lineageHash ?? null, outputHash: dep?.status === "succeeded" ? hash(dep.output ?? null) : null }; }) }); const repair = ctx.run.repair, candidate = !previous && repair?.reuseStepIds.includes(spec.id) ? this.store.repairCandidate(ctx.run.id, spec.id) : null; if (candidate && candidate.requestHash === requestHash && repair.contextHash === hash({ workspace: ctx.run.workspace, input: ctx.run.input, executor: ctx.run.executor, fingerprints: ctx.run.fingerprints }) && deps.every((id2) => { @@ -14821,7 +14770,7 @@ var Engine = class extends EventEmitter { }; // src/http.mjs -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash2 } from "node:crypto"; // web/graph-model.mjs var finished = /* @__PURE__ */ new Set(["succeeded", "completed_with_gaps", "failed", "cancelled"]); @@ -26615,7 +26564,7 @@ async function startStdio(handler, tools = TOOLS) { // src/http.mjs async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import.meta.url), exampleRoot = new URL("../examples/", import.meta.url) } = {}) { - const reportStyleHash = createHash3("sha256").update(REPORT_STYLES).digest("base64"); + const reportStyleHash = createHash2("sha256").update(REPORT_STYLES).digest("base64"); let origin; const sockets = /* @__PURE__ */ new Set(); const server = http.createServer(async (req, res) => { @@ -26708,7 +26657,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import } // src/workspace-router.mjs -import { createHash as createHash4 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; import { realpath as realpath2, stat as stat2 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, relative as relative2, join as join3, sep as sep2 } from "node:path"; @@ -27571,7 +27520,7 @@ async function canonicalWorkspace(value, pluginRoot) { return workspace; } function projectDataDir(base, workspace) { - return join3(base, "projects", createHash4("sha256").update(workspace).digest("hex")); + return join3(base, "projects", createHash3("sha256").update(workspace).digest("hex")); } function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) { const connections = /* @__PURE__ */ new Map(); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index 6275e84..44770a3 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -187,11 +187,12 @@ const step={id:key,kind:'checkpoint',status:'succeeded',output:payload.value,req if(cached){check(cached.hash===requestHash,'重复 step id 参数冲突');return cached.promise;} // Computed once per dispatch attempt, before any candidate lookup. contextHash is // stamped on every new step so the store can filter cross-run candidates in SQL - // before LIMIT; lineageHash pins the node to its own spec plus the lineage hashes - // of its succeeded dependencies (in dependsOn order), so a changed upstream + // before LIMIT; lineageHash pins the node to its own spec plus, for each + // succeeded dependency (in dependsOn order), that dependency's lineage hash + // AND output hash — so a changed, rerun, or differently-outcomed upstream // invalidates downstream candidates even when the downstream spec is unchanged. const ctxHash=ctx.contextHash??(ctx.contextHash=hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints})); - const lineageHash=hash({requestHash,deps:deps.map(id=>{const dep=this.store.step(ctx.run.id,id);return {id,lineageHash:dep?.lineageHash??null};})}); + const lineageHash=hash({requestHash,deps:deps.map(id=>{const dep=this.store.step(ctx.run.id,id);return {id,lineageHash:dep?.lineageHash??null,outputHash:dep?.status==='succeeded'?hash(dep.output??null):null};})}); const repair=ctx.run.repair,candidate=!previous&&repair?.reuseStepIds.includes(spec.id)?this.store.repairCandidate(ctx.run.id,spec.id):null; if(candidate&&candidate.requestHash===requestHash &&repair.contextHash===hash({workspace:ctx.run.workspace,input:ctx.run.input,executor:ctx.run.executor,fingerprints:ctx.run.fingerprints}) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 27bfd83..915142f 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -1,7 +1,7 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; export class Store { constructor(dir) { mkdirSync(dir,{recursive:true,mode:0o700}); this.lock=join(dir,'owner.lock'); @@ -12,7 +12,7 @@ export class Store { if(alive) throw new Error('同一状态目录已有运行中的服务,请连接既有服务'); unlinkSync(this.lock); this.fd=openSync(this.lock,'wx',0o600); } - this.owner=randomUUID();this.txDepth=0; + this.owner=randomUUID(); try { writeFileSync(this.fd,JSON.stringify({pid:process.pid,owner:this.owner})); this.db=new DatabaseSync(join(dir,'workflows.sqlite')); @@ -26,8 +26,7 @@ export class Store { CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); - CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); + CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq);`); // Recovery must inspect every unfinished run, not just the dashboard page. const unfinished=this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); for(const row of unfinished) {const run=JSON.parse(row.body); @@ -35,7 +34,7 @@ export class Store { } }catch(error){this.db?.close();this.releaseLock();throw error;} } - transaction(fn) {if(this.txDepth)return fn();this.txDepth=1;this.db.exec('BEGIN IMMEDIATE');try{const r=fn();this.db.exec('COMMIT');return r;}catch(e){this.db.exec('ROLLBACK');throw e;}finally{this.txDepth=0;}} + transaction(fn) { this.db.exec('BEGIN IMMEDIATE');try{const r=fn();this.db.exec('COMMIT');return r;}catch(e){this.db.exec('ROLLBACK');throw e;} } templates() {return this.db.prepare('SELECT body FROM templates ORDER BY rowid DESC').all().map(r=>JSON.parse(r.body));} template(id) {const r=this.db.prepare('SELECT body FROM templates WHERE id=?').get(id);return r?JSON.parse(r.body):null;} saveTemplate(value) {this.db.prepare('INSERT INTO templates VALUES(?,?)').run(value.id,JSON.stringify(value));} @@ -55,26 +54,9 @@ export class Store { findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} saveStep(runId,step) {this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body').run(runId,step.id,JSON.stringify(step));} repairCandidate(runId,id) {const r=this.db.prepare('SELECT body FROM repair_cache WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} - saveRepairCandidate(runId,step) {this.transaction(()=>{const rowid=Number(this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step)).lastInsertRowid);this.chainAdvance('repair','repair','SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid',rowid,r=>`${r.runId}/${r.id}`);});} - event(runId,type,data={}) {const event={...data,type,time:Date.now()};return this.transaction(()=>{const seq=Number(this.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run(runId,JSON.stringify(event)).lastInsertRowid);this.chainAdvance('event','events','SELECT seq AS pos,body FROM events WHERE seq>? AND seq<=? ORDER BY seq',seq,r=>String(r.pos));return {seq,...event};});} + saveRepairCandidate(runId,step) {this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step));} + event(runId,type,data={}) {const event={...data,type,time:Date.now()};const seq=Number(this.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run(runId,JSON.stringify(event)).lastInsertRowid);return {seq,...event};} events(runId,after=0,limit=150) {return this.db.prepare('SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?').all(runId,after,limit).map(e=>({seq:e.seq,...JSON.parse(e.body)}));} - rowHash(prev,kind,key,body) {return createHash('sha256').update(`${prev}:${kind}:${key}:${body}`).digest('hex');} - chainAdvance(kind,surface,sql,newUpto,keyOf) {const tail=this.setting(`integrity_${surface}`);let prev=tail?.head??'0'.repeat(64);for(const r of this.db.prepare(sql).all(tail?.upto??0,newUpto)){const k=keyOf(r);prev=this.rowHash(prev,kind,k,r.body);this.db.prepare('INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)').run(surface,r.pos,k,prev);}this.saveSetting(`integrity_${surface}`,{head:prev,upto:newUpto});} - integrityHeads() {return {events:this.setting('integrity_events')??null,repair:this.setting('integrity_repair')??null};} - verifyIntegrity() { - const genesis='0'.repeat(64);const face=(kind,surface,table,posCol)=>{ - const skey=`integrity_${surface}`;const rec=this.setting(skey);const total=Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); - if(!rec)return {head:null,upto:0,verified:null,checked:0,unchained:total,firstDivergence:null}; - const rows=this.db.prepare('SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos').all(surface); - let prev=genesis,firstDivergence=null; - for(const r of rows){const row=this.db.prepare(`SELECT body FROM ${table} WHERE ${posCol}=?`).get(r.pos);const actual=row?this.rowHash(prev,kind,r.key,row.body):null; - if(!firstDivergence&&(!row||actual!==r.hash))firstDivergence={key:r.key,expectedHead:r.hash,actualHead:actual}; - prev=r.hash;} - const verified=!firstDivergence&&prev===rec.head; - return {head:rec.head,upto:rec.upto,verified,checked:rows.length,unchained:Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n),firstDivergence};}; - return {events:face('event','events','events','seq'), - repair:face('repair','repair','repair_cache','rowid')}; - } releaseLock() {closeSync(this.fd);try{if(JSON.parse(readFileSync(this.lock,'utf8')).owner===this.owner)unlinkSync(this.lock);}catch{}} close() {this.db.close();this.releaseLock();} } From 2334eb9843c67c5143044b7db5a5398e70141687 Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 14:16:02 +0800 Subject: [PATCH 4/6] chore: drop local CI helper scripts accidentally committed These are contributor-local gate runners (local mirror + remote Ubuntu box), not part of the plugin or the repository contract. They slipped in via a broad 'git add -A' during the r2 fix round. --- local-ci.mjs | 62 ---------------------------------------------------- remote-ci.sh | 37 ------------------------------- 2 files changed, 99 deletions(-) delete mode 100644 local-ci.mjs delete mode 100755 remote-ci.sh diff --git a/local-ci.mjs b/local-ci.mjs deleted file mode 100644 index 961090e..0000000 --- a/local-ci.mjs +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node -// local-ci.mjs — Local mirror of the repository CI gates. UNTRACKED helper: never commit. -// Mirrors: -// CI / validate (ubuntu-latest) -> clean-checkout `npm run check` (validate + full suite) -// Dynamic Workflow source-and-package -> build reproducibility + packaged smoke -// plugin extras -> verify-claims (when present on the branch) -// GitHub-only (cannot run locally): CodeQL, Windows process-lifecycle job. -// Usage: node local-ci.mjs [--quick] (--quick skips the full root suite) -import { spawnSync } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -const root = resolve(import.meta.dirname); -const plugin = join(root, 'plugins/hetaoBackend/mcode-dynamic-workflows'); -const nm = join(plugin, 'node_modules'); -const quick = process.argv.includes('--quick'); -let failed = ''; - -const step = (name, cwd, fn) => { - process.stdout.write(`\n=== ${name} ===\n`); - if (failed) { console.log(`SKIP ${name} (earlier failure: ${failed})`); return; } - const code = fn(); - if (code !== 0) failed = name; - console.log(`${code === 0 ? 'PASS' : 'FAIL'} ${name}`); -}; -const run = (cmd, args, cwd = root) => spawnSync(cmd, args, { cwd, stdio: 'inherit' }).status ?? 1; - -const major = Number(process.versions.node.split('.')[0]); -if (major !== 22) { - console.log(`NOTE: CI runs Node 22 (ubuntu-latest); local Node is ${process.versions.node}. Version drift possible.`); -} -if (!existsSync(join(plugin, 'package.json'))) { - console.error(`plugin not found: ${plugin}`); process.exit(2); -} - -step('plugin source suite (node --test checks/*.check.mjs)', plugin, - () => run('npm', ['test'], plugin)); -step('packaged MCP smoke (test:package)', plugin, - () => run('npm', ['run', 'test:package'], plugin)); -step('bundle build (build.mjs)', plugin, - () => run('npm', ['run', 'build'], plugin)); -step('bundle reproducibility (git diff --exit-code)', plugin, - () => run('git', ['diff', '--exit-code', '--', 'dist', 'web', 'THIRD_PARTY_NOTICES.txt'], plugin)); -if (existsSync(join(plugin, 'scripts/verify-claims.mjs'))) { - step('mechanical claims (verify-claims)', plugin, - () => run('node', ['scripts/verify-claims.mjs'], plugin)); -} -// Clean-checkout simulation: the validator rejects plugin node_modules (symlink rule), -// and the root suite must pass without it — same as the CI runner. -step('stash plugin node_modules (clean-checkout simulate)', root, () => { - if (existsSync(nm)) rmSync(nm, { recursive: true, force: true }); - return 0; -}); -if (!quick) step('root gate: npm run check (validate + full suite)', root, - () => run('npm', ['run', 'check'], root)); -else console.log('\n=== SKIP root gate (--quick) ==='); -step('restore plugin node_modules (npm ci)', plugin, - () => run('npm', ['ci'], plugin)); - -console.log(`\n=== local-ci summary: ${failed ? `FAILED at ${failed}` : 'all gates green'} ===`); -if (failed) console.log('GitHub-only gates not mirrored here: CodeQL, Windows process-lifecycle.'); -process.exit(failed ? 1 : 0); diff --git a/remote-ci.sh b/remote-ci.sh deleted file mode 100755 index 232e1b5..0000000 --- a/remote-ci.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# remote-ci.sh — Run the full repository gate on the Ubuntu server (ssh: siinfer), -# mirroring CI / validate (ubuntu-latest) as closely as the host allows. -# Differences vs GitHub ubuntu-latest: Node 24 here vs 22 there (within the plugin's -# declared support), and no CodeQL/Windows jobs. -# Usage: ./remote-ci.sh [branch] (default: current branch of the local clone) -set -euo pipefail -BRANCH="${1:-$(git -C "$(dirname "$0")" rev-parse --abbrev-ref HEAD)}" -LOCAL_DIR="$(dirname "$0")" -HOST="${SIH_REMOTE:-siinfer}" -REMOTE_BASE="${SIH_REMOTE_DIR:-remote-ci/MiniMax-Code-Plugins}" -SHA="$(git -C "$LOCAL_DIR" rev-parse "$BRANCH")" - -echo "[remote-ci] branch=$BRANCH sha=${SHA:0:8} host=$HOST" - -# 1. Ship the exact tree (tar over ssh; no GitHub round-trip, works for unpushed heads). -# COPYFILE_DISABLE silences macOS tar provenance xattrs. -ssh "$HOST" "rm -rf \"\$HOME/$REMOTE_BASE\" && mkdir -p \"\$HOME/$REMOTE_BASE\"" -COPYFILE_DISABLE=1 tar -C "$LOCAL_DIR" --exclude=.git --exclude=node_modules \ - --exclude='*/node_modules' -cf - . \ - | ssh "$HOST" "cd \"\$HOME/$REMOTE_BASE\" && tar -xf -" - -# 2. Install dev deps for the plugin (pinned) plus the CI runner's Python deps -# (Pillow — see .github/workflows/ci.yml), then run the full gate from a clean -# tree (node_modules removed — same as the CI runner's fresh checkout). -ssh "$HOST" 'set -e - cd "$HOME/'"$REMOTE_BASE"'" - echo "[remote-ci] node $(node --version)" - (cd plugins/hetaoBackend/mcode-dynamic-workflows && npm ci --no-audit --no-fund >/dev/null 2>&1) - python3 -m venv .venv-ci 2>/dev/null || true - .venv-ci/bin/pip install --quiet --disable-pip-version-check Pillow 2>/dev/null \ - || echo "[remote-ci] WARN: Pillow install failed; octopus tests may fail" - export PATH="$PWD/.venv-ci/bin:$PATH" - rm -rf plugins/hetaoBackend/mcode-dynamic-workflows/node_modules - npm run check' - -echo "[remote-ci] FULL GATE GREEN on $HOST (${SHA:0:8})" From db2dafb04dcb6810cf8e97729e0c66df9821933c Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 15:15:32 +0800 Subject: [PATCH 5/6] fix: restamp planId on cross-run adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adopted step spread the source run's planId, so a reused node kept the old topology's plan anchor while the current run's topology carried its own planId for the same agent — the dashboard then rendered two nodes (one planned-not-run, one live). Adoption now restamps the current dispatch's planId exactly like the repair path; regression asserts the adopted step maps onto the current topology's single node (maintainer's workflowGraph reproduction). --- .../checks/cross-reuse.check.mjs | 15 +++++++++++++++ .../mcode-dynamic-workflows/dist/main.mjs | 4 ++++ .../mcode-dynamic-workflows/src/engine.mjs | 4 ++++ 3 files changed, 23 insertions(+) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs index 351d58b..2ef9d75 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs @@ -167,3 +167,18 @@ test('an upstream that re-executes with a different output invalidates downstrea assert.ok(!run2.steps.find(s=>s.id==='b').reusedFrom,'divergent upstream output must break lineage'); }finally{await f.cleanup();} }); +test('cross-run adoption restamps planId to the current topology node',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const body='return await ctx.agent({id:"a",prompt:"a"});'; + const run1=await run(f.engine,body); + // Same agent spec, script text differs by a leading comment -> different topology planId. + const run2=await run(f.engine,'// shifted\n'+body,{},{reuseAcrossRuns:true}); + assert.equal(calls.length,1,'reuse hits'); + const a2=run2.steps.find(s=>s.id==='a'); + assert.ok(a2.reusedFrom?.crossRun); + assert.notEqual(a2.planId,run1.steps.find(s=>s.id==='a').planId,'adopted step must not carry the source run planId'); + const node=run2.topology.nodes.find(n=>n.stepId==='a'); + assert.equal(a2.planId,node?.planId??'a','adopted step maps onto the current topology node'); + assert.equal(run2.topology.nodes.filter(n=>n.stepId==='a'||n.id==='a').length,1,'current topology exposes exactly one node for the agent'); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 5214cb1..8e2a6cc 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -14644,6 +14644,10 @@ var Engine = class extends EventEmitter { sessionId: void 0, turnId: void 0, contextHash: ctxHash, + // planId must follow the CURRENT dispatch: keeping the source run's planId + // duplicates/mislabels the node against this run's topology (the repair + // path already restamps it). + ...typeof planId === "string" ? { planId } : { planId: void 0 }, reusedFrom: { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: source.endedAt ?? null, crossRun: true }, originalProducer: source.originalProducer ?? (source.reusedFrom ? { ...source.reusedFrom } : { runId: candidate2.runId, stepId: candidate2.stepId, endedAt: source.endedAt ?? null, crossRun: true }) }; diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index 44770a3..ba99bc4 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -223,6 +223,10 @@ const step={id:key,kind:'checkpoint',status:'succeeded',output:payload.value,req // always records the immediate source in reusedFrom and the first producer in // originalProducer, so chained adoptions stay consistent with the emitted event. const step={...source,attempt:0,createdAt:Date.now(),startedAt:null,endedAt:source.endedAt??Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined,contextHash:ctxHash, + // planId must follow the CURRENT dispatch: keeping the source run's planId + // duplicates/mislabels the node against this run's topology (the repair + // path already restamps it). + ...(typeof planId==='string'?{planId}:{planId:undefined}), reusedFrom:{runId:candidate.runId,stepId:candidate.stepId,endedAt:source.endedAt??null,crossRun:true}, originalProducer:source.originalProducer??(source.reusedFrom?{...source.reusedFrom}:{runId:candidate.runId,stepId:candidate.stepId,endedAt:source.endedAt??null,crossRun:true})}; this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:spec.id,sourceRunId:candidate.runId,crossRun:true}); From 0db3327470dcdd560afce4a19f4a29303dc811a6 Mon Sep 17 00:00:00 2001 From: moc Date: Fri, 18 Sep 2026 16:47:29 +0800 Subject: [PATCH 6/6] fix: fresh-execution gate for adoption; originalProducer across repair chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross-run adoption now requires every agent dependency to be itself adopted/reused: a freshly executed upstream has unproven execution identity — spec and output hashes cannot see changed filesystem effects (same return value, different written content). Checkpoints recompute deterministically and stay eligible via their value hash in lineage. Regression mirrors the maintainer's same-output/changed-tracked-file repro end-to-end with real file I/O (a re-executes returning 'done', b re-executes and reads the new content). - repair adoption now carries originalProducer (first producer relayed through R2/R3 repairs, not reset to the immediate repair source), so a later cross-run adoption resolves originalProducer.runId to the run that actually executed the node (multi-repair chain regression) --- .../checks/cross-reuse.check.mjs | 34 +++++++++++++++++++ .../mcode-dynamic-workflows/dist/main.mjs | 11 ++++-- .../mcode-dynamic-workflows/src/engine.mjs | 13 +++++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs index 2ef9d75..f5c7bec 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse.check.mjs @@ -182,3 +182,37 @@ test('cross-run adoption restamps planId to the current topology node',async()=> assert.equal(run2.topology.nodes.filter(n=>n.stepId==='a'||n.id==='a').length,1,'current topology exposes exactly one node for the agent'); }finally{await f.cleanup();} }); +test('a fresh upstream execution with identical output blocks downstream adoption (same-output, changed-file shape)',async()=>{ + // Maintainer's repro shape: A (no model, never a candidate) re-executes and returns + // the SAME value while its filesystem effect changes; B (eligible, unchanged spec) + // must re-execute rather than adopt the old result. + const {writeFile,readFile}=await import('node:fs/promises');const {join}=await import('node:path'); + let aWrites='old';const calls=[],f=await fixture(async s=>{calls.push(s.id); + if(s.id==='a'){await writeFile(join(f.dir,'produced.txt'),aWrites);return {output:'done'};} + return {output:(await readFile(join(f.dir,'produced.txt'),'utf8')).trim()};}); + try{ + const script=`const a=await ctx.agent({id:'a',prompt:'write'});const b=await ctx.agent({id:'b',prompt:'read',model:'m2',dependsOn:['a']});return {a:a.output,b:b.output};`; + await writeFile(join(f.dir,'produced.txt'),'same baseline'); + const run1=await run(f.engine,script,{files:['produced.txt']},{executor:'mcode'}); + await writeFile(join(f.dir,'produced.txt'),'same baseline');aWrites='new'; + const run2=await run(f.engine,script,{files:['produced.txt']},{executor:'mcode',reuseAcrossRuns:true}); + assert.deepEqual(calls,['a','b','a','b'],'a re-executes (no model); identical output must NOT let b adopt'); + assert.deepEqual(run2.result,{a:'done',b:'new'}); + assert.ok(!run2.steps.find(s=>s.id==='b').reusedFrom); + }finally{await f.cleanup();} +}); + +test('originalProducer survives multi-repair chains into cross-run adoption',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const script=`return (await ctx.agent({id:'a',prompt:'a'})).output;`; + const broken=`const a=await ctx.agent({id:'a',prompt:'a'});throw Error('x');`; + const R1=await run(f.engine,broken); + const repair=async src=>{const d=await f.engine.repair(src.id,{requestId:crypto.randomUUID(),sourceUpdatedAt:f.store.get(src.id).updatedAt,script,reason:'fix',reuseStepIds:['a']});await f.engine.approve(d.id,{revision:1});for(let i=0;i<300;i++){if(!f.engine.active.has(d.id))return f.engine.snapshot(d.id);await new Promise(r=>setTimeout(r,20));}}; + const R2=await repair(R1),R3=await repair(R2); + const R4=await run(f.engine,script,{},{reuseAcrossRuns:true}); + const a4=R4.steps.find(s=>s.id==='a'); + assert.equal(a4.reusedFrom.runId,R3.id,'immediate source is the newest run'); + assert.equal(a4.reusedFrom.crossRun,true); + assert.equal(a4.originalProducer.runId,R1.id,'first producer survives two repairs into cross-run adoption'); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 8e2a6cc..c0a37ee 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -14616,14 +14616,21 @@ var Engine = class extends EventEmitter { sessionId: void 0, turnId: void 0, contextHash: ctxHash, - reusedFrom: { runId: repair.sourceRunId, stepId: spec.id, endedAt: candidate.endedAt ?? null } + reusedFrom: { runId: repair.sourceRunId, stepId: spec.id, endedAt: candidate.endedAt ?? null }, + // The first producer survives repair chains: R2/R3 relay the output but + // only the original execution produced it. + originalProducer: candidate.originalProducer ?? (candidate.reusedFrom ? { ...candidate.reusedFrom } : { runId: repair.sourceRunId, stepId: spec.id, endedAt: candidate.endedAt ?? null }) }; this.store.saveStep(ctx.run.id, step2); this.emitEvent(ctx.run.id, "step.reused", { stepId: step2.id, sourceRunId: repair.sourceRunId }); return Promise.resolve({ status: "succeeded", output: step2.output, cached: true }); } } - if (ctx.run.reuseAcrossRuns && !previous && !(ctx.run.executor === "mcode" && !spec.model)) { + const depsAllAdopted = deps.every((id2) => { + const dep = this.store.step(ctx.run.id, id2); + return dep?.kind === "checkpoint" || dep?.reusedFrom; + }); + if (ctx.run.reuseAcrossRuns && !previous && depsAllAdopted && !(ctx.run.executor === "mcode" && !spec.model)) { for (const candidate2 of this.store.findCrossRunReuse({ contextHash: ctxHash, requestHash, lineageHash, excludeRunId: ctx.run.id })) { let valid = true; try { diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index ba99bc4..a482e4a 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -203,11 +203,20 @@ const step={id:key,kind:'checkpoint',status:'succeeded',output:payload.value,req // Recheck the output against today's validator, including legacy candidates. let valid=true;try{if(validateOutput)valid=validateOutput(candidate.output);}catch{valid=false;} if(valid){const step={...candidate,...(typeof planId==='string'?{planId}:{}),attempt:0,createdAt:Date.now(),startedAt:null,endedAt:Date.now(),usage:null,usageHistory:[],sessionId:undefined,turnId:undefined,contextHash:ctxHash, - reusedFrom:{runId:repair.sourceRunId,stepId:spec.id,endedAt:candidate.endedAt??null}}; + reusedFrom:{runId:repair.sourceRunId,stepId:spec.id,endedAt:candidate.endedAt??null}, + // The first producer survives repair chains: R2/R3 relay the output but + // only the original execution produced it. + originalProducer:candidate.originalProducer??(candidate.reusedFrom?{...candidate.reusedFrom}:{runId:repair.sourceRunId,stepId:spec.id,endedAt:candidate.endedAt??null})}; this.store.saveStep(ctx.run.id,step);this.emitEvent(ctx.run.id,'step.reused',{stepId:step.id,sourceRunId:repair.sourceRunId}); return Promise.resolve({status:'succeeded',output:step.output,cached:true});} } - if(ctx.run.reuseAcrossRuns&&!previous&&!(ctx.run.executor==='mcode'&&!spec.model)){ + // A freshly executed agent dependency has unproven execution identity: spec and + // output hashes cannot see changed filesystem effects (same return value, different + // written content). Downstream adoption is therefore only sound when every agent + // dependency was itself adopted/reused; checkpoints recompute deterministically and + // their value hash is already bound into lineage. + const depsAllAdopted=deps.every(id=>{const dep=this.store.step(ctx.run.id,id);return dep?.kind==='checkpoint'||dep?.reusedFrom;}); + if(ctx.run.reuseAcrossRuns&&!previous&&depsAllAdopted&&!(ctx.run.executor==='mcode'&&!spec.model)){ // Cross-run reuse: adopt an earlier run's stored result when the node spec, its // upstream lineage and the run context (workspace, input, executor, tracked // files) all hash identically. All three keys are stamped on candidate steps at