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
5 changes: 4 additions & 1 deletion packages/core/src/goals/goal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/goals/goal-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string, number>();
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<string, number>();
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[] = [];
Expand Down
21 changes: 15 additions & 6 deletions packages/core/src/goals/goal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckpointAttempt | undefined> | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This change tightens the wind-down stamp rule — finishedWindDown = heldWindDown && delivered — but leaves three earlier comments stating the old "finished ⇒ stamps" rule: the in-memory field doc at goal-runtime.ts:300-304 ("only the turn that actually finishes stamps the record"), the budget-gate comment at goal-runtime.ts:608-610 ("The record marks the hand-off that finished" — its case list omits the new granted-finished-but-undelivered state), and the GoalTurnFinishedTransition.windDownTurnId contract at goal-reducer.ts:49 ("Set when the finishing turn was the spend window's wind-down hand-off"). goal-protocol.ts:175-183 already documents the delivered rule, so the module now contradicts itself. A maintainer extending the settle/release/restore paths — or adding a caller of reduceGoalTurnFinished — reads one of these comments first, concludes that finishing a wind-down turn alone stamps the record, and stamps an undelivered hand-off through a new path, silently reintroducing the #10150 defect: a Goal stopped at usage_limited with the record claiming a hand-off the user never received. Reword all three to the delivered-and-finished rule, for example:

// goal-runtime.ts (field doc)
 * In memory only: a wind-down the host dropped undelivered must be minted
 * 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.

// goal-runtime.ts (budget gate)
// A spent window buys one hand-off before it stops. The record marks
// the hand-off that was delivered and finished; until then -- never
// granted, dropped before the model saw it, or finished under another
// text -- grant it.

// goal-reducer.ts (transition contract)
/** Set when the finishing turn was the spend window's wind-down hand-off and was delivered to the model. */
中文说明

此变更收紧了收尾盖章规则——finishedWindDown = heldWindDown && delivered——但留下了三处仍在陈述旧规则(「结束即盖章」)的注释:goal-runtime.ts:300-304 的内存字段文档("only the turn that actually finishes stamps the record")、goal-runtime.ts:608-610 的预算闸门注释("The record marks the hand-off that finished"——其情形列表缺少新增的「已授予、已结束但未送达」状态)、以及 goal-reducer.ts:49GoalTurnFinishedTransition.windDownTurnId 契约("Set when the finishing turn was the spend window's wind-down hand-off")。goal-protocol.ts:175-183 已记载送达规则,模块内部因此自相矛盾。未来扩展 settle/release/restore 路径(或新增 reduceGoalTurnFinished 调用方)的维护者会先读到这些注释,误以为仅结束收尾轮即可盖章,从而在新路径上为未送达的交接盖章,悄悄重现 #10150 缺陷:Goal 停在 usage_limited,记录却声称用户已收到交接,而用户从未收到。建议将三处注释都改为「已送达且结束」规则,例如:

// goal-runtime.ts(字段文档)
 * In memory only: a wind-down the host dropped undelivered must be minted
 * 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.

// goal-runtime.ts(预算闸门)
// A spent window buys one hand-off before it stops. The record marks
// the hand-off that was delivered and finished; until then -- never
// granted, dropped before the model saw it, or finished under another
// text -- grant it.

// goal-reducer.ts(transition 契约)
/** Set when the finishing turn was the spend window's wind-down hand-off and was delivered to the model. */

— qwen3.8-max via Qwen Code /review (v0.22.2)

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,
Expand Down
Loading