From 6d4de2ce1c491b86932910e1a1d432d94c36c4fb Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:42:13 +0900 Subject: [PATCH 01/24] feat(interactive): M4 host-owned Coordinator, serialized Agent Pending, receipt-first CAS and resumable Program stepping --- src/interactive/coordinator.mjs | 545 ++++++++++++++++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 src/interactive/coordinator.mjs diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs new file mode 100644 index 0000000..dd40265 --- /dev/null +++ b/src/interactive/coordinator.mjs @@ -0,0 +1,545 @@ +import {randomUUID} from 'node:crypto'; +import {canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; +import {loadInteractiveProgram} from './load.mjs'; + +const FINISHED = new Set(['succeeded','failed','errored']); +const ERROR_CODES = new Set([ + 'FK_R002','FK_R004','FK_R010','FK_R011','FK_I_VALIDATOR_CONTRACT', + 'FK_I_INVALID_TRANSITION','FK_I_INVALID_CHECKPOINT', +]); +const MAX_RECEIPTS = 128; +const MAX_REASONS = 32; +const MAX_REASON_LENGTH = 512; + +function failure(code, message) { + return Object.assign(new Error(code + ': ' + message), {code}); +} +function nonempty(value, field) { + if (typeof value !== 'string' || value.length < 1 || value.length > 256 + || /[\u0000-\u001f\u007f]/.test(value)) throw new TypeError('Invalid ' + field); + return value; +} +function fingerprint(value) { return sha256Bytes(canonicalBytes(value)); } +function copy(value) { return safeJSONSnapshot(value); } +function validStageCount(value) { + if (!Number.isSafeInteger(value) || value < 1 || value > 128) { + throw new RangeError('maxStagesPerCall must be in [1, 128]'); + } + return value; +} +function stableTransitionId(cp, kind) { + return 't:' + fingerprint([cp.namespace,cp.runId,cp.revision,cp.blockId,kind]).slice(7); +} +function internalEvent(cp, kind, extra = {}) { + const payload = { + schemaVersion:1, forBlockId:cp.blockId, kind, + ...extra, + }; + const eventDigest = fingerprint([cp.runId,cp.revision,payload]); + return Object.freeze({...payload,eventDigest,eventId:'e:' + eventDigest.slice(7)}); +} +function freezeDeep(value) { + if (value && typeof value === 'object') { + Object.values(value).forEach(freezeDeep); + Object.freeze(value); + } + return value; +} +function programView(cp) { + return freezeDeep(copy({ + schemaVersion:1,namespace:cp.namespace,runId:cp.runId,revision:cp.revision, + blockId:cp.blockId,phase:cp.phase,state:cp.state, + controlStack:cp.controlStack,candidate:cp.candidate ?? null, + })); +} +function confirmCurrentBlock(cp, program) { + const block = program.programTable.blocks.find(item=>item.id===cp.blockId); + if (!block || block.phase !== cp.phase || !program.programTable.nodes[block.nodeId]) { + throw failure('FK_I_INVALID_CHECKPOINT','block does not belong to immutable Program'); + } + const record = program.programTable.nodes[block.nodeId]; + const frames = cp.controlStack; + if (!Array.isArray(frames) || frames.length > 1 || frames.some(x=>x.kind!=='agent')) { + throw failure('FK_I_INVALID_CHECKPOINT','illegal P0-A control stack'); + } + if (record.kind === 'agent' && cp.phase !== 'enter') { + const frame = frames.at(-1); + if (!frame || frame.nodeId !== block.nodeId || !Number.isSafeInteger(frame.activation) + || frame.activation < 1 || !Number.isSafeInteger(frame.attempt) + || frame.attempt < 1 || frame.attempt > record.metadata.retries + 1 + || !Array.isArray(frame.reasons)) { + throw failure('FK_I_INVALID_CHECKPOINT','invalid current Agent frame'); + } + } + if (cp.status === 'waiting') { + if (cp.phase !== 'awaiting' || cp.pending?.requestId == null || cp.candidate != null) { + throw failure('FK_I_INVALID_CHECKPOINT','waiting without certified pending request'); + } + } else if (cp.phase === 'awaiting' && !FINISHED.has(cp.status)) { + throw failure('FK_I_INVALID_CHECKPOINT','awaiting must be a published waiting state'); + } + if (cp.candidate != null && cp.phase !== 'validate' && cp.phase !== 'commit') { + throw failure('FK_I_INVALID_CHECKPOINT','candidate outside validation or commit phase'); + } + if (cp.prepared != null && cp.phase !== 'dispatch-ready') { + throw failure('FK_I_INVALID_CHECKPOINT','prepared request outside dispatch-ready phase'); + } + if (!Array.isArray(cp.receipts) || cp.receipts.length > MAX_RECEIPTS + || !Number.isSafeInteger(cp.activationSeq) || cp.activationSeq < 0) { + throw failure('FK_I_INVALID_CHECKPOINT','invalid receipt ledger or activation counter'); + } + return {block,record}; +} +function certifiedProposal(cp, program, event, proposal) { + const {block} = confirmCurrentBlock(cp,program); + if (!block.eventKinds.includes(event.kind) + || !proposal || proposal.fromBlockId !== cp.blockId + || proposal.nodeId !== block.nodeId + || !block.proposalKinds.includes(proposal.kind)) { + throw failure('FK_I_INVALID_TRANSITION','Program returned uncertified proposal'); + } + if (proposal.kind === 'terminal' || proposal.kind === 'raise-error') { + if (proposal.toBlockId != null) { + throw failure('FK_I_INVALID_TRANSITION','terminal and error must have no successor'); + } + } else if (!block.legalTargets.includes(proposal.toBlockId)) { + throw failure('FK_I_INVALID_TRANSITION','Program target not in legalTargets'); + } + if (proposal.stackEffect?.kind !== 'none' + && !['push','pop','update'].includes(proposal.stackEffect?.kind)) { + throw failure('FK_I_INVALID_TRANSITION','invalid control stack effect'); + } + if (proposal.mergeIntent?.kind !== 'none' + && proposal.mergeIntent?.kind !== 'commit-candidate') { + throw failure('FK_I_INVALID_TRANSITION','invalid merge intent'); + } + return proposal; +} +function applyStack(cp, effect) { + const frames = copy(cp.controlStack); + if (effect.kind === 'none') return frames; + if (effect.kind === 'push') { + if (frames.length || effect.frame?.kind !== 'agent' + || effect.frame.nodeId !== cp.blockId.split('::')[0]) { + throw failure('FK_I_INVALID_TRANSITION','illegal Agent frame push'); + } + frames.push(copy(effect.frame)); + } else if (effect.kind === 'pop') { + if (frames.length !== 1 + || frames[0].kind !== effect.expectedKind + || frames[0].nodeId !== effect.expectedNodeId) { + throw failure('FK_I_INVALID_TRANSITION','illegal Agent frame pop'); + } + frames.pop(); + } else if (effect.kind === 'update') { + if (frames.length !== 1 || frames[0].kind !== effect.expectedKind + || frames[0].nodeId !== effect.expectedNodeId || effect.field !== 'attempt' + || effect.value !== frames[0].attempt + 1) { + throw failure('FK_I_INVALID_TRANSITION','illegal Agent frame update'); + } + frames[0].attempt = effect.value; + frames[0].reasons = []; + } + return frames; +} +function asPublicFrame(cp) { + return copy({ + namespace:cp.namespace,runId:cp.runId,status:cp.status,revision:cp.revision, + state: FINISHED.has(cp.status) ? cp.state : undefined, + ...(cp.status === 'waiting' ? {pending:cp.pending} : {}), + ...(cp.status === 'paused' ? {pauseReason:cp.pauseReason ?? 'stage-budget'} : {}), + ...(FINISHED.has(cp.status) ? {terminal:cp.terminal ?? null} : {}), + }); +} +function inputForAgent(state, fields) { + const input = Object.create(null); + const missing=[]; + for (const key of fields) { + if (Object.hasOwn(state,key)) Object.defineProperty(input,key,{ + value:state[key],enumerable:true,writable:true,configurable:true, + }); + else missing.push(key); + } + return {input,missing}; +} +function reasonsFromValidation(value) { + if (!Array.isArray(value) || value.length > MAX_REASONS + || value.some(v => typeof v !== 'string' || !v.length || v.length > MAX_REASON_LENGTH)) { + throw failure('FK_I_VALIDATOR_CONTRACT','validateResult must return bounded string[]'); + } + return [...value]; +} +function assertRuntime(program,runtime) { + if (typeof runtime !== 'object' || !runtime || Array.isArray(runtime)) { + throw new TypeError('InteractiveRuntime must be an object'); + } + if (runtime.checkStep !== undefined) { + throw failure('FK_I_UNSUPPORTED_GRAPH','P0-A cannot silently skip checkStep'); + } + for (const binding of program.programTable.requiredBindings) { + if (binding.id === 'runtime.validateResult' && typeof runtime.validateResult !== 'function' + || binding.id === 'runtime.skillResolver' && typeof runtime.skillResolver !== 'function') { + throw failure('FK_I_BINDING_MISSING','Required P0-A binding ' + binding.id); + } + } +} +function initialCheckpoint(namespace, runId, program, policyHash, state) { + return { + schemaVersion:1,namespace,runId,revision:0, + artifactHash:program.artifactHash,policyHash,status:'running', + blockId:program.programTable.entryBlockId, + phase:'enter',state:copy(state), + controlStack:[],activationSeq:0,receipts:[], + pending:null,prepared:null,candidate:null,terminal:null, + contextError:null, + }; +} + +/** + * P0-A host-owned Coordinator. The immutable generated JS Program is the + * authority for routing, frame effects and stage proposals. No runAgent, + * network write, or unverified external event is executed by this component. + * + * Only the trusted Host may call start/complete/resume and provide bindings. + * Host identity/provenance adapters are added in M5, NOT simulated here. + */ +export function createInteractiveCoordinator({store, runtime, policy = {}, maxStagesPerCall = 128} = {}) { + if (!store || typeof store.load !== 'function' + || typeof store.createRun !== 'function' + || typeof store.compareAndSwap !== 'function' + || typeof store.putArtifact !== 'function' + || typeof store.getArtifact !== 'function') { + throw new TypeError('A CheckpointStore with atomic CAS and artifact retention is required'); + } + nonempty(store.namespace,'store.namespace'); + if (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) { + throw new TypeError('InteractiveRuntime object required'); + } + const execution = Object.freeze({ + validateResult:runtime.validateResult, + skillResolver:runtime.skillResolver, + checkStep:runtime.checkStep, + }); + const policySnapshot = copy(policy); + const policyHash = fingerprint(policySnapshot); + const stageBudget = validStageCount(maxStagesPerCall); + + async function loadPinned(cp) { + if (cp.namespace !== store.namespace || cp.policyHash !== policyHash) { + throw failure('FK_I_POLICY_MISMATCH','Run policy and namespace must match'); + } + assertSHA256(cp.artifactHash); + const saved = store.getArtifact(cp.artifactHash); + if (!saved || saved.programIdentity.artifactHash !== cp.artifactHash) { + throw failure('FK_I_PROGRAM_MISMATCH','Missing compiled Program bytes'); + } + const program = await loadInteractiveProgram(saved.bytes,cp.artifactHash); + const declared = {...program.interactiveProgram.identity,artifactHash:cp.artifactHash}; + if (canonicalBytes(declared).toString('hex') + !== canonicalBytes(saved.programIdentity).toString('hex')) { + throw failure('FK_I_PROGRAM_MISMATCH','Stored Program manifest mismatch'); + } + assertRuntime(program,execution); + confirmCurrentBlock(cp,program); + return program; + } + + function advanceSafely(cp, program, event) { + try { + const proposal = program.advance(programView(cp),event); + return certifiedProposal(cp,program,event,proposal); + } catch(error) { + if (error?.code === 'FK_R002') { + return certifiedProposal(cp,program,internalEvent(cp,'deterministic-failure',{error:{code:'FK_R002'}}), + program.advance(programView(cp),internalEvent(cp,'deterministic-failure',{error:{code:'FK_R002'}}))); + } + throw error; + } + } + function stepCAS(cp, proposal, eventKind, extras={}) { + const next = {...cp,revision:cp.revision+1,...extras}; + if (proposal.kind === 'raise-error') { + next.status='errored'; + next.terminal={status:'errored',nodeId:proposal.nodeId,causeCode:proposal.causeCode}; + next.pending=null;next.prepared=null;next.candidate=null; + } else if (proposal.kind === 'terminal') { + next.status=proposal.terminal.status; + next.terminal=copy(proposal.terminal); + next.pending=null;next.prepared=null;next.candidate=null; + } else { + next.blockId=proposal.toBlockId; + next.phase=proposal.toBlockId.slice(proposal.toBlockId.lastIndexOf('::')+2); + next.controlStack=applyStack(cp,proposal.stackEffect); + next.status=proposal.kind === 'publish-pending' ? 'waiting' : 'running'; + } + if (proposal.kind === 'publish-pending') { + if (cp.prepared == null) throw failure('FK_I_INVALID_CHECKPOINT','publish without prepared request'); + next.pending=cp.prepared;next.prepared=null; + } + if (proposal.kind === 'commit-state') { + if (proposal.mergeIntent.kind !== 'commit-candidate' + || cp.candidate?.requestId !== proposal.mergeIntent.requestId + || cp.candidate?.validated !== true + || cp.candidate.kind !== 'agent' + || !cp.candidate.result || typeof cp.candidate.result !== 'object' + || Array.isArray(cp.candidate.result)) { + throw failure('FK_I_INVALID_TRANSITION','commit requires verified object candidate'); + } + next.state=copy({...cp.state,...cp.candidate.result}); + next.candidate=null; + } + if (proposal.kind === 'retry-agent') { + next.candidate=null;next.pending=null;next.prepared=null; + if (extras?.retryReasons) next.controlStack[0].reasons=copy(extras.retryReasons); + } + if (proposal.kind === 'commit-candidate') { + if (cp.candidate?.kind !== 'agent') throw failure('FK_I_INVALID_CHECKPOINT','no Agent candidate'); + next.candidate={...cp.candidate,validated:true}; + } + if (proposal.kind === 'prepare-pending') { + if (!extras.prepared) throw failure('FK_I_INVALID_CHECKPOINT','prepared data missing'); + next.prepared=copy(extras.prepared); + } + if (proposal.kind === 'move' && cp.phase === 'enter' && cp.blockId !== next.blockId + && proposal.stackEffect.kind === 'push') { + next.activationSeq=cp.activationSeq+1; + } + const transitionId=stableTransitionId(cp,eventKind + '/' + proposal.kind); + const write=store.compareAndSwap({ + runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next,transitionId, + journalEntry:{ + kind:proposal.kind,event:eventKind,from:cp.blockId, + to:proposal.toBlockId ?? null,causeCode:proposal.causeCode ?? null, + }, + }); + return write; + } + + async function makePrepared(cp,program) { + const block=program.programTable.blocks.find(b=>b.id===cp.blockId); + const node=program.programTable.nodes[block.nodeId]; + const frame=cp.controlStack.at(-1); + if (node.kind!=='agent' || cp.phase!=='prepare' || !frame) { + throw failure('FK_I_INVALID_CHECKPOINT','not at Agent prepare phase'); + } + const instruction = await execution.skillResolver(Object.freeze(copy({ + runId:cp.runId,nodeId:block.nodeId,name:node.metadata.name, + agent:node.metadata.agent ?? null,skill:node.metadata.skill ?? null, + integrity:node.metadata.integrity ?? null, + }))); + if (typeof instruction !== 'string' || !instruction.length || instruction.length > 128*1024) { + throw failure('FK_I_SKILL_CONTRACT','skillResolver must supply bounded text'); + } + const {input,missing}=inputForAgent(cp.state,node.metadata.inputs); + const requestId = 'req:' + fingerprint([ + cp.namespace,cp.runId,cp.artifactHash,frame.nodeId,frame.activation,frame.attempt, + ]).slice(7); + return copy({ + kind:'agent',runId:cp.runId,nodeId:block.nodeId, + name:node.metadata.name,requestId,attempt:frame.attempt, + instruction,input,missing,resultSchema:node.metadata.output, + reasons:frame.reasons, + ...(node.metadata.overview ? {overview:{step:frame.overviewStep}} : {}), + }); + } + + async function validateCandidate(cp,program) { + if (cp.phase!=='validate' || cp.candidate?.kind!=='agent' + || cp.candidate.requestId == null || cp.candidate.validated === true) { + throw failure('FK_I_INVALID_CHECKPOINT','unvalidated candidate required'); + } + const block=program.programTable.blocks.find(b=>b.id===cp.blockId); + const schema=copy(program.programTable.nodes[block.nodeId].metadata.output); + if (cp.candidate.submissionKind === 'agent-call-failed') { + return {kind:'agent-call-failed',reasons: ['runAgent threw: ' + + (cp.candidate.failure?.message ?? 'Agent call failed').slice(0,MAX_REASON_LENGTH-16)]}; + } + let reasons; + try { + reasons=execution.validateResult(schema,copy(cp.candidate.result)); + if (reasons && typeof reasons.then === 'function') { + throw failure('FK_I_VALIDATOR_CONTRACT','validateResult must be synchronous and pure'); + } + reasons=reasonsFromValidation(reasons); + } catch { + return {kind:'deterministic-failure',causeCode:'FK_I_VALIDATOR_CONTRACT'}; + } + return reasons.length + ? {kind:'validation-rejected',reasons} + : {kind:'validation-passed',reasons:[]}; + } + + async function drive(runId) { + for (let count=0; count < stageBudget; ) { + const cp=store.load(runId); + if (!cp) throw failure('FK_I_RUN_NOT_FOUND','run is not accessible in this namespace'); + const program=await loadPinned(cp); + if (FINISHED.has(cp.status) || cp.status==='waiting') return asPublicFrame(cp); + if (cp.status === 'paused') { + if (cp.pauseReason !== 'stage-budget') return asPublicFrame(cp); + const unpause={...cp,status:'running',revision:cp.revision+1,pauseReason:null}; + const committed=store.compareAndSwap({runId,expectedRevision:cp.revision, + nextCheckpoint:unpause,transitionId:stableTransitionId(cp,'resume-budget'), + journalEntry:{kind:'resume-budget'}}); + if (!committed.committed) continue; + // Budget unpause is a control action, not a Program stage. + continue; + } + const {block}=confirmCurrentBlock(cp,program); + if (cp.phase === 'awaiting') { + throw failure('FK_I_INVALID_CHECKPOINT','awaiting without waiting Pending'); + } + let event,proposal,extras={}; + if (cp.phase === 'prepare') { + let prepared; + try { prepared=await makePrepared(cp,program); } + catch (error) { + if (['FK_R014','FK_I_TEMPORARY_SKILL'].includes(error?.code)) { + const paused={...cp,revision:cp.revision+1,status:'paused', + pauseReason:'skill-temporary'}; + const result=store.compareAndSwap({runId,expectedRevision:cp.revision, + nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-skill'), + journalEntry:{kind:'pause',reason:'skill-temporary'}}); + if (!result.committed) continue; + return asPublicFrame(result.checkpoint); + } + const causeCode=error?.code==='FK_R010' || error?.code==='FK_R011' + ? error.code : 'FK_I_SKILL_CONTRACT'; + event=internalEvent(cp,'deterministic-failure',{error:{code:causeCode}}); + proposal=advanceSafely(cp,program,event); + } + if (prepared) { + event=internalEvent(cp,'internal-step'); + proposal=advanceSafely(cp,program,event); + extras.prepared=prepared; + } + } else if (cp.phase === 'validate') { + const evaluation=await validateCandidate(cp,program); + event=evaluation.kind === 'deterministic-failure' + ? internalEvent(cp,'deterministic-failure',{error:{code:evaluation.causeCode}}) + : internalEvent(cp,evaluation.kind); + proposal=advanceSafely(cp,program,event); + if (evaluation.reasons?.length) extras.retryReasons=evaluation.reasons; + } else { + const kind=cp.phase==='dispatch-ready' ? 'pending-prepared' : 'internal-step'; + const extra=cp.phase==='enter' && block.nodeId in program.programTable.nodes + && program.programTable.nodes[block.nodeId].kind==='agent' + ? {activation:cp.activationSeq+1} : {}; + event=internalEvent(cp,kind,extra); + proposal=advanceSafely(cp,program,event); + } + const advanced=stepCAS(cp,proposal,event.kind,extras); + if (!advanced.committed) continue; + count++; + if (FINISHED.has(advanced.checkpoint.status) || advanced.checkpoint.status==='waiting') { + return asPublicFrame(advanced.checkpoint); + } + } + // A stage budget is observable and resumable. Never misreport it as waiting. + for (;;) { + const cp=store.load(runId); + const program=await loadPinned(cp); + if (FINISHED.has(cp.status) || cp.status==='waiting' || cp.status==='paused') { + return asPublicFrame(cp); + } + const paused={...cp,revision:cp.revision+1,status:'paused', + pauseReason:'stage-budget'}; + const result=store.compareAndSwap({runId,expectedRevision:cp.revision, + nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-budget'), + journalEntry:{kind:'pause',reason:'stage-budget'}}); + if (result.committed) return asPublicFrame(result.checkpoint); + } + } + + /** Register compiler-verified bytes before the idempotent start transaction. */ + async function start({programBytes,programIdentity,startKey,runId=randomUUID(),state={},startIntent=null}={}) { + nonempty(startKey,'startKey');nonempty(runId,'runId'); + if (!(programBytes instanceof Uint8Array) || !programIdentity) { + throw new TypeError('Compiler Program bytes and identity are required'); + } + const byteSnapshot=Buffer.from(programBytes); + const declared=copy(programIdentity); + assertSHA256(declared.artifactHash); + const program=await loadInteractiveProgram(byteSnapshot,declared.artifactHash); + if (!canonicalBytes({...program.interactiveProgram.identity,artifactHash:declared.artifactHash}) + .equals(canonicalBytes(declared))) { + throw failure('FK_I_PROGRAM_MISMATCH','identity and verified Program differ'); + } + assertRuntime(program,execution); // before any mutation, reject unavailable Stage bindings + const initial=initialCheckpoint(store.namespace,runId,program,policyHash,state); + const intent=copy(startIntent); + store.putArtifact({programIdentity:declared,bytes:byteSnapshot}); + const opened=store.createRun({startKey,startIntent:intent,checkpoint:initial}); + return drive(opened.checkpoint.runId); + } + + /** Only a trusted Host may call complete; M5 will bind provenance/transport. */ + async function complete({runId,requestId,nodeId,attempt,kind='agent-result',result,failure:callFailure}={}) { + nonempty(runId,'runId');nonempty(requestId,'requestId');nonempty(nodeId,'nodeId'); + if (!Number.isSafeInteger(attempt) || attempt < 1) throw new TypeError('Invalid Agent attempt'); + if (!['agent-result','agent-call-failed'].includes(kind)) { + throw failure('FK_I_INVALID_RECEIPT','Unsupported P0-A receipt type'); + } + const submission=copy(kind==='agent-result' + ? {runId,requestId,nodeId,attempt,kind,result} + : {runId,requestId,nodeId,attempt,kind,failure:callFailure}); + const digest=fingerprint(submission); + for (;;) { + const cp=store.load(runId); + if (!cp) throw failure('FK_I_RUN_NOT_FOUND','run is not accessible'); + const program=await loadPinned(cp); + const already=cp.receipts.find(r=>r.requestId===requestId); + if (already) { + if (already.digest!==digest || already.nodeId!==nodeId || already.attempt!==attempt) { + throw failure('FK_I_RECEIPT_CONFLICT','Different receipt with accepted requestId'); + } + const current=await drive(runId); + return {acceptedRevision:already.acceptedRevision,...current,duplicate:true}; + } + if (cp.status!=='waiting' || cp.phase!=='awaiting') { + throw failure('FK_I_STALE_RECEIPT','Only currently published Agent request can be completed'); + } + const pending=cp.pending; + const frame=cp.controlStack.at(-1); + const block=program.programTable.blocks.find(b=>b.id===cp.blockId); + if (!pending || !frame || pending.kind!=='agent' + || pending.requestId!==requestId || pending.nodeId!==nodeId + || pending.attempt!==attempt || frame.nodeId!==nodeId + || frame.attempt!==attempt || block.nodeId!==nodeId + || cp.receipts.length >= MAX_RECEIPTS) { + throw failure('FK_I_STALE_RECEIPT','Wrong request, node, attempt, or exhausted ledger'); + } + const accept=block.acceptTarget; + if (!block.legalTargets.includes(accept) || accept!==nodeId+'::validate') { + throw failure('FK_I_INVALID_TRANSITION','Awaiting receipt target is not certified'); + } + const ledgerEntry={requestId,nodeId,attempt,digest,acceptedRevision:cp.revision+1}; + const next={ + ...cp,revision:cp.revision+1,blockId:accept,phase:'validate', + status:'running',pending:null,prepared:null, + candidate:{kind:'agent',requestId,nodeId,attempt, + submissionKind:kind, + ...(kind==='agent-result'?{result:submission.result} + :{failure:submission.failure}),validated:false}, + receipts:[...cp.receipts,ledgerEntry], + }; + const persisted=store.compareAndSwap({runId,expectedRevision:cp.revision, + nextCheckpoint:next,transitionId:stableTransitionId(cp,'receipt:'+requestId), + journalEntry:{kind:'receipt-accepted',nodeId,attempt,requestId,digest}}); + if (!persisted.committed) continue; + const current=await drive(runId); + return {acceptedRevision:ledgerEntry.acceptedRevision,...current,duplicate:false}; + } + } + + async function resume({runId}={}) { nonempty(runId,'runId');return drive(runId); } + async function load({runId}={}) { + nonempty(runId,'runId'); + const cp=store.load(runId); + if (!cp) return null; + await loadPinned(cp); + return asPublicFrame(cp); + } + + return Object.freeze({start,complete,resume,load,policyHash}); +} From e9ca59e30ce938b194575fd0158a2711bc102138 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:43:26 +0900 Subject: [PATCH 02/24] fix(interactive): M4 canonical public frames, no ephemeral extras, resumable skill pause --- src/interactive/coordinator.mjs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index dd40265..15bd709 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -3,10 +3,6 @@ import {canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './can import {loadInteractiveProgram} from './load.mjs'; const FINISHED = new Set(['succeeded','failed','errored']); -const ERROR_CODES = new Set([ - 'FK_R002','FK_R004','FK_R010','FK_R011','FK_I_VALIDATOR_CONTRACT', - 'FK_I_INVALID_TRANSITION','FK_I_INVALID_CHECKPOINT', -]); const MAX_RECEIPTS = 128; const MAX_REASONS = 32; const MAX_REASON_LENGTH = 512; @@ -145,7 +141,7 @@ function applyStack(cp, effect) { function asPublicFrame(cp) { return copy({ namespace:cp.namespace,runId:cp.runId,status:cp.status,revision:cp.revision, - state: FINISHED.has(cp.status) ? cp.state : undefined, + ...(FINISHED.has(cp.status) ? {state:cp.state} : {}), ...(cp.status === 'waiting' ? {pending:cp.pending} : {}), ...(cp.status === 'paused' ? {pauseReason:cp.pauseReason ?? 'stage-budget'} : {}), ...(FINISHED.has(cp.status) ? {terminal:cp.terminal ?? null} : {}), @@ -257,7 +253,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt } } function stepCAS(cp, proposal, eventKind, extras={}) { - const next = {...cp,revision:cp.revision+1,...extras}; + const next = {...cp,revision:cp.revision+1}; if (proposal.kind === 'raise-error') { next.status='errored'; next.terminal={status:'errored',nodeId:proposal.nodeId,causeCode:proposal.causeCode}; @@ -376,7 +372,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt const program=await loadPinned(cp); if (FINISHED.has(cp.status) || cp.status==='waiting') return asPublicFrame(cp); if (cp.status === 'paused') { - if (cp.pauseReason !== 'stage-budget') return asPublicFrame(cp); + if (!['stage-budget','skill-temporary'].includes(cp.pauseReason)) return asPublicFrame(cp); const unpause={...cp,status:'running',revision:cp.revision+1,pauseReason:null}; const committed=store.compareAndSwap({runId,expectedRevision:cp.revision, nextCheckpoint:unpause,transitionId:stableTransitionId(cp,'resume-budget'), From 3a3002d156ab5226e9b2f8770efc3b6e26253166 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:45:26 +0900 Subject: [PATCH 03/24] test(interactive): M4 P0-A Program traversal, durable Agent retry, receipt idempotency, restart and tenant isolation --- tests/interactive/coordinator.test.mjs | 333 +++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 tests/interactive/coordinator.test.mjs diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs new file mode 100644 index 0000000..07395be --- /dev/null +++ b/tests/interactive/coordinator.test.mjs @@ -0,0 +1,333 @@ +import test, {after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm,readFile,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; +import {canonicalBytes} from '../../src/interactive/canonical.mjs'; + +const dirs=[]; +after(async()=>{for(const dir of dirs)await rm(dir,{recursive:true,force:true})}); +async function dbPath(){ + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-m4-')); + dirs.push(dir); + return path.join(dir,'runs.db'); +} +const dsl="import {defineWorkflow,condition,node,terminal} from '@devcodex-labs/flowkit/dsl';"; +const schema="{type:'object',properties:{answer:{type:'string'}}}"; +function agentSource({branch=true,retries=2}={}) { + return dsl + "\nexport default defineWorkflow({id:'m4-agent',version:'1.0.0',name:'M4',nodes:[" + +(branch ? "condition('gate',{name:'Gate',branches:[{when:'ready',next:'worker'},{when:'true',next:'fail'}]})," : "") + +"node('worker',{name:'Worker',agent:'writer',inputs:['existing','missing'],output:"+schema + +",retries:"+retries+",overview:true,next:'done'})," + +"terminal('done',{name:'Done',status:'succeeded'})," + +(branch ? "terminal('fail',{name:'Failure',status:'failed'})" : "") + +"]});"; +} +function terminalSource() { + return dsl+"\nexport default defineWorkflow({id:'m4-terminal',version:'1.0.0',name:'M4Terminal',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});"; +} +async function emitted(source) { + const analysis=await analyzeSource(source,{filename:'m4.workflow.js',projectRoot:process.cwd(),skillCheck:false}); + assert.equal(analysis.diagnostics.some(d=>d.severity==='error'),false,JSON.stringify(analysis.diagnostics)); + const ir=lowerInteractive(analysis); + assert.ok(ir.ir,JSON.stringify(ir.diagnostics)); + const output=emitInteractive(ir.ir); + assert.ok(output.code,JSON.stringify(output.diagnostics)); + return {programBytes:Buffer.from(output.code),programIdentity:output.programIdentity}; +} +const bindings={ + skillResolver:async descriptor=>'Write valid JSON for '+descriptor.nodeId, + validateResult:(schema,result)=>result && typeof result==='object' + && typeof result.answer==='string' ? [] : ['answer must be string'], +}; +const check=(result, expected)=>assert.equal(result.status,expected,JSON.stringify(result)); +function coordinator(store,{runtime=bindings,policy={},maxStagesPerCall}={}) { + return createInteractiveCoordinator({store,runtime,policy, + ...(maxStagesPerCall===undefined?{}:{maxStagesPerCall})}); +} +async function runAgentFixture(t,{retries=2,branch=true,runtime=bindings,maxStagesPerCall}={}) { + const file=await dbPath(); + const store=createSqliteCheckpointStore(file,{namespace:'alpha'}); + t.after(()=>store.close()); + const source=await emitted(agentSource({retries,branch})); + const api=coordinator(store,{runtime,maxStagesPerCall}); + const state=branch?{ready:true,existing:'hello'}:{existing:'hello'}; + return {file,store,source,api,state}; +} +function receipt(frame,result={answer:'hello'}) { + return {runId:frame.runId,requestId:frame.pending.requestId, + nodeId:frame.pending.nodeId,attempt:frame.pending.attempt,result}; +} +async function advanceBudget(api,runId,stopStatus='waiting') { + let frame=await api.resume({runId}); + for(let steps=0;steps<40 && frame.status!==stopStatus && frame.status==='paused';steps++) { + frame=await api.resume({runId}); + } + return frame; +} + +test('M4 pure Terminal passes without Agent bindings, then resume is stable',async t=>{ + const file=await dbPath(); + const store=createSqliteCheckpointStore(file,{namespace:'alpha'}); + t.after(()=>store.close()); + const api=coordinator(store,{runtime:{}}); + const source=await emitted(terminalSource()); + const result=await api.start({...source,startKey:'one',state:{value:1}}); + check(result,'succeeded'); + assert.deepEqual(result.state,{value:1}); + assert.equal(result.terminal.nodeId,'done'); + const stable=await api.resume({runId:result.runId}); + assert.equal(stable.revision,result.revision); + assert.deepEqual(stable,result); +}); + +test('M4 generated Program routes Condition before Agent, publishes immutable Pending, then commits verified State',async t=>{ + const {api,store,source,state}=await runAgentFixture(t); + const started=await api.start({...source,startKey:'first',state}); + check(started,'waiting'); + assert.ok(started.revision>=5); + assert.equal(started.pending.kind,'agent'); + assert.equal(started.pending.name,'Worker'); + assert.equal(started.pending.attempt,1); + assert.equal(started.pending.instruction,'Write valid JSON for worker'); + assert.deepEqual(started.pending.input,{existing:'hello'}); + assert.deepEqual(started.pending.missing,['missing']); + assert.deepEqual(started.pending.overview,{step:1}); + assert.deepEqual(started.pending.reasons,[]); + assert.equal(Object.hasOwn(started,'state'),false); // only Terminal discloses complete State + assert.deepEqual(await api.resume({runId:started.runId}),started); + const accepted=await api.complete(receipt(started,{answer:'ok'})); + check(accepted,'succeeded'); + assert.deepEqual(accepted.state,{ready:true,existing:'hello',answer:'ok'}); + assert.ok(accepted.acceptedRevision>started.revision); + assert.deepEqual(store.listJournal(started.runId).map(x=>x.revision), + Array.from({length:accepted.revision+1},(_,i)=>i)); + assert.equal((await api.resume({runId:started.runId})).revision,accepted.revision); +}); + +test('M4 false Condition never emits an Agent Pending and returns failed Terminal',async t=>{ + const {api,source}=await runAgentFixture(t); + const result=await api.start({...source,startKey:'false-branch',state:{ready:false}}); + check(result,'failed'); + assert.equal(result.terminal.nodeId,'fail'); +}); + +test('M4 Standard-compatible missing when input is a durable errored FK_R002 (never a fake waiting)',async t=>{ + const sourceText=dsl + "\nexport default defineWorkflow({id:'missing-when',version:'1.0.0',name:'Missing',nodes:[condition('gate',{name:'Gate',branches:[{when:'review.approved',next:'done'},{when:'true',next:'fail'}]}),terminal('done',{name:'Done',status:'succeeded'}),terminal('fail',{name:'Fail',status:'failed'})]});"; + const source=await emitted(sourceText); + const file=await dbPath(); + const store=createSqliteCheckpointStore(file,{namespace:'alpha'}); + t.after(()=>store.close()); + const api=coordinator(store,{runtime:{}}); + const result=await api.start({...source,startKey:'bad-when',state:{}}); + check(result,'errored'); + assert.equal(result.terminal.causeCode,'FK_R002'); + const again=await api.resume({runId:result.runId}); + assert.equal(again.revision,result.revision); + assert.equal(store.listJournal(result.runId).length,result.revision+1); +}); + +test('M4 duplicate equal receipt returns durable acceptedRevision after multiple later CAS stages',async t=>{ + const {api,source,state}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'dup',state}); + const original=receipt(waiting,JSON.parse('{"answer":"yes","nested":{"b":2,"a":1}}')); + const accepted=await api.complete(original); + check(accepted,'succeeded'); + const same={...original,result:JSON.parse('{"nested":{"a":1,"b":2},"answer":"yes"}')}; + const replay=await api.complete(same); + assert.equal(replay.duplicate,true); + assert.equal(replay.acceptedRevision,accepted.acceptedRevision); + assert.equal(replay.revision,accepted.revision); + assert.deepEqual(replay.state,accepted.state); +}); + +test('M4 wrong node, attempt, requestId, different digest and untrusted event kind all reject without State mutation',async t=>{ + const {api,store,source,state}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'reject',state}); + const id=waiting.runId; + const before=canonicalBytes(store.load(id)); + for(const invalid of [ + {...receipt(waiting),nodeId:'other'}, + {...receipt(waiting),attempt:2}, + {...receipt(waiting),requestId:'req:fake'}, + {...receipt(waiting),kind:'human'}, + ]) { + await assert.rejects(api.complete(invalid),/FK_I_(STALE_RECEIPT|INVALID_RECEIPT)/); + assert.deepEqual(canonicalBytes(store.load(id)),before); + } + const first=await api.complete(receipt(waiting)); + await assert.rejects(api.complete(receipt(waiting,{answer:'changed'})),/FK_I_RECEIPT_CONFLICT/); + assert.equal(store.load(id).revision,first.revision); +}); + +test('M4 retries invalid Agent outputs at most retries+1, preserving State and FK_R004 on exhaustion',async t=>{ + const {api,store,source,state}=await runAgentFixture(t); + let current=await api.start({...source,startKey:'exhaust',state}); + check(current,'waiting'); + const ids=[]; + for(let attempt=1;attempt<=3;attempt++){ + assert.equal(current.pending.attempt,attempt); + ids.push(current.pending.requestId); + const result=await api.complete(receipt(current,{answer:37})); + if(attempt<3){ + check(result,'waiting'); + assert.equal(result.pending.attempt,attempt+1); + assert.deepEqual(result.pending.reasons,['answer must be string']); + current=result; + }else { + check(result,'errored'); + assert.equal(result.terminal.causeCode,'FK_R004'); + assert.deepEqual(result.state,state); + } + } + assert.equal(new Set(ids).size,3); + const final=store.load(current.runId); + assert.equal(final.status,'errored'); + assert.equal(final.receipts.length,3); + assert.equal((await api.resume({runId:current.runId})).revision,final.revision); +}); + +test('M4 synchronous validator that throws or returns undefined becomes terminal contract error without retry',async t=>{ + for(const validateResult of [ + ()=>{throw new Error('secret validator exception')}, + ()=>undefined, + async()=>[], + ]){ + const {api,source,state}=await runAgentFixture(t,{runtime:{...bindings,validateResult}}); + const waiting=await api.start({...source,startKey:'bad-validator',state}); + const result=await api.complete(receipt(waiting)); + check(result,'errored'); + assert.equal(result.terminal.causeCode,'FK_I_VALIDATOR_CONTRACT'); + assert.deepEqual(result.state,state); + assert.equal((await api.resume({runId:waiting.runId})).revision,result.revision); + } +}); + +test('M4 host-reported Agent call failure retries once and eventually stops without executing a model internally',async t=>{ + const {api,source,state}=await runAgentFixture(t,{retries:1}); + const first=await api.start({...source,startKey:'throw',state}); + const failedCall={ + runId:first.runId,requestId:first.pending.requestId, + nodeId:first.pending.nodeId,attempt:1, + kind:'agent-call-failed',failure:{message:'timeout'}, + }; + const next=await api.complete(failedCall); + check(next,'waiting'); + assert.deepEqual(next.pending.reasons,['runAgent threw: timeout']); + const exhausted=await api.complete({ + ...failedCall,requestId:next.pending.requestId,attempt:2, + }); + check(exhausted,'errored'); + assert.equal(exhausted.terminal.causeCode,'FK_R004'); +}); + +test('M4 run start fingerprint rejects drift but same startKey+same intent returns original run',async t=>{ + const {api,source,state}=await runAgentFixture(t); + const first=await api.start({...source,startKey:'daily',state,startIntent:{tenant:'v1'}}); + const again=await api.start({...source,runId:'different-run',startKey:'daily', + state:{existing:'hello',ready:true},startIntent:{tenant:'v1'}}); + assert.equal(again.runId,first.runId); + assert.equal(again.pending.requestId,first.pending.requestId); + await assert.rejects(api.start({...source,startKey:'daily',state:{ready:true}, + startIntent:{tenant:'v1'}}),/FK_I_START_CONFLICT/); + await assert.rejects(api.start({...source,startKey:'daily',state, + startIntent:{tenant:'v2'}}),/FK_I_START_CONFLICT/); +}); + +test('M4 restart with no workflow source uses retained compiler artifact and immutable policy',async t=>{ + const {file,store,api,source,state}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'resume',state}); + const laterStore=createSqliteCheckpointStore(file,{namespace:'alpha'}); + t.after(()=>laterStore.close()); + const restored=coordinator(laterStore,{runtime:bindings}); + assert.deepEqual(await restored.resume({runId:waiting.runId}),waiting); + const success=await restored.complete(receipt(waiting,{answer:'resumed'})); + check(success,'succeeded'); + const wrongPolicy=coordinator(laterStore,{runtime:bindings,policy:{bindingVersion:2}}); + await assert.rejects(wrongPolicy.resume({runId:waiting.runId}),/FK_I_POLICY_MISMATCH/); + assert.equal(store.load(waiting.runId).revision,success.revision); +}); + +test('M4 stage budget=1 checkpoints every step, never returns an empty fake waiting, resumes to the same result',async t=>{ + const {api,source,state}=await runAgentFixture(t,{maxStagesPerCall:1}); + const first=await api.start({...source,startKey:'tiny-budget',state}); + check(first,'paused'); + assert.equal(first.pauseReason,'stage-budget'); + let waiting=await advanceBudget(api,first.runId); + check(waiting,'waiting'); + assert.ok(waiting.pending.requestId); + const paused=await api.complete(receipt(waiting,{answer:'success'})); + check(paused,'paused'); + let ending=await advanceBudget(api,first.runId,'succeeded'); + check(ending,'succeeded'); + assert.equal(ending.state.answer,'success'); +}); + +test('M4 changing client-owned input object while waiting does not mutate persisted State',async t=>{ + const {api,source,state}=await runAgentFixture(t); + state.existing='before'; + const waiting=await api.start({...source,startKey:'snapshot',state}); + state.existing='after'; + const result=await api.complete(receipt(waiting)); + check(result,'succeeded'); + assert.equal(result.state.existing,'before'); +}); + +test('M4 rejects unsupported checkStep and missing runtime bindings before start writes',async t=>{ + const {store,source,state}=await runAgentFixture(t); + for(const runtime of [ + {validateResult:bindings.validateResult}, + {skillResolver:bindings.skillResolver}, + {...bindings,checkStep:()=>null}, + ]){ + const api=coordinator(store,{runtime}); + await assert.rejects(api.start({...source,startKey:'not-started',state}), + /FK_I_(BINDING_MISSING|UNSUPPORTED_GRAPH)/); + } + assert.deepEqual(store.listJournal('not-started'),[]); +}); + +test('M4 concurrent complete from two independent Store connections accepts one receipt but returns same Ack',async t=>{ + const {file,api,source,state,store}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'race',state}); + const otherStore=createSqliteCheckpointStore(file,{namespace:'alpha'}); + t.after(()=>otherStore.close()); + const second=coordinator(otherStore); + const [a,b]=await Promise.all([ + api.complete(receipt(waiting)), + second.complete(receipt(waiting)), + ]); + assert.equal(a.acceptedRevision,b.acceptedRevision); + assert.ok(a.duplicate!==b.duplicate); + assert.equal(store.load(waiting.runId).receipts.length,1); + assert.equal(store.load(waiting.runId).status,'succeeded'); +}); + +test('M4 rejects other tenant runId without exposing checkpoint, even with same compiled workflow',async t=>{ + const {file,api,source,state}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'private-run',state}); + const foreign=createSqliteCheckpointStore(file,{namespace:'beta'}); + t.after(()=>foreign.close()); + const other=coordinator(foreign); + assert.equal(await other.load({runId:waiting.runId}),null); + await assert.rejects(other.resume({runId:waiting.runId}),/FK_I_RUN_NOT_FOUND/); + await assert.rejects(other.complete(receipt(waiting)),/FK_I_RUN_NOT_FOUND/); +}); + +test('M4 forbids invalid JSON and preserve __proto__ as State data after verified merge',async t=>{ + const {api,source}=await runAgentFixture(t); + const state=JSON.parse('{"ready":true,"__proto__":{"safe":"state"}}'); + const waiting=await api.start({...source,startKey:'special-data',state}); + const result=await api.complete(receipt(waiting,JSON.parse('{"answer":"fine","__proto__":{"safe":"result"}}'))); + check(result,'succeeded'); + assert.equal(result.state.__proto__.safe,'result'); + assert.equal({}.safe,undefined); + await assert.rejects(api.start({...source,startKey:'accessor', + state:{ready:true,bad:NaN}}),/canonical JSON number/i); +}); From 6113341a1a2bf756c869477fa5b0fb7560dc5284 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:46:27 +0900 Subject: [PATCH 04/24] feat(interactive): M4 explicitly scoped public interactive subpath --- src/interactive/index.mjs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/interactive/index.mjs diff --git a/src/interactive/index.mjs b/src/interactive/index.mjs new file mode 100644 index 0000000..e806ef1 --- /dev/null +++ b/src/interactive/index.mjs @@ -0,0 +1,5 @@ +/** P0-A Host-owned Interactive SDK. Existing Standard root exports stay unchanged. */ +export {compileInteractive} from './compile.mjs'; +export {loadInteractiveProgram} from './load.mjs'; +export {createSqliteCheckpointStore} from './sqlite-store.mjs'; +export {createInteractiveCoordinator} from './coordinator.mjs'; From 0e912abce27f0972e8d1a51777788b54619f6e90 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:46:55 +0900 Subject: [PATCH 05/24] types(interactive): M4 host-only Coordinator, Pending, Store and compiler contracts --- types/interactive.d.ts | 163 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 types/interactive.d.ts diff --git a/types/interactive.d.ts b/types/interactive.d.ts new file mode 100644 index 0000000..1a68e94 --- /dev/null +++ b/types/interactive.d.ts @@ -0,0 +1,163 @@ +/** + * P0-A / Interactive-state-machine-v1. + * Host-owned execution only; a model may never independently call complete(). + * Not interchangeable with the six Standard root functions. + */ +export interface InteractiveProgramIdentity { + artifactHash: string; + sourceHash: string; + workflowId: string; + workflowVersion: string; + compilerVersion: string; + programAbiVersion: 1; + semanticsVersion: 'interactive-state-machine-v1'; + checkpointSchemaVersion: 1; + canonicalizationVersion: 'jcs-rfc8785-v1'; + implementedStage: 'P0-A'; +} +export interface InteractiveCompileOptions { + output: 'memory' | 'file'; + featureStage: 'P0-A'; + outDir?: string; + targetVersion?: 'interactive-state-machine-v1'; +} +export interface InteractiveDiagnostic { + code: string; + severity: 'error' | 'warning'; + message: string; + [key: string]: unknown; +} +export interface InteractiveCompileResult { + code: string; + diagnostics: InteractiveDiagnostic[]; + programIdentity?: InteractiveProgramIdentity; + artifactPath?: string; +} +export declare function compileInteractive( + file: string, options: InteractiveCompileOptions +): Promise; +export interface LoadedInteractiveProgram { + artifactHash: string; + interactiveProgram: { + artifactFormat: 'FlowkitInteractiveProgramV1'; + identity: Omit; + }; + programTable: { + schemaVersion: 1; + entryBlockId: string; + blocks: readonly Record[]; + nodes: Record; + requiredBindings: readonly Record[]; + }; + advance(view: unknown, event: unknown): unknown; +} +export declare function loadInteractiveProgram( + bytes: Uint8Array, expectedArtifactHash: string +): Promise; + +export interface InteractiveAgentPending { + kind: 'agent'; + runId: string; + nodeId: string; + name: string; + requestId: string; + attempt: number; + instruction: string; + input: Record; + missing: string[]; + resultSchema: Record; + reasons: string[]; + overview?: {step: number}; +} +export type InteractiveRunStatus = + 'running' | 'waiting' | 'paused' | 'succeeded' | 'failed' | 'errored'; +export interface InteractiveRunFrame { + namespace: string; + runId: string; + status: InteractiveRunStatus; + revision: number; + state?: Record; + pending?: InteractiveAgentPending; + pauseReason?: string; + terminal?: {status: 'succeeded'|'failed'|'errored'; nodeId: string; causeCode?: string}|null; +} +export type InteractiveCheckpoint = { + schemaVersion: 1; + namespace: string; + runId: string; + revision: number; + artifactHash: string; + status: InteractiveRunStatus; + blockId: string; + phase: string; + state: Record; + controlStack: unknown[]; + [key: string]: unknown; +}; +export interface CheckpointStore { + readonly namespace: string; + close(): void; + putArtifact(input: {programIdentity: InteractiveProgramIdentity; bytes: Uint8Array}): string; + getArtifact(hash: string): {programIdentity: InteractiveProgramIdentity;bytes: Uint8Array}|null; + deleteArtifact(hash: string): boolean; + createRun(input: { + startKey: string; + startIntent?: unknown; + checkpoint: InteractiveCheckpoint; + }): {created: boolean;checkpoint: InteractiveCheckpoint}; + load(runId: string): InteractiveCheckpoint|null; + compareAndSwap(input: { + runId: string; + expectedRevision: number; + nextCheckpoint: InteractiveCheckpoint; + transitionId: string; + journalEntry?: unknown; + }): {committed:boolean;checkpoint:InteractiveCheckpoint}; + listJournal(runId: string): unknown[]; +} +export declare function createSqliteCheckpointStore( + file: string, options: {namespace:string} +): CheckpointStore; + +export interface InteractiveSkillDescriptor { + runId: string; + nodeId: string; + name: string; + agent: string|null; + skill: string|null; + integrity: string|null; +} +export interface InteractiveRuntime { + skillResolver?: (descriptor: InteractiveSkillDescriptor) => string|Promise; + validateResult?: (schema: Record, result: unknown) => string[]; + /** checkStep is deliberately NOT supported in P0-A. */ + checkStep?: never; +} +export interface InteractiveCoordinator { + readonly policyHash: string; + start(input: { + programBytes: Uint8Array; + programIdentity: InteractiveProgramIdentity; + startKey: string; + runId?: string; + state?: Record; + startIntent?: unknown; + }): Promise; + complete(input: { + runId: string; + requestId: string; + nodeId: string; + attempt: number; + kind?: 'agent-result' | 'agent-call-failed'; + result?: unknown; + failure?: {message:string}; + }): Promise; + resume(input: {runId:string}): Promise; + load(input: {runId:string}): Promise; +} +export declare function createInteractiveCoordinator(input: { + store: CheckpointStore; + runtime: InteractiveRuntime; + policy?: Record; + maxStagesPerCall?: number; +}): InteractiveCoordinator; From e2578d394b47b755225ae5a3ba8ffcd8b2658ed9 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:47:08 +0900 Subject: [PATCH 06/24] build(interactive): include M4 Coordinator, SDK entry and declarations in exact package manifest --- scripts/package-files.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index 6f38d7e..f33268a 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -10,8 +10,8 @@ export const packageFiles = [ ...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]), ...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]), ['src/compiler/interactive/emit.mjs', 'dist/compiler/interactive/emit.mjs'], - ...['compile', 'identity', 'load', 'canonical', 'sqlite-store'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), + ...['compile', 'identity', 'load', 'canonical', 'sqlite-store', 'coordinator', 'index'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), ...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]), ...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]), - ...['dsl', 'index', 'compiler-api'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), + ...['dsl', 'index', 'compiler-api', 'interactive'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), ]; From 989e9eb979173937a10511dd63043942d256df61 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:47:20 +0900 Subject: [PATCH 07/24] feat(interactive): expose M4 SDK from isolated ./interactive subpath only --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 07488d6..d06238c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,11 @@ "import": "./dist/integrations/capability-graph.mjs", "default": "./dist/integrations/capability-graph.mjs" }, + "./interactive": { + "types": "./dist/interactive.d.ts", + "import": "./dist/interactive/index.mjs", + "default": "./dist/interactive/index.mjs" + }, "./package.json": "./package.json" }, "files": [ From 5c00c2d965afa0342fb3fb05d5eb31206a902fb2 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:47:34 +0900 Subject: [PATCH 08/24] test(interactive): assert isolated SDK export while preserving Standard contract --- scripts/check-package.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 27318e8..88f9f6e 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -55,8 +55,8 @@ export async function validatePackage(root, packedPaths) { assert.deepEqual(lock.packages[''].dependencies, pkg.dependencies); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[''].name, pkg.name); assert.equal(lock.packages[''].version, pkg.version); assert.deepEqual(pkg.files, ['dist', 'README.md', 'LICENSE']); - assert.deepEqual(Object.keys(pkg.exports), ['.', './dsl', './capability-graph', './package.json']); - for (const [key, types, module] of [['.', './dist/compiler-api.d.ts', './dist/index.mjs'], ['./dsl', './dist/dsl.d.ts', './dist/dsl.mjs'], ['./capability-graph', './dist/capability-graph.d.ts', './dist/integrations/capability-graph.mjs']]) { + assert.deepEqual(Object.keys(pkg.exports), ['.', './dsl', './capability-graph', './interactive', './package.json']); + for (const [key, types, module] of [['.', './dist/compiler-api.d.ts', './dist/index.mjs'], ['./dsl', './dist/dsl.d.ts', './dist/dsl.mjs'], ['./capability-graph', './dist/capability-graph.d.ts', './dist/integrations/capability-graph.mjs'], ['./interactive', './dist/interactive.d.ts', './dist/interactive/index.mjs']]) { assert.deepEqual(Object.keys(pkg.exports[key]), ['types', 'import', 'default']); assert.deepEqual(pkg.exports[key], { types, import: module, default: module }); } From 91114a9ca0716695f7033dca3b1a395de6e1361c Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:47:37 +0900 Subject: [PATCH 09/24] test(interactive): assert isolated SDK export while preserving Standard contract --- tests/contracts.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index 830256e..4b59290 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -33,6 +33,6 @@ test('字段、类型和目录边界固定且不可改写', async () => { assert.equal(typeof pkg.private, 'boolean'); assert.equal(pkg.type, 'module'); assert.equal(pkg.engines.node, '>=22.13.0'); - assert.deepEqual(Object.keys(pkg.exports), ['.', './dsl', './capability-graph', './package.json']); + assert.deepEqual(Object.keys(pkg.exports), ['.', './dsl', './capability-graph', './interactive', './package.json']); assert.deepEqual(pkg.dependencies, { acorn: '8.19.0' }); }); From 9e7b8d1e438d2144e7c0afe0fa8f174219065b1a Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:48:01 +0900 Subject: [PATCH 10/24] test(interactive): consume real M4 SDK from installed private package --- tests/package/consumer.mjs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/package/consumer.mjs b/tests/package/consumer.mjs index 423b251..b56db45 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import * as api from '@devcodex-labs/flowkit'; +import * as interactive from '@devcodex-labs/flowkit/interactive'; import * as dsl from '@devcodex-labs/flowkit/dsl'; import pkg from '@devcodex-labs/flowkit/package.json' with { type: 'json' }; assert.deepEqual(Object.keys(api), ['check', 'compile', 'compileSource', 'evalWhen', 'isControlSignal', 'run']); @@ -26,3 +27,28 @@ for (const mode of ['source', 'aot', 'dump']) { } assert.equal(api.evalWhen({ runtime: {}, state: { ready: true } }, 'ready'), true); assert.equal(api.isControlSignal({ control: true }), true); assert.equal(await readFile('sample.compiled.mjs', 'utf8'), output.code); + + +assert.deepEqual(Object.keys(interactive),[ + 'compileInteractive','createInteractiveCoordinator','createSqliteCheckpointStore','loadInteractiveProgram', +]); +await writeFile('simple.workflow.js', + "import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl';\n"+ + "export default defineWorkflow({id:'isolated-interactive',version:'1.0.0',name:'Public M4',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});\n"); +const compiledInteractive=await interactive.compileInteractive('simple.workflow.js',{ + output:'memory',featureStage:'P0-A', +}); +assert.ok(compiledInteractive.programIdentity?.artifactHash); +const store=interactive.createSqliteCheckpointStore('interactive-demo.db',{namespace:'consumer'}); +try { + const machine=interactive.createInteractiveCoordinator({store,runtime:{}}); + const frame=await machine.start({ + programBytes:Buffer.from(compiledInteractive.code), + programIdentity:compiledInteractive.programIdentity, + startKey:'consumer-test', + state:{installed:true}, + }); + assert.equal(frame.status,'succeeded'); + assert.deepEqual(frame.state,{installed:true}); + assert.equal((await machine.resume({runId:frame.runId})).revision,frame.revision); +} finally {store.close();} From 0ca3cf3f868bf73d56f36bc6578420bbc2ff88e9 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:48:22 +0900 Subject: [PATCH 11/24] test(interactive): M4 TypeScript public subpath and forbidden P0-B capabilities --- tests/types/interactive.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/types/interactive.ts diff --git a/tests/types/interactive.ts b/tests/types/interactive.ts new file mode 100644 index 0000000..81efb91 --- /dev/null +++ b/tests/types/interactive.ts @@ -0,0 +1,35 @@ +import { + compileInteractive,createInteractiveCoordinator,createSqliteCheckpointStore,loadInteractiveProgram, +} from '../../types/interactive.js'; +import type { + InteractiveRuntime, InteractiveProgramIdentity, InteractiveRunFrame, CheckpointStore, + InteractiveCompileOptions, +} from '../../types/interactive.js'; + +const options: InteractiveCompileOptions={output:'memory',featureStage:'P0-A'}; +const compiled=compileInteractive('flow.workflow.ts',options); +declare const artifact: InteractiveProgramIdentity; +declare const bytes: Uint8Array; +const loader=loadInteractiveProgram(bytes,artifact.artifactHash); +const store: CheckpointStore=createSqliteCheckpointStore('test.db',{namespace:'tenant'}); +const runtime: InteractiveRuntime={ + skillResolver:async d=>d.name, + validateResult:(_schema,_result)=>[], +}; +const coordinator=createInteractiveCoordinator({store,runtime,maxStagesPerCall:8,policy:{bindingVersion:1}}); +const start: Promise=coordinator.start({ + programBytes:bytes,programIdentity:artifact,startKey:'job-1',state:{}, +}); +const finish=coordinator.complete({runId:'r',requestId:'request',nodeId:'n',attempt:1,result:{answer:'ok'}}); +const resume: Promise=coordinator.resume({runId:'r'}); +void [compiled,loader,start,finish,resume]; +// @ts-expect-error featureStage must be only P0-A +compileInteractive('x',{output:'memory',featureStage:'P0-B'}); +// @ts-expect-error validators remain synchronous +const asyncValidator: NonNullable=async()=>[]; +// @ts-expect-error no checkStep until P0-B +const unsupported: InteractiveRuntime={checkStep:()=>null}; +// @ts-expect-error required durable idempotency key +coordinator.start({programBytes:bytes,programIdentity:artifact}); +// @ts-expect-error correct attempt required on complete +coordinator.complete({runId:'r',requestId:'q',nodeId:'n',result:{}}); From 85c9af685ae048be47d024307c78e901516d8db8 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:48:35 +0900 Subject: [PATCH 12/24] test(types): include M4 Interactive compile-time contract checks --- tests/types/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/types/tsconfig.json b/tests/types/tsconfig.json index d212f4c..f3e34bd 100644 --- a/tests/types/tsconfig.json +++ b/tests/types/tsconfig.json @@ -16,6 +16,7 @@ "public-contract.ts", "compiler-api.ts", "../../src/internal.d.ts", - "capability-graph.ts" + "capability-graph.ts", + "interactive.ts" ] } From cb29e8500cfd699be5e8ff8689a93b23c684a5d1 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:53:03 +0900 Subject: [PATCH 13/24] fix(interactive): refuse malformed failure receipts and primitive accepted results, harden validator snapshots --- src/interactive/coordinator.mjs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 15bd709..4a23b30 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -3,7 +3,9 @@ import {canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './can import {loadInteractiveProgram} from './load.mjs'; const FINISHED = new Set(['succeeded','failed','errored']); -const MAX_RECEIPTS = 128; +// P0-A has at most 500 static nodes and 11 attempts per Agent. +// Do not cap legitimate 500-node workflows below their worst-case 5,500 receipts. +const MAX_RECEIPTS = 8192; const MAX_REASONS = 32; const MAX_REASON_LENGTH = 512; @@ -159,6 +161,9 @@ function inputForAgent(state, fields) { return {input,missing}; } function reasonsFromValidation(value) { + // Snapshot *before* array inspection: reject sparse arrays, proxies, getters, + // cycles and post-return mutation instead of accepting holes as undefined. + value = copy(value); if (!Array.isArray(value) || value.length > MAX_REASONS || value.some(v => typeof v !== 'string' || !v.length || v.length > MAX_REASON_LENGTH)) { throw failure('FK_I_VALIDATOR_CONTRACT','validateResult must return bounded string[]'); @@ -348,7 +353,13 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt const schema=copy(program.programTable.nodes[block.nodeId].metadata.output); if (cp.candidate.submissionKind === 'agent-call-failed') { return {kind:'agent-call-failed',reasons: ['runAgent threw: ' + - (cp.candidate.failure?.message ?? 'Agent call failed').slice(0,MAX_REASON_LENGTH-16)]}; + cp.candidate.failure.message.slice(0,MAX_REASON_LENGTH-16)]}; + } + // Compiler output schema is an object. An incorrectly permissive Host validator + // must not pass a primitive that would later strand the commit stage. + if (!cp.candidate.result || typeof cp.candidate.result !== 'object' + || Array.isArray(cp.candidate.result)) { + return {kind:'validation-rejected',reasons:['Agent result must be object']}; } let reasons; try { @@ -476,9 +487,18 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt if (!['agent-result','agent-call-failed'].includes(kind)) { throw failure('FK_I_INVALID_RECEIPT','Unsupported P0-A receipt type'); } + let safeFailure; + if (kind === 'agent-call-failed') { + safeFailure=copy(callFailure); + if (!safeFailure || typeof safeFailure !== 'object' || Array.isArray(safeFailure) + || typeof safeFailure.message !== 'string' || !safeFailure.message.length + || safeFailure.message.length > 4096 || Object.keys(safeFailure).length !== 1) { + throw failure('FK_I_INVALID_RECEIPT','Agent failure requires bounded {message:string}'); + } + } const submission=copy(kind==='agent-result' ? {runId,requestId,nodeId,attempt,kind,result} - : {runId,requestId,nodeId,attempt,kind,failure:callFailure}); + : {runId,requestId,nodeId,attempt,kind,failure:safeFailure}); const digest=fingerprint(submission); for (;;) { const cp=store.load(runId); From ab705e68cbed402f1cd76060f8ea0a612c04d1d6 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:53:28 +0900 Subject: [PATCH 14/24] test(interactive): M4 malformed Host failures, primitive results and sparse verifier data --- tests/interactive/coordinator.test.mjs | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index 07395be..4503c23 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -331,3 +331,42 @@ test('M4 forbids invalid JSON and preserve __proto__ as State data after verifie await assert.rejects(api.start({...source,startKey:'accessor', state:{ready:true,bad:NaN}}),/canonical JSON number/i); }); + + +test('M4 malformed Host failure message is rejected before receipt CAS and leaves waiting replayable',async t=>{ + const {api,store,source,state}=await runAgentFixture(t); + const waiting=await api.start({...source,startKey:'malformed-failure',state}); + const before=canonicalBytes(store.load(waiting.runId)); + for(const bad of [12,null,{}, {message:12},{message:''},{message:'x',extra:'y'}]){ + await assert.rejects(api.complete({ + runId:waiting.runId,requestId:waiting.pending.requestId, + nodeId:waiting.pending.nodeId,attempt:waiting.pending.attempt, + kind:'agent-call-failed',failure:bad, + }),/FK_I_INVALID_RECEIPT|Expected plain JSON/i); + assert.deepEqual(canonicalBytes(store.load(waiting.runId)),before); + } + const good=await api.complete(receipt(waiting)); + check(good,'succeeded'); +}); + +test('M4 an overly permissive validator cannot strand a primitive candidate at commit',async t=>{ + const runtime={...bindings,validateResult:()=>[]}; + const {api,source,state}=await runAgentFixture(t,{runtime}); + const waiting=await api.start({...source,startKey:'primitive',state}); + const retried=await api.complete(receipt(waiting,null)); + check(retried,'waiting'); + assert.deepEqual(retried.pending.reasons,['Agent result must be object']); + const done=await api.complete(receipt(retried,{answer:'safe'})); + check(done,'succeeded'); + assert.equal(done.state.answer,'safe'); +}); + +test('M4 a sparse validator reasons array fails closed without committing invalid State',async t=>{ + const runtime={...bindings,validateResult:()=>Array(1)}; + const {api,source,state}=await runAgentFixture(t,{runtime}); + const waiting=await api.start({...source,startKey:'sparse-reasons',state}); + const result=await api.complete(receipt(waiting)); + check(result,'errored'); + assert.equal(result.terminal.causeCode,'FK_I_VALIDATOR_CONTRACT'); + assert.deepEqual(result.state,state); +}); From d088317effebdd7192cdd93f787bb2167fbd3528 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:56:18 +0900 Subject: [PATCH 15/24] test(interactive): SIGKILL fault injection around prepare, publish, receipt, validate and commit on real Node processes --- tests/interactive/crash.test.mjs | 148 +++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/interactive/crash.test.mjs diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs new file mode 100644 index 0000000..88a0c9d --- /dev/null +++ b/tests/interactive/crash.test.mjs @@ -0,0 +1,148 @@ +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {spawn} from 'node:child_process'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; + +const dirs=[]; +after(async()=>{for(const d of dirs)await rm(d,{recursive:true,force:true})}); +const runtime={ + skillResolver:async ()=>'Agent instruction', + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['invalid answer'], +}; +async function fixture(t) { + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-m4-cut-')); + dirs.push(dir); + const file=path.join(dir,'state.db'); + const source="import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl';\n"+ + "export default defineWorkflow({id:'crash-agent',version:'1.0.0',name:'Crash',nodes:["+ + "node('worker',{name:'Worker',agent:'writer',inputs:['preexisting'],"+ + "output:{type:'object',properties:{answer:{type:'string'}}},retries:0,next:'done'}),"+ + "terminal('done',{name:'Done',status:'succeeded'})]});"; + const analyzed=await analyzeSource(source,{filename:'crash.workflow.js',projectRoot:process.cwd(),skillCheck:false}); + assert.ok(!analyzed.diagnostics.some(x=>x.severity==='error'),JSON.stringify(analyzed.diagnostics)); + const ir=lowerInteractive(analyzed); + assert.ok(ir.ir,JSON.stringify(ir.diagnostics)); + const emitted=emitInteractive(ir.ir); + const compiled={bytes:Buffer.from(emitted.code),identity:emitted.programIdentity}; + const db=createSqliteCheckpointStore(file,{namespace:'test-host'}); + t.after(()=>db.close()); + const api=createInteractiveCoordinator({store:db,runtime}); + return {file,compiled,db,api}; +} + +const moduleStore=new URL('../../src/interactive/sqlite-store.mjs',import.meta.url).href; +const moduleCoordinator=new URL('../../src/interactive/coordinator.mjs',import.meta.url).href; +const childScript=[ + 'import {createSqliteCheckpointStore} from '+JSON.stringify(moduleStore)+';', + 'import {createInteractiveCoordinator} from '+JSON.stringify(moduleCoordinator)+';', + "import {writeSync} from 'node:fs';", + 'const [filename,mode,phase,side,runId,bytes64,identity,receiptJson]=process.argv.slice(1);', + "const db=createSqliteCheckpointStore(filename,{namespace:'test-host'});", + 'let observed=false;', + 'const kill=()=>{observed=true;writeSync(1,"M4_CUT\\n");process.kill(process.pid,"SIGKILL");};', + 'const store={...db,compareAndSwap(args){', + ' const isTarget=args.nextCheckpoint.phase===phase &&', + ' (mode==="start" || args.nextCheckpoint.revision>6);', + ' if(isTarget && side==="before")kill();', + ' const answer=db.compareAndSwap(args);', + ' if(isTarget && answer.committed && side==="after")kill();', + ' return answer;', + '}};', + 'const api=createInteractiveCoordinator({store,runtime:{', + ' skillResolver:async()=>"Agent instruction",', + ' validateResult:(_schema,r)=>typeof r?.answer==="string"?[]:["invalid answer"],', + '}});', + 'if(mode==="start")await api.start({', + ' runId,programBytes:Buffer.from(bytes64,"base64"),programIdentity:JSON.parse(identity),', + ' startKey:"host-cut",state:{preexisting:"constant"},', + '});else await api.complete(JSON.parse(receiptJson));', + 'if(!observed)throw new Error("CUT_NOT_HIT");', +].join('\n'); + +async function spawnAndKill({file,compiled,mode,phase,side,runId,receipt}) { + const args=['--input-type=module','-e',childScript, + file,mode,phase,side,runId,compiled.bytes.toString('base64'), + JSON.stringify(compiled.identity),JSON.stringify(receipt??null)]; + return new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,args,{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr=''; + child.stdout.on('data',b=>{stdout+=b}); + child.stderr.on('data',b=>{stderr+=b}); + child.on('error',reject); + child.on('close',(exitCode,signal)=>{ + if(!stdout.includes('M4_CUT'))return reject(new Error( + 'No forced cut at '+mode+'/'+phase+'/'+side+'; exit='+exitCode+ + ' signal='+signal+'; stderr='+stderr + )); + if(exitCode===0)return reject(new Error('Worker survived SIGKILL')); + resolve({exitCode,signal}); + }); + }); +} + +test('M4 real process SIGKILL before/after Agent preparation and durable publish resumes once',async t=>{ + for(const side of ['before','after']){ + const {file,compiled,db,api}=await fixture(t); + const runId='start-'+side; + await spawnAndKill({file,compiled,mode:'start',phase:'awaiting',side,runId}); + const checkpoint=db.load(runId); + assert.ok(checkpoint); + assert.ok(['dispatch-ready','awaiting'].includes(checkpoint.phase)); + const waiting=await api.resume({runId}); + assert.equal(waiting.status,'waiting'); + assert.equal(waiting.pending.attempt,1); + assert.equal(waiting.pending.nodeId,'worker'); + const again=await api.resume({runId}); + assert.equal(again.pending.requestId,waiting.pending.requestId); + assert.equal(again.revision,waiting.revision); + const done=await api.complete({ + runId,requestId:waiting.pending.requestId,nodeId:'worker',attempt:1, + result:{answer:'complete'}, + }); + assert.equal(done.status,'succeeded'); + assert.equal(done.state.answer,'complete'); + assert.equal(db.load(runId).receipts.length,1); + assert.deepEqual(db.listJournal(runId).map(x=>x.revision), + Array.from({length:done.revision+1},(_,i)=>i)); + } +}); + +test('M4 real process SIGKILL before/after receipt acceptance, validation and state commit',async t=>{ + for(const phase of ['validate','commit','route']){ + for(const side of ['before','after']){ + const {file,compiled,db,api}=await fixture(t); + const runId='complete-'+phase+'-'+side; + const waiting=await api.start({ + runId,programBytes:compiled.bytes,programIdentity:compiled.identity, + startKey:runId,state:{preexisting:'constant'}, + }); + assert.equal(waiting.status,'waiting'); + const receipt={ + runId,requestId:waiting.pending.requestId,nodeId:'worker',attempt:1, + result:{answer:'once'}, + }; + await spawnAndKill({file,compiled,mode:'complete',phase,side,runId,receipt}); + const observed=db.load(runId); + assert.ok(['waiting','running'].includes(observed.status)); + let finished; + if(observed.status==='waiting') finished=await api.complete(receipt); + else finished=await api.resume({runId}); + assert.equal(finished.status,'succeeded'); + assert.deepEqual(finished.state,{preexisting:'constant',answer:'once'}); + const duplicate=await api.complete(receipt); + assert.equal(duplicate.duplicate,true); + assert.equal(duplicate.acceptedRevision,db.load(runId).receipts[0].acceptedRevision); + assert.equal(duplicate.revision,finished.revision); + assert.equal(db.load(runId).receipts.length,1); + assert.deepEqual(db.listJournal(runId).map(x=>x.revision), + Array.from({length:finished.revision+1},(_,i)=>i)); + } + } +}); From ccf3bbec4aed66356eb72993fe0125a6b832091f Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:58:06 +0900 Subject: [PATCH 16/24] test(interactive): target real phase cuts without assuming a fixed revision count --- tests/interactive/crash.test.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs index 88a0c9d..fdc8981 100644 --- a/tests/interactive/crash.test.mjs +++ b/tests/interactive/crash.test.mjs @@ -48,8 +48,7 @@ const childScript=[ 'let observed=false;', 'const kill=()=>{observed=true;writeSync(1,"M4_CUT\\n");process.kill(process.pid,"SIGKILL");};', 'const store={...db,compareAndSwap(args){', - ' const isTarget=args.nextCheckpoint.phase===phase &&', - ' (mode==="start" || args.nextCheckpoint.revision>6);', + ' const isTarget=args.nextCheckpoint.phase===phase;', ' if(isTarget && side==="before")kill();', ' const answer=db.compareAndSwap(args);', ' if(isTarget && answer.committed && side==="after")kill();', From c2ca175a468467a4e76de6c8733e57bd97e32305 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:59:43 +0900 Subject: [PATCH 17/24] docs(interactive): M4 Host-only execution contract and crash-recovery integration guide --- docs/interactive.md | 136 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/interactive.md diff --git a/docs/interactive.md b/docs/interactive.md new file mode 100644 index 0000000..1b553b5 --- /dev/null +++ b/docs/interactive.md @@ -0,0 +1,136 @@ +# FlowKit Interactive — P0-A M4 Host Coordinator + +> **Internal/maintainer integration only.** The Standard root API remains exactly +> \`run / compile / compileSource / check / evalWhen / isControlSignal\`. +> Interactive APIs live under \`@devcodex-labs/flowkit/interactive\`. +> This does **not** grant a model permission to act as a trusted Host. + +## Supported features + +- **Implemented**: Condition, Terminal, Agent (no Agent verify/checkStep), immutable compiled JavaScript Program, original Standard analyzer, namespace-scoped SQLite WAL/FULL Checkpoint Store, phase-by-phase CAS, deterministic Agent preparation, persist-before-publish, receipt-accept-before-validate, local sync validation, retry exhaustion, crash resume, final State and error cause. +- **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic \`runAgent\` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions. +- The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct \`complete()\` authority. + +## Example: compile, start, resolve pending, resume + +The source is a validated \`*.workflow.js\` or \`*.workflow.ts\` using +FlowKit's existing DSL; it is **not imported or executed** while compiling. + +~~~js +import {readFile} from 'node:fs/promises'; +import { + compileInteractive, createSqliteCheckpointStore, createInteractiveCoordinator, +} from '@devcodex-labs/flowkit/interactive'; + +const compiled = await compileInteractive('tasks/review.workflow.js', { + output: 'memory', + featureStage: 'P0-A', +}); +if (compiled.diagnostics.some(d => d.severity === 'error')) { + throw new Error('Interactive compile rejected the workflow'); +} + +const store = createSqliteCheckpointStore('.host-private/review.db', { + namespace: 'host-tenant-001', +}); +try { + const coordinator = createInteractiveCoordinator({ + store, + policy: {bindingVersion: 1, hostPolicyId: 'host-policy-v1'}, + runtime: { + // Trusted-read: return the instruction; do not make model calls here. + skillResolver: async ({agent, skill, nodeId}) => { + if (!agent || skill) throw new Error('Unsupported skill mapping in this example'); + return readFile(new URL('skills/' + agent + '.md', import.meta.url), 'utf8'); + }, + // Pure, synchronous local output schema validator. + validateResult: (_outputSchema, result) => + typeof result?.answer === 'string' ? [] : ['answer is required'], + }, + }); + + let frame = await coordinator.start({ + programBytes: Buffer.from(compiled.code, 'utf8'), + programIdentity: compiled.programIdentity, + startKey: 'review-2026-11-10', + state: {ready: true, message: 'Review the supplied draft'}, + }); + + while (frame.status === 'waiting') { + const task = frame.pending; // Only published after durable CAS. + // modelResult = await HOST_OWNED_AGENT_TRANSPORT(task); + const modelResult = {answer: 'Reviewed'}; // demonstration only + frame = await coordinator.complete({ + runId: task.runId, + requestId: task.requestId, + nodeId: task.nodeId, + attempt: task.attempt, + kind: 'agent-result', + result: modelResult, + }); + } + while (frame.status === 'paused' && frame.pauseReason === 'stage-budget') { + frame = await coordinator.resume({runId: frame.runId}); + } + if (frame.status === 'errored') throw new Error(frame.terminal?.causeCode); + console.log(frame.status, frame.state); +} finally { + store.close(); +} +~~~ + +## Invariants and recovery + +1. **Program is the routing authority.** The generated \`advance(view,event)\` + and its verified immutable \`programTable\` supply legal stage transitions. + The Coordinator does not reconstruct DSL routing or let model output choose + \`toBlockId\`. +2. **A durable Pending precedes delivery.** The Host may dispatch only the + \`frame.pending\` returned from a successful \`waiting\` CAS. A restart with + that run ID returns the same \`requestId\` and persisted task. +3. **Receipt is stored before validation.** \`complete()\` atomically stores the + result/failure, its canonical digest, \`acceptedRevision\` and the \`validate\` + Checkpoint. The local validator runs only afterward. If the process stops + between the two steps, \`resume()\` re-validates the **same** candidate, + without publishing the previous request again. +4. **Receipt retransmission is idempotent.** The same canonical body with the + same \`requestId/nodeId/attempt\` returns the original + \`acceptedRevision\` even if several later revisions exist; different + content returns \`FK_I_RECEIPT_CONFLICT\`. +5. **Only a validated result may modify State.** Validation failure retries + with a new deterministic request ID up to \`retries+1\`; exhaustion becomes + \`errored(FK_R004)\`. A malformed pure validator becomes + \`errored(FK_I_VALIDATOR_CONTRACT)\`. No candidate is merged before a + successful validation and commit-stage CAS. +6. **Identity is fixed.** The run pins \`namespace\`, Program artifact bytes, + start intent, and policy fingerprint. Rebinding or changing policy requires + a compatible, explicitly managed procedure; it is not inferred from source + or a new Program file. +7. **Budget is explicit.** A finite \`maxStagesPerCall\` (1–128, default 128) + produces a \`paused(stage-budget)\` checkpoint, which \`resume()\` can + advance. It is never a fake \`waiting\` frame. + +## Security / operational boundary + +- Treat \`runtime.skillResolver\` as **trusted read-only code**: it may be + invoked again after a conflict or crash. Do not charge models or execute + external side effects in it. +- \`runtime.validateResult\` must be **synchronous, deterministic and pure**. + FlowKit cannot establish purity of arbitrary Host callbacks. +- Agent input contains only declared input keys plus a \`missing\` list; + absent keys are not synthesized as null. The Host still owns disclosure + rules for confidential state and instructions. +- \`complete()\` is **not a model-exposed tool**. M5 must enforce actual Host + provenance and identity outside the model-controlled process. +- Store data is written with \`node:sqlite\` in WAL mode and + \`synchronous=FULL\`, with every transition in one SQLite transaction. + Do not place this reference Store on an unreliable/shared filesystem. + Node 22's built-in SQLite is experimental; the pinned GitHub Actions + Ubuntu/Windows Node 22/24 matrix is mandatory evidence, not a generic + claim of compatibility for arbitrary host environments. +- External Agent calls are **not exactly-once**: the Host must provide + idempotent delivery/reconciliation if it has externally billed or side + effecting operations. FlowKit guarantees a unique durable pending identity, + not exactly-once provider execution. +- No npm publication, deployment, untrusted code sandbox, or background + service is part of this milestone. From bbd29e33cc33bb56f0e7dbe96430fd5dc2e0e992 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:00:28 +0900 Subject: [PATCH 18/24] docs(interactive): render SDK identifiers as Markdown code spans --- docs/interactive.md | 58 ++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/interactive.md b/docs/interactive.md index 1b553b5..b344340 100644 --- a/docs/interactive.md +++ b/docs/interactive.md @@ -1,19 +1,19 @@ # FlowKit Interactive — P0-A M4 Host Coordinator > **Internal/maintainer integration only.** The Standard root API remains exactly -> \`run / compile / compileSource / check / evalWhen / isControlSignal\`. -> Interactive APIs live under \`@devcodex-labs/flowkit/interactive\`. +> `run / compile / compileSource / check / evalWhen / isControlSignal`. +> Interactive APIs live under `@devcodex-labs/flowkit/interactive`. > This does **not** grant a model permission to act as a trusted Host. ## Supported features - **Implemented**: Condition, Terminal, Agent (no Agent verify/checkStep), immutable compiled JavaScript Program, original Standard analyzer, namespace-scoped SQLite WAL/FULL Checkpoint Store, phase-by-phase CAS, deterministic Agent preparation, persist-before-publish, receipt-accept-before-validate, local sync validation, retry exhaustion, crash resume, final State and error cause. -- **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic \`runAgent\` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions. -- The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct \`complete()\` authority. +- **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic `runAgent` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions. +- The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct `complete()` authority. ## Example: compile, start, resolve pending, resume -The source is a validated \`*.workflow.js\` or \`*.workflow.ts\` using +The source is a validated `*.workflow.js` or `*.workflow.ts` using FlowKit's existing DSL; it is **not imported or executed** while compiling. ~~~js @@ -81,49 +81,49 @@ try { ## Invariants and recovery -1. **Program is the routing authority.** The generated \`advance(view,event)\` - and its verified immutable \`programTable\` supply legal stage transitions. +1. **Program is the routing authority.** The generated `advance(view,event)` + and its verified immutable `programTable` supply legal stage transitions. The Coordinator does not reconstruct DSL routing or let model output choose - \`toBlockId\`. + `toBlockId`. 2. **A durable Pending precedes delivery.** The Host may dispatch only the - \`frame.pending\` returned from a successful \`waiting\` CAS. A restart with - that run ID returns the same \`requestId\` and persisted task. -3. **Receipt is stored before validation.** \`complete()\` atomically stores the - result/failure, its canonical digest, \`acceptedRevision\` and the \`validate\` + `frame.pending` returned from a successful `waiting` CAS. A restart with + that run ID returns the same `requestId` and persisted task. +3. **Receipt is stored before validation.** `complete()` atomically stores the + result/failure, its canonical digest, `acceptedRevision` and the `validate` Checkpoint. The local validator runs only afterward. If the process stops - between the two steps, \`resume()\` re-validates the **same** candidate, + between the two steps, `resume()` re-validates the **same** candidate, without publishing the previous request again. 4. **Receipt retransmission is idempotent.** The same canonical body with the - same \`requestId/nodeId/attempt\` returns the original - \`acceptedRevision\` even if several later revisions exist; different - content returns \`FK_I_RECEIPT_CONFLICT\`. + same `requestId/nodeId/attempt` returns the original + `acceptedRevision` even if several later revisions exist; different + content returns `FK_I_RECEIPT_CONFLICT`. 5. **Only a validated result may modify State.** Validation failure retries - with a new deterministic request ID up to \`retries+1\`; exhaustion becomes - \`errored(FK_R004)\`. A malformed pure validator becomes - \`errored(FK_I_VALIDATOR_CONTRACT)\`. No candidate is merged before a + with a new deterministic request ID up to `retries+1`; exhaustion becomes + `errored(FK_R004)`. A malformed pure validator becomes + `errored(FK_I_VALIDATOR_CONTRACT)`. No candidate is merged before a successful validation and commit-stage CAS. -6. **Identity is fixed.** The run pins \`namespace\`, Program artifact bytes, +6. **Identity is fixed.** The run pins `namespace`, Program artifact bytes, start intent, and policy fingerprint. Rebinding or changing policy requires a compatible, explicitly managed procedure; it is not inferred from source or a new Program file. -7. **Budget is explicit.** A finite \`maxStagesPerCall\` (1–128, default 128) - produces a \`paused(stage-budget)\` checkpoint, which \`resume()\` can - advance. It is never a fake \`waiting\` frame. +7. **Budget is explicit.** A finite `maxStagesPerCall` (1–128, default 128) + produces a `paused(stage-budget)` checkpoint, which `resume()` can + advance. It is never a fake `waiting` frame. ## Security / operational boundary -- Treat \`runtime.skillResolver\` as **trusted read-only code**: it may be +- Treat `runtime.skillResolver` as **trusted read-only code**: it may be invoked again after a conflict or crash. Do not charge models or execute external side effects in it. -- \`runtime.validateResult\` must be **synchronous, deterministic and pure**. +- `runtime.validateResult` must be **synchronous, deterministic and pure**. FlowKit cannot establish purity of arbitrary Host callbacks. -- Agent input contains only declared input keys plus a \`missing\` list; +- Agent input contains only declared input keys plus a `missing` list; absent keys are not synthesized as null. The Host still owns disclosure rules for confidential state and instructions. -- \`complete()\` is **not a model-exposed tool**. M5 must enforce actual Host +- `complete()` is **not a model-exposed tool**. M5 must enforce actual Host provenance and identity outside the model-controlled process. -- Store data is written with \`node:sqlite\` in WAL mode and - \`synchronous=FULL\`, with every transition in one SQLite transaction. +- Store data is written with `node:sqlite` in WAL mode and + `synchronous=FULL`, with every transition in one SQLite transaction. Do not place this reference Store on an unreliable/shared filesystem. Node 22's built-in SQLite is experimental; the pinned GitHub Actions Ubuntu/Windows Node 22/24 matrix is mandatory evidence, not a generic From 815573e533c5409d85e6dab39a16aae79224dad1 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:01:14 +0900 Subject: [PATCH 19/24] test(interactive): cover SIGKILL before/after enter, prepare and publish CAS --- tests/interactive/crash.test.mjs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs index fdc8981..9314f36 100644 --- a/tests/interactive/crash.test.mjs +++ b/tests/interactive/crash.test.mjs @@ -86,14 +86,15 @@ async function spawnAndKill({file,compiled,mode,phase,side,runId,receipt}) { }); } -test('M4 real process SIGKILL before/after Agent preparation and durable publish resumes once',async t=>{ - for(const side of ['before','after']){ - const {file,compiled,db,api}=await fixture(t); - const runId='start-'+side; - await spawnAndKill({file,compiled,mode:'start',phase:'awaiting',side,runId}); - const checkpoint=db.load(runId); - assert.ok(checkpoint); - assert.ok(['dispatch-ready','awaiting'].includes(checkpoint.phase)); +test('M4 real process SIGKILL before/after Agent enter, prepare and durable publish resumes once',async t=>{ + for(const phase of ['prepare','dispatch-ready','awaiting']){ + for(const side of ['before','after']){ + const {file,compiled,db,api}=await fixture(t); + const runId='start-'+phase+'-'+side; + await spawnAndKill({file,compiled,mode:'start',phase,side,runId}); + const checkpoint=db.load(runId); + assert.ok(checkpoint); + assert.ok(['enter','prepare','dispatch-ready','awaiting'].includes(checkpoint.phase)); const waiting=await api.resume({runId}); assert.equal(waiting.status,'waiting'); assert.equal(waiting.pending.attempt,1); @@ -110,6 +111,7 @@ test('M4 real process SIGKILL before/after Agent preparation and durable publish assert.equal(db.load(runId).receipts.length,1); assert.deepEqual(db.listJournal(runId).map(x=>x.revision), Array.from({length:done.revision+1},(_,i)=>i)); + } } }); From 8259a673a8a4781335c51453413150648c04ca27 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:01:48 +0900 Subject: [PATCH 20/24] test(interactive): confirm durable receipt Ack after successful CAS response loss --- tests/interactive/coordinator.test.mjs | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index 4503c23..6ae6ff4 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -370,3 +370,34 @@ test('M4 a sparse validator reasons array fails closed without committing invali assert.equal(result.terminal.causeCode,'FK_I_VALIDATOR_CONTRACT'); assert.deepEqual(result.state,state); }); + + +test('M4 a successful receipt CAS followed by a lost response is recovered by durable duplicate Ack',async t=>{ + const {store,source,state}=await runAgentFixture(t); + let loseOnce=true; + const wrapped={ + ...store, + compareAndSwap(args){ + const response=store.compareAndSwap(args); + if(loseOnce && response.committed && args.nextCheckpoint.phase==='validate'){ + loseOnce=false; + throw new Error('simulated lost CAS response after commit'); + } + return response; + }, + }; + const original=coordinator(wrapped); + const waiting=await original.start({...source,startKey:'lost-response',state}); + const submitted=receipt(waiting,{answer:'once'}); + await assert.rejects(original.complete(submitted),/lost CAS response/); + const accepted=store.load(waiting.runId); + assert.equal(accepted.phase,'validate'); + assert.equal(accepted.receipts.length,1); + assert.equal(accepted.receipts[0].requestId,waiting.pending.requestId); + const recovered=await coordinator(store).complete(submitted); + check(recovered,'succeeded'); + assert.equal(recovered.duplicate,true); + assert.equal(recovered.acceptedRevision,accepted.revision); + assert.equal(recovered.state.answer,'once'); + assert.equal(store.load(waiting.runId).receipts.length,1); +}); From 4aed90b12d4471922ca4471d4e3997ecda3d3211 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:02:21 +0900 Subject: [PATCH 21/24] test(interactive): race two real Node Host processes on one Agent receipt --- tests/interactive/crash.test.mjs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs index 9314f36..ba3d977 100644 --- a/tests/interactive/crash.test.mjs +++ b/tests/interactive/crash.test.mjs @@ -147,3 +147,51 @@ test('M4 real process SIGKILL before/after receipt acceptance, validation and st } } }); + + +test('M4 two independent Node processes racing the same complete accept one receipt, not two',async t=>{ + const {file,compiled,db,api}=await fixture(t); + const waiting=await api.start({ + runId:'real-process-race',programBytes:compiled.bytes, + programIdentity:compiled.identity,startKey:'race', + state:{preexisting:'constant'}, + }); + assert.equal(waiting.status,'waiting'); + const receipt={ + runId:waiting.runId,requestId:waiting.pending.requestId, + nodeId:'worker',attempt:1,result:{answer:'once'}, + }; + const raceScript=[ + 'import {createSqliteCheckpointStore} from '+JSON.stringify(moduleStore)+';', + 'import {createInteractiveCoordinator} from '+JSON.stringify(moduleCoordinator)+';', + 'const [file,body]=process.argv.slice(1);', + 'const db=createSqliteCheckpointStore(file,{namespace:"test-host"});', + 'try{', + 'const api=createInteractiveCoordinator({store:db,runtime:{', + ' skillResolver:async()=>"Agent instruction",', + ' validateResult:(_s,r)=>typeof r?.answer==="string"?[]:["invalid answer"],', + '}});', + 'const result=await api.complete(JSON.parse(body));', + 'console.log(JSON.stringify({acceptedRevision:result.acceptedRevision,', + ' duplicate:result.duplicate,status:result.status}));', + '}finally{db.close()};', + ].join('\n'); + const run=()=>new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,['--input-type=module','-e',raceScript, + file,JSON.stringify(receipt)],{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr=''; + child.stdout.on('data',b=>{stdout+=b}); + child.stderr.on('data',b=>{stderr+=b}); + child.on('error',reject); + child.on('close',code=>code===0?resolve(JSON.parse(stdout.trim())) + :reject(new Error(stderr))); + }); + const [first,second]=await Promise.all([run(),run()]); + assert.equal(first.acceptedRevision,second.acceptedRevision); + assert.deepEqual([first.duplicate,second.duplicate].sort(),[false,true]); + assert.equal(first.status,'succeeded'); + assert.equal(second.status,'succeeded'); + assert.equal(db.load(waiting.runId).receipts.length,1); + assert.deepEqual(db.load(waiting.runId).state, + {preexisting:'constant',answer:'once'}); +}); From 23026761db868feff2de21bc09cd7490de52711f Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:05:28 +0900 Subject: [PATCH 22/24] fix(interactive): align Agent Pending id with Standard runAgent fields --- src/interactive/coordinator.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 4a23b30..fc93bae 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -336,7 +336,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt cp.namespace,cp.runId,cp.artifactHash,frame.nodeId,frame.activation,frame.attempt, ]).slice(7); return copy({ - kind:'agent',runId:cp.runId,nodeId:block.nodeId, + kind:'agent',runId:cp.runId,nodeId:block.nodeId,id:block.nodeId, name:node.metadata.name,requestId,attempt:frame.attempt, instruction,input,missing,resultSchema:node.metadata.output, reasons:frame.reasons, From 71981c9381b2ee21f4080e1fe6c8b591bc4c337f Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:05:31 +0900 Subject: [PATCH 23/24] fix(interactive): align Agent Pending id with Standard runAgent fields --- types/interactive.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/interactive.d.ts b/types/interactive.d.ts index 1a68e94..bbdf869 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -59,6 +59,7 @@ export interface InteractiveAgentPending { kind: 'agent'; runId: string; nodeId: string; + id: string; name: string; requestId: string; attempt: number; From 90dbd5534f3d05b999c2c50b15e90b4312610856 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 23:05:33 +0900 Subject: [PATCH 24/24] fix(interactive): align Agent Pending id with Standard runAgent fields --- tests/interactive/coordinator.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index 6ae6ff4..c02a291 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -94,6 +94,7 @@ test('M4 generated Program routes Condition before Agent, publishes immutable Pe assert.ok(started.revision>=5); assert.equal(started.pending.kind,'agent'); assert.equal(started.pending.name,'Worker'); + assert.equal(started.pending.id,'worker'); assert.equal(started.pending.attempt,1); assert.equal(started.pending.instruction,'Write valid JSON for worker'); assert.deepEqual(started.pending.input,{existing:'hello'});