diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index de00ff53406..296130219d1 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -9,7 +9,6 @@ import type { Config } from '../config/config.js'; import type { GeminiChat } from './geminiChat.js'; import { createGoalRuntime, - MAX_GOAL_CONTINUATION_TURNS, GoalPersistenceUnavailableError, type GoalJournal, type GoalRuntime, @@ -53,6 +52,8 @@ vi.mock('../utils/nextSpeakerChecker.js', () => ({ import { GeminiClient, SendMessageType } from './client.js'; import { GeminiEventType, type ServerGeminiStreamEvent } from './turn.js'; +const FORMER_GOAL_CONTINUATION_LIMIT = 50; + const permit: GoalTurnPermit = { goalId: 'goal-1', revision: 1, @@ -983,7 +984,7 @@ describe('GeminiClient Goal admission', () => { expect(runtime.finishTurn).not.toHaveBeenCalled(); }); - it('runs runtime-scheduled Goal turns within the continuation budget without session budgets', async () => { + it('runs runtime-scheduled Goal turns beyond the former fixed limit without session budgets', async () => { const { client, config } = setupGoalClient(); const goalJournal: GoalJournal = { getTranscriptCursor: () => ({ recordId: null }), @@ -1017,7 +1018,7 @@ describe('GeminiClient Goal admission', () => { vi.mocked(config.getGoalRuntime).mockReturnValue(runtime); await runtime.dispatch({ action: 'create', objective: 'ship' }); - const turns = MAX_GOAL_CONTINUATION_TURNS - 1; + const turns = FORMER_GOAL_CONTINUATION_LIMIT + 25; for (let turn = 0; turn < turns; turn += 1) { const current = started[turn]!; await drain( diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index 87bf8ab22e4..7b9d7b1565c 100644 --- a/packages/core/src/goals/goal-reducer.test.ts +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -22,6 +22,8 @@ import { reduceGoalTurnFinished, } from './goal-reducer.js'; +const FORMER_GOAL_CONTINUATION_LIMIT = 50; + const goalRecord = (overrides: Partial = {}): GoalRecord => ({ goalId: 'g-1', revision: 1, @@ -243,9 +245,13 @@ describe('goal reducer', () => { }, ); - it('resets the continuation turn budget when resuming an exhausted goal', () => { + it('preserves the cumulative turn count when resuming a limited goal', () => { const resumed = reduceGoalControl( - goalRecord({ status: 'usage_limited', revision: 4, turnCount: 50 }), + goalRecord({ + status: 'usage_limited', + revision: 4, + turnCount: FORMER_GOAL_CONTINUATION_LIMIT, + }), { request: { action: 'resume', @@ -261,7 +267,7 @@ describe('goal reducer', () => { expect(resumed).toMatchObject({ status: 'active', revision: 4, - turnCount: 0, + turnCount: FORMER_GOAL_CONTINUATION_LIMIT, evidenceCursor: { recordId: 'r-100' }, }); }); diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index 33423e7e44e..0cad14e68f8 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -135,12 +135,8 @@ export function reduceGoalControl( if (request.action !== 'resume') { return assertNever(request, snapshotOf(current)); } - // An explicit resume re-authorizes autonomous continuation, so it grants a - // fresh turn budget; keeping the exhausted count would report `active` and - // immediately re-transition to `usage_limited` without running a turn. return transitionGoal(current, transition.now, { status: 'active', - turnCount: 0, }); } diff --git a/packages/core/src/goals/goal-runtime.integration.test.ts b/packages/core/src/goals/goal-runtime.integration.test.ts index 421537b4630..0a822363a7b 100644 --- a/packages/core/src/goals/goal-runtime.integration.test.ts +++ b/packages/core/src/goals/goal-runtime.integration.test.ts @@ -12,11 +12,12 @@ import type { } from './goal-protocol.js'; import { createGoalRuntime, - MAX_GOAL_CONTINUATION_TURNS, type GoalJournal, type GoalTurnHost, } from './goal-runtime.js'; +const FORMER_GOAL_CONTINUATION_LIMIT = 50; + function journal(): GoalJournal { let cursor: TranscriptCursor = { recordId: null }; return { @@ -31,8 +32,8 @@ function journal(): GoalJournal { } describe('Goal runtime host integration', () => { - it('keeps sequential automatic admissions independent within the turn budget', async () => { - const turns = MAX_GOAL_CONTINUATION_TURNS - 1; + it('keeps sequential automatic admissions independent beyond the former fixed limit', async () => { + const turns = FORMER_GOAL_CONTINUATION_LIMIT + 25; const started: GoalTurnPermit[] = []; const host: GoalTurnHost = { startGoalTurn: vi.fn(async ({ permit }) => { diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index c3f31f67f28..862c0773fa0 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -18,7 +18,6 @@ import { import { createGoalRuntime, GoalPersistenceUnavailableError, - MAX_GOAL_CONTINUATION_TURNS, type GoalEvidenceSource, type GoalJournal, type GoalTurnHost, @@ -26,6 +25,8 @@ import { import { GoalConflictError } from './goal-reducer.js'; import type { GoalVerifier } from './goal-verifier.js'; +const FORMER_GOAL_CONTINUATION_LIMIT = 50; + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -74,7 +75,10 @@ function fakeGoalJournal( }; } -function goalStateRecord(snapshot: GoalSnapshotV2): RuntimeRecord { +function goalStateRecord( + snapshot: GoalSnapshotV2, + cause: GoalStateCause = 'pause', +): RuntimeRecord { return { uuid: 'restore-record', parentUuid: null, @@ -85,7 +89,7 @@ function goalStateRecord(snapshot: GoalSnapshotV2): RuntimeRecord { provenance: 'goal_control', cwd: '/tmp', version: 'test', - systemPayload: { v: 2, cause: 'pause', snapshot }, + systemPayload: { v: 2, cause, snapshot }, }; } @@ -1005,112 +1009,72 @@ describe('goal runtime', () => { expect(observed[0]?.activity).toBe('running'); }); - it('transitions to usage_limited after exceeding the continuation turn budget', async () => { + it('continues beyond the former fixed continuation limit', async () => { const journal = fakeGoalJournal(); const host = fakeGoalTurnHost(); const runtime = createGoalRuntime({ journal }); runtime.bindHost(host); await runtime.dispatch({ action: 'create', objective: 'loop forever' }); - // Drive turns up to the budget cap. - for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS; i++) { + const turns = FORMER_GOAL_CONTINUATION_LIMIT + 25; + for (let i = 0; i < turns; i++) { const permit = host.started[host.started.length - 1]; expect(permit).toBeDefined(); await runtime.finishTurn(permit); } - // Allow the async usage_limited transition to settle. - await vi.waitFor(() => - expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'), - ); - expect(runtime.getSnapshot().goal?.lastReason).toContain( - String(MAX_GOAL_CONTINUATION_TURNS), - ); - expect(journal.appended.at(-1)?.cause).toBe('usage_limited'); + expect(host.started).toHaveLength(turns + 1); + expect(runtime.getSnapshot()).toMatchObject({ + activity: 'running', + goal: { status: 'active', turnCount: turns }, + }); + expect( + journal.appended.map((p) => p.cause).filter((c) => c === 'usage_limited'), + ).toHaveLength(0); }); - it('resumes a budget-exhausted goal into a fresh turn instead of re-limiting', async () => { + it('resumes persisted state at the former limit without resetting its turn count', async () => { const journal = fakeGoalJournal(); const host = fakeGoalTurnHost(); const runtime = createGoalRuntime({ journal }); runtime.bindHost(host); - await runtime.dispatch({ action: 'create', objective: 'loop forever' }); - - for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS; i++) { - const permit = host.started[host.started.length - 1]; - expect(permit).toBeDefined(); - await runtime.finishTurn(permit); - } - - await vi.waitFor(() => - expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'), - ); - const goal = runtime.getSnapshot().goal!; - const startedBeforeResume = host.started.length; + await runtime.restore([ + goalStateRecord( + { + v: 2, + activity: 'idle', + goal: { + goalId: 'g-1', + revision: 1, + objective: 'keep going', + status: 'usage_limited', + evidenceCursor: { recordId: 'limit-record' }, + turnCount: FORMER_GOAL_CONTINUATION_LIMIT, + activeTimeMs: 1_000, + createdAt: 1, + updatedAt: 2, + }, + }, + 'usage_limited', + ), + ]); - const response = await runtime.dispatch({ + const resumed = await runtime.dispatch({ action: 'resume', - expectedGoalId: goal.goalId, - expectedRevision: goal.revision, + expectedGoalId: 'g-1', + expectedRevision: 1, }); - // The reported outcome must match the settled outcome: resume grants a - // fresh budget and starts a continuation turn rather than reporting - // `active` and immediately re-transitioning to `usage_limited`. - expect(response.snapshot.goal?.status).toBe('active'); - expect(response.snapshot.goal?.turnCount).toBe(0); - await vi.waitFor(() => - expect(host.started.length).toBe(startedBeforeResume + 1), - ); - expect(runtime.getSnapshot().goal?.status).toBe('active'); - }); - - it('does not usage-limit a replacement goal created during budget-exhaustion persistence', async () => { - const appendReached = deferred(); - const appendGate = deferred(); - let blockNext = false; - const journal = fakeGoalJournal({ - beforeAppend: async () => { - if (!blockNext) return; - blockNext = false; - appendReached.resolve(); - await appendGate.promise; - }, + expect(resumed.snapshot).toMatchObject({ + activity: 'running', + goal: { status: 'active', turnCount: FORMER_GOAL_CONTINUATION_LIMIT }, }); - const host = fakeGoalTurnHost(); - const runtime = createGoalRuntime({ journal }); - runtime.bindHost(host); - await runtime.dispatch({ action: 'create', objective: 'loop forever' }); - - for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS - 1; i++) { - const permit = host.started[host.started.length - 1]; - expect(permit).toBeDefined(); - await runtime.finishTurn(permit); - } - - const goalId = runtime.getSnapshot().goal!.goalId; - const revision = runtime.getSnapshot().goal!.revision; - blockNext = true; - const lastPermit = host.started[host.started.length - 1]; - const finishing = runtime.finishTurn(lastPermit); - await appendReached.promise; - - const replacing = runtime.dispatch({ - action: 'replace', - objective: 'fresh start', - expectedGoalId: goalId, - expectedRevision: revision, + expect(host.started).toHaveLength(1); + await runtime.finishTurn(host.started[0]); + expect(runtime.getSnapshot()).toMatchObject({ + activity: 'running', + goal: { status: 'active', turnCount: FORMER_GOAL_CONTINUATION_LIMIT + 1 }, }); - appendGate.resolve(); - await Promise.all([finishing, replacing]); - - await new Promise((resolve) => setImmediate(resolve)); - - expect(runtime.getSnapshot().goal?.status).toBe('active'); - expect(runtime.getSnapshot().goal?.objective).toBe('fresh start'); - expect( - journal.appended.map((p) => p.cause).filter((c) => c === 'usage_limited'), - ).toHaveLength(0); }); it('returns a bounded catalog without exposing full evidence content', async () => { diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index fbdf9d81cff..c4f7f89c5eb 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -43,7 +43,6 @@ import { export const GOAL_RUNTIME_DISPOSED_MESSAGE = 'Goal runtime has been disposed'; export const STALE_GOAL_TURN_MESSAGE = 'Goal turn permit is no longer valid'; -export const MAX_GOAL_CONTINUATION_TURNS = 50; export interface GoalJournal { getTranscriptCursor(): TranscriptCursor; @@ -304,39 +303,6 @@ export function createGoalRuntime( ) { return; } - if (snapshot.goal.turnCount >= MAX_GOAL_CONTINUATION_TURNS) { - const budgetGoalId = snapshot.goal.goalId; - const budgetRevision = snapshot.goal.revision; - void enqueue(async () => { - if ( - snapshot.goal?.status !== 'active' || - snapshot.goal.goalId !== budgetGoalId || - snapshot.goal.revision !== budgetRevision - ) - return; - const now = Date.now(); - const reason = `Goal exceeded the ${MAX_GOAL_CONTINUATION_TURNS}-turn continuation budget`; - const limitedSnapshot: GoalSnapshotV2 = { - v: GOAL_STATE_VERSION, - goal: { - ...snapshot.goal, - status: 'usage_limited', - activeTimeMs: elapsedActiveTime(snapshot.goal, now), - updatedAt: now, - lastReason: reason, - }, - activity: 'idle', - }; - await options.journal.recordGoalState(randomUUID(), { - v: GOAL_STATE_VERSION, - cause: 'usage_limited', - snapshot: limitedSnapshot, - }); - snapshot = structuredClone(limitedSnapshot); - broadcast('usage_limited'); - }); - return; - } continuationQueued = true; flushContinuation(cause); };