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
7 changes: 4 additions & 3 deletions packages/core/src/core/client-goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/goals/goal-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
reduceGoalTurnFinished,
} from './goal-reducer.js';

const FORMER_GOAL_CONTINUATION_LIMIT = 50;

const goalRecord = (overrides: Partial<GoalRecord> = {}): GoalRecord => ({
goalId: 'g-1',
revision: 1,
Expand Down Expand Up @@ -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',
Expand All @@ -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' },
});
});
Expand Down
4 changes: 0 additions & 4 deletions packages/core/src/goals/goal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/goals/goal-runtime.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }) => {
Expand Down
136 changes: 50 additions & 86 deletions packages/core/src/goals/goal-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import {
import {
createGoalRuntime,
GoalPersistenceUnavailableError,
MAX_GOAL_CONTINUATION_TURNS,
type GoalEvidenceSource,
type GoalJournal,
type GoalTurnHost,
} from './goal-runtime.js';
import { GoalConflictError } from './goal-reducer.js';
import type { GoalVerifier } from './goal-verifier.js';

const FORMER_GOAL_CONTINUATION_LIMIT = 50;

function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
Expand Down Expand Up @@ -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,
Expand All @@ -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 },
};
}

Expand Down Expand Up @@ -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<void>();
const appendGate = deferred<void>();
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 () => {
Expand Down
34 changes: 0 additions & 34 deletions packages/core/src/goals/goal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
};
Expand Down
Loading