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.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..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; @@ -1521,15 +1523,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,