From eb5a1202f7779dd33ea1116bd09659e9d3736e25 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 15:27:04 +0800 Subject: [PATCH 1/2] fix(goal): stamp the wind-down hand-off only when its turn was delivered #10132 marked the record's `windDownTurnId` whenever the turn holding the wind-down permit finished -- reading "the permit was used" as "the user got the hand-off". #10013 established why that inference is wrong for the objective-updated notice: a system message or a direct user query can claim a queued continuation's permit and send its own text under it, so the turn finishes with the prompt never reaching the model. Hosts therefore mark delivery at the real send site, and only a delivered turn commits what it carried. The hand-off now follows the same rule. `finishTurn` stamps the marker only when the wind-down turn was marked delivered; an undelivered one leaves the record clean, so the next `queueContinuation` grants the hand-off again instead of settling `usage_limited` on a hand-off the user never received (which a resume would not have repaired either, since the marker is cleared only by a re-arm). The in-memory permit marker is released either way; it belongs to the permit, not the outcome. The wind-down tests that finish the hand-off turn now mark it delivered first, so they keep meaning "the model saw the hand-off". Two new cases pin the split: finished-but-undelivered leaves no marker and re-mints the hand-off; finished-and-delivered stamps it and stops. Mutation probe: making the stamp unconditional again fails exactly the undelivered case (145 others green). --- packages/core/src/goals/goal-runtime.test.ts | 74 ++++++++++++++++++++ packages/core/src/goals/goal-runtime.ts | 13 +++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index 7f86821093b..484065257a2 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -373,6 +373,9 @@ describe('goal runtime', () => { expect(host.inputs[0]).not.toHaveProperty('windDown'); expect(runtime.getSnapshot().goal?.status).toBe('active'); + // The hand-off reached the model: only a delivered wind-down turn + // stamps the record. + runtime.markTurnDelivered(`goal-runtime:${host.started[1]!.turnId}`); await runtime.finishTurn(host.started[1]!); // The hand-off turn stamps the record, and the stop settles on the @@ -446,6 +449,9 @@ describe('goal runtime', () => { await runtime.finishTurn(host.started[0]!); expect(host.started).toHaveLength(2); expect(host.inputs[1]).toMatchObject({ windDown: true }); + // The hand-off reached the model: only a delivered wind-down turn + // stamps the record. + runtime.markTurnDelivered(`goal-runtime:${host.started[1]!.turnId}`); await runtime.finishTurn(host.started[1]!); await vi.waitFor(() => { @@ -488,6 +494,9 @@ describe('goal runtime', () => { spend.set(host.started[0]!.turnId, 1_500); await runtime.finishTurn(host.started[0]!); + // The hand-off reached the model: only a delivered wind-down turn + // stamps the record. + runtime.markTurnDelivered(`goal-runtime:${host.started[1]!.turnId}`); await runtime.finishTurn(host.started[1]!); await vi.waitFor(() => { @@ -550,6 +559,71 @@ describe('goal runtime', () => { expect(started).toHaveLength(2); }); + it('grants the hand-off again when its turn finished without being delivered', async () => { + // A system message or a direct user query can claim the wind-down + // continuation's permit and send its own text under it. The turn then + // finishes, but the user never got the hand-off -- so the record must + // not say they did, and the next continuation owes it again. + const journal = fakeGoalJournal(); + const host = fakeGoalTurnHost(); + const spend = new Map(); + const runtime = createGoalRuntime({ + journal, + tokenLedger: { + takeGoalTurnTokens: (turnId: string) => spend.get(turnId) ?? 0, + }, + tokenBudgetGrant: 1_000, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + spend.set(host.started[0]!.turnId, 1_500); + await runtime.finishTurn(host.started[0]!); + expect(host.inputs[1]).toMatchObject({ windDown: true }); + + // Finished under the wind-down permit, never marked delivered. + await runtime.finishTurn(host.started[1]!); + await new Promise((resolve) => setImmediate(resolve)); + + expect(runtime.getSnapshot().goal?.status).toBe('active'); + expect(runtime.getSnapshot().goal).not.toHaveProperty('windDownTurnId'); + expect(journal.appended.at(-1)!.snapshot.goal).not.toHaveProperty( + 'windDownTurnId', + ); + expect(host.started).toHaveLength(3); + expect(host.inputs[2]).toMatchObject({ windDown: true }); + }); + + it('stops after the hand-off once a delivered wind-down turn finishes', async () => { + const journal = fakeGoalJournal(); + const host = fakeGoalTurnHost(); + const spend = new Map(); + const runtime = createGoalRuntime({ + journal, + tokenLedger: { + takeGoalTurnTokens: (turnId: string) => spend.get(turnId) ?? 0, + }, + tokenBudgetGrant: 1_000, + }); + runtime.bindHost(host); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + spend.set(host.started[0]!.turnId, 1_500); + await runtime.finishTurn(host.started[0]!); + const windDown = host.started[1]!; + expect(host.inputs[1]).toMatchObject({ windDown: true }); + + runtime.markTurnDelivered(`goal-runtime:${windDown.turnId}`); + await runtime.finishTurn(windDown); + + await vi.waitFor(() => { + expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'); + }); + expect(runtime.getSnapshot().goal).toMatchObject({ + limitKind: 'token_budget', + windDownTurnId: windDown.turnId, + }); + expect(host.started).toHaveLength(2); + }); + it('completes a Goal whose hand-off turn proves the objective done', async () => { const journal = fakeGoalJournal(); let records: readonly RuntimeRecord[] = []; diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index 93e1a7df4ec..737c1927ad1 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -1521,15 +1521,22 @@ export function createGoalRuntime( // query can claim a queued continuation's permit and send its own // text instead. Only the host's delivery mark says the model saw // the objective; without it the notice stays owed. - settleCurrentTurnAnnouncement(currentTurnDelivered); + const delivered = currentTurnDelivered; + settleCurrentTurnAnnouncement(delivered); const recordUuid = randomUUID(); - const finishedWindDown = windDownTurnId === permit.turnId; + // The same rule decides the hand-off. The record's marker means + // "the user got the hand-off", and the budget gate stops the Goal + // on it -- so a wind-down permit that finished under someone + // else's text leaves no marker, and the next continuation grants + // the hand-off again instead of stopping cold. + const heldWindDown = windDownTurnId === permit.turnId; + const finishedWindDown = heldWindDown && delivered; const nextGoal = reduceGoalTurnFinished(snapshot.goal, { now: Date.now(), tokensUsed: takeTurnTokens(permit.turnId), ...(finishedWindDown ? { windDownTurnId: permit.turnId } : {}), }); - if (finishedWindDown) windDownTurnId = undefined; + if (heldWindDown) windDownTurnId = undefined; const persistedSnapshot: GoalSnapshotV2 = { v: GOAL_STATE_VERSION, goal: nextGoal, From ba0018c9deb7dc38c6a1ed4bc42f50264e268679 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 10:07:16 +0000 Subject: [PATCH 2/2] docs(goal): align wind-down comments with the delivered-stamp rule (#10260) Co-authored-by: Qwen-Coder --- packages/core/src/goals/goal-reducer.ts | 5 ++++- packages/core/src/goals/goal-runtime.ts | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index f38e5947fe4..bfde01a84ce 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -46,7 +46,10 @@ export interface GoalTurnFinishedTransition { lastReason?: string; /** Tokens billed to the turn that just finished. */ tokensUsed?: number; - /** Set when the finishing turn was the spend window's wind-down hand-off. */ + /** + * Set when the finishing turn was the spend window's wind-down hand-off + * and was delivered to the model. + */ windDownTurnId?: string; } diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index 737c1927ad1..f253944b064 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -300,7 +300,8 @@ export function createGoalRuntime( /** * The permit turn of the wind-down continuation now in flight, if any. * In memory only: a wind-down the host dropped undelivered must be minted - * again, and only the turn that actually finishes stamps the record. + * again, and only a wind-down turn that finishes delivered stamps the + * record; one finished under someone else's text leaves the hand-off owed. */ let windDownTurnId: string | undefined; let restorePreparation: Promise | undefined; @@ -606,8 +607,9 @@ export function createGoalRuntime( } if (isGoalTokenBudgetSpent(snapshot.goal)) { // A spent window buys one hand-off before it stops. The record marks - // the hand-off that finished; until then -- never granted, or granted - // and dropped by the host before the model saw it -- grant it. + // the hand-off that was delivered and finished; until then -- never + // granted, dropped before the model saw it, or finished under someone + // else's text -- grant it. if (snapshot.goal.windDownTurnId !== undefined) { stopForSpentBudget(); return;