Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/interactive/coordinator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
31 changes: 31 additions & 0 deletions tests/interactive/coordinator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading