From 452ac387b1f3da97d38bfb27b8582953ab1b818e Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 13:07:13 +0800 Subject: [PATCH] fix(interactive): snapshot start State and startIntent before first await start() copied caller-owned State and startIntent only after loadInteractiveProgram resolved. That await yields even when the loader is already settled, so a caller could change both objects before the Checkpoint and startIntentHash were created. Copy both with the existing bounded canonical snapshot first, and preserve the initial-State capacity error. Later Store writes use those copies. Includes the R04 regression tests from PR #17. --- src/interactive/coordinator.mjs | 17 +++++++++++--- tests/interactive/coordinator.test.mjs | 31 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 1ce6460..6031f40 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -539,16 +539,27 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt const byteSnapshot=Buffer.from(programBytes); const declared=copy(programIdentity); assertSHA256(declared.artifactHash); + // The first await yields even when the loader is already settled. Copy + // caller-owned State and startIntent before that yield. Checkpoint + // creation, the start intent hash, and Store writes all use these copies. + let stateSnapshot; + try { stateSnapshot=copy(state); } + catch(error) { + if (error instanceof RangeError && /Canonical JSON exceeds maximum bytes/.test(error.message)) { + throw failure('FK_I_CHECKPOINT_CAPACITY', 'initial State exceeds Checkpoint hard limit'); + } + throw error; + } + const intentSnapshot=copy(startIntent); 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); + const initial=initialCheckpoint(store.namespace,runId,program,policyHash,stateSnapshot); store.putArtifact({programIdentity:declared,bytes:byteSnapshot}); - const opened=store.createRun({startKey,startIntent:intent,checkpoint:initial}); + const opened=store.createRun({startKey,startIntent:intentSnapshot,checkpoint:initial}); return drive(opened.checkpoint.runId); } diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index c02a291..86a1292 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -402,3 +402,34 @@ test('M4 a successful receipt CAS followed by a lost response is recovered by du assert.equal(recovered.state.answer,'once'); assert.equal(store.load(waiting.runId).receipts.length,1); }); + + +test('R04 regression: start snapshots caller-owned State before first await',async t=>{ + const {api,source,state,store}=await runAgentFixture(t); + // Exercise both an existing top-level key and a nested caller-owned reference. + state.context={request:'before-await'}; + const originalState=structuredClone(state); + const starting=api.start({...source,startKey:'r04-state-snapshot',state}); + // These writes occur synchronously after invoking start(), before its await resolves. + state.existing='modified-after-start'; + state.context.request='modified-after-start'; + const frame=await starting; + assert.deepEqual(store.load(frame.runId).state,originalState, + 'persisted State must be a deep entry-time snapshot'); +}); + +test('R04 regression: start snapshots caller-owned startIntent before first await',async t=>{ + const {api,source,state}=await runAgentFixture(t); + const intent={request:'before-await'}; + const starting=api.start({ + ...source,startKey:'r04-intent-snapshot',state,startIntent:intent, + }); + // The idempotency identity must not depend on post-call mutation. + intent.request='modified-after-start'; + const first=await starting; + const originalReplay=await api.start({ + ...source,startKey:'r04-intent-snapshot',state, + startIntent:{request:'before-await'}, + }); + assert.equal(originalReplay.runId,first.runId); +});