mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-15 11:45:01 +00:00
feat(core): add Goal v3 state protocol (#7517)
* feat(core): add Goal v3 state protocol * fix(core): address Goal protocol review feedback * fix(core): recover paused legacy Goals * fix(core): reject unsupported Goal controls * fix(core): cover recoverable Goal states * fix(core): recover older valid Goal state Continue past malformed lifecycle records so a newer corrupt write cannot hide the latest valid Goal snapshot. --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
8bc1cf0a5e
commit
e79b538461
10 changed files with 1834 additions and 0 deletions
|
|
@ -17,6 +17,10 @@
|
|||
"types": "./dist/src/utils/transcript-records.d.ts",
|
||||
"import": "./dist/src/utils/transcript-records.js"
|
||||
},
|
||||
"./goalWire": {
|
||||
"types": "./dist/src/goals/goal-wire.d.ts",
|
||||
"import": "./dist/src/goals/goal-wire.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*"
|
||||
|
|
|
|||
125
packages/core/src/goals/goal-legacy-projection.test.ts
Normal file
125
packages/core/src/goals/goal-legacy-projection.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
GoalRecord,
|
||||
GoalStateCause,
|
||||
GoalStateRecordPayloadV2,
|
||||
} from './goal-protocol.js';
|
||||
import { projectGoalStateToLegacy } from './goal-legacy-projection.js';
|
||||
|
||||
const GOAL: GoalRecord = {
|
||||
goalId: 'goal-1',
|
||||
revision: 2,
|
||||
objective: 'ship it',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'state-1' },
|
||||
turnCount: 4,
|
||||
activeTimeMs: 2000,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
lastReason: 'continuing',
|
||||
};
|
||||
|
||||
function payload(
|
||||
cause: GoalStateCause,
|
||||
status: GoalRecord['status'] = 'active',
|
||||
goal: GoalRecord | null = { ...GOAL, status },
|
||||
): GoalStateRecordPayloadV2 {
|
||||
return {
|
||||
v: 2,
|
||||
cause,
|
||||
snapshot: { v: 2, activity: 'idle', goal },
|
||||
};
|
||||
}
|
||||
|
||||
describe('projectGoalStateToLegacy', () => {
|
||||
it.each(['create', 'replace', 'edit', 'resume', 'migrated'] as const)(
|
||||
'projects %s as legacy set with an active projection',
|
||||
(cause) => {
|
||||
const projected = projectGoalStateToLegacy(payload(cause));
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('set');
|
||||
expect(projected.activeGoal).toMatchObject({
|
||||
condition: 'ship it',
|
||||
iterations: 4,
|
||||
setAt: 100,
|
||||
lastReason: 'continuing',
|
||||
});
|
||||
expect(projected.goalTerminal).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('projects completion as achieved and stops active_goal', () => {
|
||||
const projected = projectGoalStateToLegacy(payload('complete', 'complete'));
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('achieved');
|
||||
expect(projected.activeGoal).toBeNull();
|
||||
expect(projected.goalTerminal).toMatchObject({
|
||||
kind: 'achieved',
|
||||
condition: 'ship it',
|
||||
iterations: 4,
|
||||
durationMs: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects clear as cleared using the prior goal objective', () => {
|
||||
const projected = projectGoalStateToLegacy(
|
||||
payload('clear', 'active', null),
|
||||
GOAL,
|
||||
);
|
||||
|
||||
expect(projected.goalStatus).toMatchObject({
|
||||
kind: 'cleared',
|
||||
condition: 'ship it',
|
||||
});
|
||||
expect(projected.activeGoal).toBeNull();
|
||||
expect(projected.goalTerminal).toBeNull();
|
||||
});
|
||||
|
||||
it('projects pause as a non-terminal legacy paused state', () => {
|
||||
const projected = projectGoalStateToLegacy(payload('pause', 'paused'));
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('paused');
|
||||
expect(projected.activeGoal).toBeNull();
|
||||
expect(projected.goalTerminal).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['blocked', 'usage_limited'] as const)(
|
||||
'projects %s as a legacy stopped state',
|
||||
(status) => {
|
||||
const projected = projectGoalStateToLegacy(payload(status, status));
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('aborted');
|
||||
expect(projected.activeGoal).toBeNull();
|
||||
expect(projected.goalTerminal).toMatchObject({
|
||||
kind: 'aborted',
|
||||
condition: 'ship it',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('uses checking for active runtime progress without widening the union', () => {
|
||||
const projected = projectGoalStateToLegacy(
|
||||
payload('turn_finished', 'active'),
|
||||
);
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('checking');
|
||||
expect(projected.activeGoal).not.toBeNull();
|
||||
expect(projected.goalTerminal).toBeNull();
|
||||
});
|
||||
|
||||
it('does not repeat an aborted terminal after a paused turn finishes', () => {
|
||||
const projected = projectGoalStateToLegacy(
|
||||
payload('turn_finished', 'paused'),
|
||||
);
|
||||
|
||||
expect(projected.goalStatus.kind).toBe('checking');
|
||||
expect(projected.activeGoal).toBeNull();
|
||||
expect(projected.goalTerminal).toBeNull();
|
||||
});
|
||||
});
|
||||
137
packages/core/src/goals/goal-legacy-projection.ts
Normal file
137
packages/core/src/goals/goal-legacy-projection.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GoalRecord, GoalStateRecordPayloadV2 } from './goal-protocol.js';
|
||||
|
||||
export type LegacyGoalStatusKind =
|
||||
| 'set'
|
||||
| 'achieved'
|
||||
| 'cleared'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
| 'paused'
|
||||
| 'checking';
|
||||
|
||||
export interface LegacyGoalStatus {
|
||||
type: 'goal_status';
|
||||
kind: LegacyGoalStatusKind;
|
||||
condition: string;
|
||||
iterations?: number;
|
||||
setAt?: number;
|
||||
durationMs?: number;
|
||||
lastReason?: string;
|
||||
}
|
||||
|
||||
export interface LegacyActiveGoal {
|
||||
readonly condition: string;
|
||||
readonly iterations: number;
|
||||
readonly setAt: number;
|
||||
readonly tokensAtStart?: number;
|
||||
readonly hookId?: string;
|
||||
readonly lastReason?: string;
|
||||
}
|
||||
|
||||
export interface LegacyGoalTerminal {
|
||||
kind: 'achieved' | 'failed' | 'aborted';
|
||||
condition: string;
|
||||
iterations: number;
|
||||
durationMs: number;
|
||||
lastReason?: string;
|
||||
}
|
||||
|
||||
export interface LegacyGoalProjection {
|
||||
activeGoal: LegacyActiveGoal | null;
|
||||
goalStatus: LegacyGoalStatus;
|
||||
goalTerminal: LegacyGoalTerminal | null;
|
||||
}
|
||||
|
||||
export function projectGoalStateToLegacy(
|
||||
payload: GoalStateRecordPayloadV2,
|
||||
previousGoal: GoalRecord | null = null,
|
||||
): LegacyGoalProjection {
|
||||
const snapshotGoal = payload.snapshot.goal;
|
||||
const displayGoal = snapshotGoal ?? previousGoal;
|
||||
const kind = legacyStatusKind(payload);
|
||||
const goalStatus: LegacyGoalStatus = {
|
||||
type: 'goal_status',
|
||||
kind,
|
||||
condition: displayGoal?.objective ?? '',
|
||||
...(displayGoal ? { iterations: displayGoal.turnCount } : {}),
|
||||
...(displayGoal ? { setAt: displayGoal.createdAt } : {}),
|
||||
...(displayGoal ? { durationMs: displayGoal.activeTimeMs } : {}),
|
||||
...(displayGoal?.lastReason === undefined
|
||||
? {}
|
||||
: { lastReason: displayGoal.lastReason }),
|
||||
};
|
||||
const terminalKind =
|
||||
kind === 'achieved' || kind === 'failed' || kind === 'aborted'
|
||||
? kind
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
activeGoal:
|
||||
snapshotGoal?.status === 'active'
|
||||
? {
|
||||
condition: snapshotGoal.objective,
|
||||
iterations: snapshotGoal.turnCount,
|
||||
setAt: snapshotGoal.createdAt,
|
||||
...(snapshotGoal.lastReason === undefined
|
||||
? {}
|
||||
: { lastReason: snapshotGoal.lastReason }),
|
||||
}
|
||||
: null,
|
||||
goalStatus,
|
||||
goalTerminal:
|
||||
terminalKind && displayGoal
|
||||
? {
|
||||
kind: terminalKind,
|
||||
condition: displayGoal.objective,
|
||||
iterations: displayGoal.turnCount,
|
||||
durationMs: displayGoal.activeTimeMs,
|
||||
...(displayGoal.lastReason === undefined
|
||||
? {}
|
||||
: { lastReason: displayGoal.lastReason }),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyStatusKind(
|
||||
payload: GoalStateRecordPayloadV2,
|
||||
): LegacyGoalStatusKind {
|
||||
switch (payload.cause) {
|
||||
case 'create':
|
||||
case 'replace':
|
||||
case 'edit':
|
||||
case 'resume':
|
||||
case 'migrated':
|
||||
return 'set';
|
||||
case 'complete':
|
||||
return 'achieved';
|
||||
case 'clear':
|
||||
return 'cleared';
|
||||
case 'pause':
|
||||
return 'paused';
|
||||
case 'blocked':
|
||||
case 'usage_limited':
|
||||
return 'aborted';
|
||||
case 'turn_finished':
|
||||
case 'verifier_accept':
|
||||
case 'verifier_reject':
|
||||
return payload.snapshot.goal?.status === 'complete'
|
||||
? 'achieved'
|
||||
: payload.snapshot.goal?.status === 'blocked' ||
|
||||
payload.snapshot.goal?.status === 'usage_limited'
|
||||
? 'aborted'
|
||||
: 'checking';
|
||||
default:
|
||||
return assertNever(payload.cause);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported Goal state cause: ${String(value)}`);
|
||||
}
|
||||
249
packages/core/src/goals/goal-persistence.test.ts
Normal file
249
packages/core/src/goals/goal-persistence.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GoalStateRecordPayloadV2 } from './goal-protocol.js';
|
||||
import type { GoalRecoveryRecord } from './goal-persistence.js';
|
||||
import {
|
||||
createMigratedGoalState,
|
||||
recoverGoalFromRecords,
|
||||
} from './goal-persistence.js';
|
||||
|
||||
const ACTIVE_PAYLOAD: GoalStateRecordPayloadV2 = {
|
||||
v: 2,
|
||||
cause: 'create',
|
||||
snapshot: {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 1,
|
||||
objective: 'ship it',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'state-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 1500,
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function record(
|
||||
uuid: string,
|
||||
overrides: Partial<GoalRecoveryRecord> = {},
|
||||
): GoalRecoveryRecord {
|
||||
return {
|
||||
uuid,
|
||||
type: 'system',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recoverGoalFromRecords', () => {
|
||||
it('returns the newest valid v2 lifecycle snapshot', () => {
|
||||
const newer = {
|
||||
...ACTIVE_PAYLOAD,
|
||||
cause: 'pause' as const,
|
||||
snapshot: {
|
||||
...ACTIVE_PAYLOAD.snapshot,
|
||||
goal: { ...ACTIVE_PAYLOAD.snapshot.goal!, status: 'paused' as const },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('state-1', {
|
||||
subtype: 'goal_state',
|
||||
systemPayload: ACTIVE_PAYLOAD,
|
||||
}),
|
||||
record('state-2', {
|
||||
subtype: 'goal_state',
|
||||
systemPayload: newer,
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: 'v2', payload: newer });
|
||||
});
|
||||
|
||||
it.each<{
|
||||
label: string;
|
||||
overrides: Partial<GoalRecoveryRecord>;
|
||||
}>([
|
||||
{
|
||||
label: 'malformed',
|
||||
overrides: {
|
||||
systemPayload: {
|
||||
v: 3,
|
||||
snapshot: ACTIVE_PAYLOAD.snapshot,
|
||||
} as unknown as GoalStateRecordPayloadV2,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'non-system',
|
||||
overrides: {
|
||||
type: 'user',
|
||||
systemPayload: ACTIVE_PAYLOAD,
|
||||
},
|
||||
},
|
||||
])(
|
||||
'uses the newest valid lifecycle record when a newer record is $label',
|
||||
({ overrides }) => {
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('state-1', {
|
||||
subtype: 'goal_state',
|
||||
systemPayload: ACTIVE_PAYLOAD,
|
||||
}),
|
||||
record('state-2', {
|
||||
subtype: 'goal_state',
|
||||
...overrides,
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: 'v2', payload: ACTIVE_PAYLOAD });
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a goal_state payload stored on a non-system record', () => {
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('state-1', {
|
||||
type: 'user',
|
||||
subtype: 'goal_state',
|
||||
systemPayload: ACTIVE_PAYLOAD,
|
||||
}),
|
||||
]),
|
||||
).toEqual({
|
||||
kind: 'unsupported',
|
||||
reason: expect.stringContaining('state-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['paused', 'blocked', 'usage_limited', 'complete'] as const)(
|
||||
'restores %s state for display without making it active',
|
||||
(status) => {
|
||||
const payload: GoalStateRecordPayloadV2 = {
|
||||
...ACTIVE_PAYLOAD,
|
||||
snapshot: {
|
||||
...ACTIVE_PAYLOAD.snapshot,
|
||||
goal: { ...ACTIVE_PAYLOAD.snapshot.goal!, status },
|
||||
},
|
||||
};
|
||||
|
||||
const recovery = recoverGoalFromRecords([
|
||||
record('state-1', {
|
||||
subtype: 'goal_state',
|
||||
systemPayload: payload,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(recovery).toEqual({ kind: 'v2', payload });
|
||||
if (recovery.kind === 'v2') {
|
||||
expect(recovery.payload.snapshot.activity).toBe('idle');
|
||||
expect(recovery.payload.snapshot.goal?.status).toBe(status);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('uses only the objective from a legacy active Goal', () => {
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('legacy', {
|
||||
subtype: 'slash_command',
|
||||
systemPayload: {
|
||||
phase: 'result',
|
||||
rawCommand: '/goal ship it',
|
||||
outputHistoryItems: [
|
||||
{
|
||||
type: 'goal_status',
|
||||
kind: 'checking',
|
||||
condition: 'ship it',
|
||||
iterations: 19,
|
||||
setAt: 42,
|
||||
lastReason: 'old evidence',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: 'legacy', objective: 'ship it' });
|
||||
});
|
||||
|
||||
it('does not revive a stopped legacy Goal', () => {
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('legacy', {
|
||||
subtype: 'slash_command',
|
||||
systemPayload: {
|
||||
phase: 'result',
|
||||
rawCommand: '/goal',
|
||||
outputHistoryItems: [
|
||||
{ type: 'goal_status', kind: 'aborted', condition: 'ship it' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('does not revive a paused legacy Goal', () => {
|
||||
expect(
|
||||
recoverGoalFromRecords([
|
||||
record('legacy', {
|
||||
subtype: 'slash_command',
|
||||
systemPayload: {
|
||||
phase: 'result',
|
||||
rawCommand: '/goal',
|
||||
outputHistoryItems: [
|
||||
{ type: 'goal_status', kind: 'paused', condition: 'ship it' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toEqual({ kind: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy migration', () => {
|
||||
it('creates a fresh active payload at the lifecycle record boundary', () => {
|
||||
expect(
|
||||
createMigratedGoalState({
|
||||
objective: 'ship it',
|
||||
goalId: 'new-goal',
|
||||
recordUuid: 'migration-record',
|
||||
now: 1000,
|
||||
}),
|
||||
).toEqual({
|
||||
v: 2,
|
||||
cause: 'migrated',
|
||||
snapshot: {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'new-goal',
|
||||
revision: 1,
|
||||
objective: 'ship it',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'migration-record' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty migrated objective', () => {
|
||||
expect(() =>
|
||||
createMigratedGoalState({
|
||||
objective: ' ',
|
||||
goalId: 'new-goal',
|
||||
recordUuid: 'migration-record',
|
||||
now: 1000,
|
||||
}),
|
||||
).toThrow('Migrated Goal objective must not be empty');
|
||||
});
|
||||
});
|
||||
145
packages/core/src/goals/goal-persistence.ts
Normal file
145
packages/core/src/goals/goal-persistence.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChatRecord,
|
||||
SlashCommandRecordPayload,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import { parseGoalStateRecordPayloadV2 } from './goal-reducer.js';
|
||||
import {
|
||||
GOAL_STATE_VERSION,
|
||||
type GoalStateRecordPayloadV2,
|
||||
} from './goal-protocol.js';
|
||||
|
||||
export type GoalRecovery =
|
||||
| { kind: 'v2'; payload: GoalStateRecordPayloadV2 }
|
||||
| { kind: 'legacy'; objective: string }
|
||||
| { kind: 'unsupported'; reason: string }
|
||||
| { kind: 'none' };
|
||||
|
||||
export type GoalRecoveryRecord = Pick<ChatRecord, 'uuid' | 'type'> & {
|
||||
subtype?: string;
|
||||
systemPayload?: unknown;
|
||||
};
|
||||
|
||||
const LEGACY_ACTIVE_KINDS = new Set(['set', 'checking']);
|
||||
const LEGACY_STOPPED_KINDS = new Set([
|
||||
'achieved',
|
||||
'cleared',
|
||||
'failed',
|
||||
'aborted',
|
||||
'paused',
|
||||
]);
|
||||
|
||||
export function recoverGoalFromRecords(
|
||||
records: readonly GoalRecoveryRecord[],
|
||||
): GoalRecovery {
|
||||
let unsupported: GoalRecovery | undefined;
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const record = records[index];
|
||||
if (record?.subtype !== 'goal_state') continue;
|
||||
const payload =
|
||||
record.type === 'system'
|
||||
? parseGoalStateRecordPayloadV2(record.systemPayload)
|
||||
: undefined;
|
||||
if (payload) return { kind: 'v2', payload };
|
||||
unsupported ??= {
|
||||
kind: 'unsupported',
|
||||
reason: `Goal lifecycle record ${record.uuid} is malformed or uses an unsupported version`,
|
||||
};
|
||||
}
|
||||
|
||||
return unsupported ?? recoverLegacyGoal(records);
|
||||
}
|
||||
|
||||
function recoverLegacyGoal(
|
||||
records: readonly GoalRecoveryRecord[],
|
||||
): GoalRecovery {
|
||||
for (
|
||||
let recordIndex = records.length - 1;
|
||||
recordIndex >= 0;
|
||||
recordIndex -= 1
|
||||
) {
|
||||
const record = records[recordIndex];
|
||||
if (record?.type !== 'system' || record.subtype !== 'slash_command') {
|
||||
continue;
|
||||
}
|
||||
const payload = record.systemPayload as
|
||||
| SlashCommandRecordPayload
|
||||
| undefined;
|
||||
if (
|
||||
payload?.phase !== 'result' ||
|
||||
!Array.isArray(payload.outputHistoryItems)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let itemIndex = payload.outputHistoryItems.length - 1;
|
||||
itemIndex >= 0;
|
||||
itemIndex -= 1
|
||||
) {
|
||||
const value: unknown = payload.outputHistoryItems[itemIndex];
|
||||
if (!isObjectRecord(value) || value['type'] !== 'goal_status') continue;
|
||||
const kind = value['kind'];
|
||||
const condition = value['condition'];
|
||||
if (typeof kind !== 'string' || typeof condition !== 'string') {
|
||||
return unsupportedLegacy(record.uuid);
|
||||
}
|
||||
if (LEGACY_STOPPED_KINDS.has(kind)) return { kind: 'none' };
|
||||
if (!LEGACY_ACTIVE_KINDS.has(kind) || condition.trim().length === 0) {
|
||||
return unsupportedLegacy(record.uuid);
|
||||
}
|
||||
return { kind: 'legacy', objective: condition.trim() };
|
||||
}
|
||||
}
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
function unsupportedLegacy(recordUuid: string): GoalRecovery {
|
||||
return {
|
||||
kind: 'unsupported',
|
||||
reason: `Legacy Goal record ${recordUuid} cannot be recovered safely`,
|
||||
};
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export interface MigratedGoalStateInput {
|
||||
objective: string;
|
||||
goalId: string;
|
||||
recordUuid: string;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function createMigratedGoalState(
|
||||
input: MigratedGoalStateInput,
|
||||
): GoalStateRecordPayloadV2 {
|
||||
const objective = input.objective.trim();
|
||||
if (!objective) {
|
||||
throw new Error('Migrated Goal objective must not be empty');
|
||||
}
|
||||
return {
|
||||
v: GOAL_STATE_VERSION,
|
||||
cause: 'migrated',
|
||||
snapshot: {
|
||||
v: GOAL_STATE_VERSION,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: input.goalId,
|
||||
revision: 1,
|
||||
objective,
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: input.recordUuid },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
126
packages/core/src/goals/goal-protocol.ts
Normal file
126
packages/core/src/goals/goal-protocol.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const GOAL_STATE_VERSION = 2 as const;
|
||||
|
||||
export const PAUSED_GOAL_SYSTEM_REMINDER =
|
||||
'<system-reminder>\nThe Goal is paused. Do not continue its objective unless the user resumes it. Treat this message as ordinary conversation.\n</system-reminder>';
|
||||
|
||||
export type GoalStatus =
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'blocked'
|
||||
| 'usage_limited'
|
||||
| 'complete';
|
||||
|
||||
export type GoalActivity = 'idle' | 'running' | 'verifying';
|
||||
|
||||
export interface TranscriptCursor {
|
||||
recordId: string | null;
|
||||
}
|
||||
|
||||
export interface GoalExpectedVersion {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface GoalTurnPermit extends GoalExpectedVersion {
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
export interface GoalRecord {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
status: GoalStatus;
|
||||
evidenceCursor: TranscriptCursor;
|
||||
turnCount: number;
|
||||
activeTimeMs: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastReason?: string;
|
||||
}
|
||||
|
||||
export interface GoalSnapshotV2 {
|
||||
v: typeof GOAL_STATE_VERSION;
|
||||
goal: GoalRecord | null;
|
||||
activity: GoalActivity;
|
||||
}
|
||||
|
||||
/** True while any new model send must carry the runtime's exact turn permit. */
|
||||
export function goalRequiresExactPermit(snapshot: GoalSnapshotV2): boolean {
|
||||
return (
|
||||
snapshot.goal !== null &&
|
||||
(snapshot.goal.status === 'active' || snapshot.activity === 'running')
|
||||
);
|
||||
}
|
||||
|
||||
export type GoalControlRequest =
|
||||
| { action: 'create'; objective: string }
|
||||
| {
|
||||
action: 'replace';
|
||||
objective: string;
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
}
|
||||
| {
|
||||
action: 'edit';
|
||||
objective: string;
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
}
|
||||
| {
|
||||
action: 'pause';
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
}
|
||||
| {
|
||||
action: 'resume';
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
}
|
||||
| {
|
||||
action: 'clear';
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
};
|
||||
|
||||
export interface GoalStateResponse {
|
||||
snapshot: GoalSnapshotV2;
|
||||
}
|
||||
|
||||
export interface GoalTerminalProposal {
|
||||
status: 'complete' | 'blocked';
|
||||
reason: string;
|
||||
evidenceRefs: string[];
|
||||
blockerKind?: 'authority' | 'external' | 'repeated';
|
||||
}
|
||||
|
||||
export type GoalStateCause =
|
||||
| 'create'
|
||||
| 'replace'
|
||||
| 'edit'
|
||||
| 'pause'
|
||||
| 'resume'
|
||||
| 'turn_finished'
|
||||
| 'verifier_accept'
|
||||
| 'verifier_reject'
|
||||
| 'complete'
|
||||
| 'blocked'
|
||||
| 'usage_limited'
|
||||
| 'clear'
|
||||
| 'migrated';
|
||||
|
||||
export interface GoalStateRecordPayloadV2 {
|
||||
v: typeof GOAL_STATE_VERSION;
|
||||
cause: GoalStateCause;
|
||||
snapshot: GoalSnapshotV2;
|
||||
blockedAudit?: {
|
||||
fingerprint: string;
|
||||
count: number;
|
||||
turnIds: string[];
|
||||
};
|
||||
}
|
||||
505
packages/core/src/goals/goal-reducer.test.ts
Normal file
505
packages/core/src/goals/goal-reducer.test.ts
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
goalRequiresExactPermit,
|
||||
type GoalControlRequest,
|
||||
type GoalRecord,
|
||||
type GoalSnapshotV2,
|
||||
} from './goal-protocol.js';
|
||||
import {
|
||||
GoalConflictError,
|
||||
GoalInvalidTransitionError,
|
||||
elapsedActiveTime,
|
||||
parseGoalControlRequest,
|
||||
parseGoalSnapshotV2,
|
||||
parseGoalStateRecordPayloadV2,
|
||||
reduceGoalControl,
|
||||
reduceGoalTurnFinished,
|
||||
} from './goal-reducer.js';
|
||||
|
||||
const goalRecord = (overrides: Partial<GoalRecord> = {}): GoalRecord => ({
|
||||
goalId: 'g-1',
|
||||
revision: 1,
|
||||
objective: 'ship',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const snapshot = (goal: GoalRecord | null): GoalSnapshotV2 => ({
|
||||
v: 2,
|
||||
goal,
|
||||
activity: 'idle',
|
||||
});
|
||||
|
||||
describe('goal reducer', () => {
|
||||
it('replaces the same objective with a fresh identity and cursor', () => {
|
||||
const previous = goalRecord({ goalId: 'g-1', objective: 'ship' });
|
||||
const next = reduceGoalControl(previous, {
|
||||
request: {
|
||||
action: 'replace',
|
||||
objective: 'ship',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 200,
|
||||
nextGoalId: 'g-2',
|
||||
cursor: { recordId: 'r-200' },
|
||||
});
|
||||
|
||||
expect(next).toMatchObject({
|
||||
goalId: 'g-2',
|
||||
revision: 1,
|
||||
objective: 'ship',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'r-200' },
|
||||
turnCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('edits in place and rejects evidence from the previous revision', () => {
|
||||
const previous = goalRecord({ goalId: 'g-1', revision: 4 });
|
||||
const next = reduceGoalControl(previous, {
|
||||
request: {
|
||||
action: 'edit',
|
||||
objective: 'new objective',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 4,
|
||||
},
|
||||
now: 300,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-300' },
|
||||
});
|
||||
|
||||
expect(next).toMatchObject({
|
||||
goalId: 'g-1',
|
||||
revision: 5,
|
||||
objective: 'new objective',
|
||||
evidenceCursor: { recordId: 'r-300' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a trimmed active goal only when no goal exists', () => {
|
||||
const next = reduceGoalControl(null, {
|
||||
request: { action: 'create', objective: ' ship ' },
|
||||
now: 100,
|
||||
nextGoalId: 'g-1',
|
||||
cursor: { recordId: 'r-100' },
|
||||
});
|
||||
|
||||
expect(next).toEqual(goalRecord());
|
||||
expect(() =>
|
||||
reduceGoalControl(next, {
|
||||
request: { action: 'create', objective: 'another' },
|
||||
now: 200,
|
||||
nextGoalId: 'g-2',
|
||||
cursor: { recordId: 'r-200' },
|
||||
}),
|
||||
).toThrow(GoalConflictError);
|
||||
});
|
||||
|
||||
it('rejects empty objectives', () => {
|
||||
expect(() =>
|
||||
reduceGoalControl(null, {
|
||||
request: { action: 'create', objective: ' \n ' },
|
||||
now: 100,
|
||||
nextGoalId: 'g-1',
|
||||
cursor: { recordId: 'r-100' },
|
||||
}),
|
||||
).toThrow(GoalInvalidTransitionError);
|
||||
});
|
||||
|
||||
it('returns the current snapshot for stale identity and revision', () => {
|
||||
const previous = goalRecord({ revision: 4 });
|
||||
|
||||
for (const request of [
|
||||
{
|
||||
action: 'pause' as const,
|
||||
expectedGoalId: 'g-other',
|
||||
expectedRevision: 4,
|
||||
},
|
||||
{
|
||||
action: 'pause' as const,
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 3,
|
||||
},
|
||||
]) {
|
||||
try {
|
||||
reduceGoalControl(previous, {
|
||||
request,
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
});
|
||||
throw new Error('expected conflict');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(GoalConflictError);
|
||||
expect((error as GoalConflictError).current).toEqual(
|
||||
snapshot(previous),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('pauses and resumes without changing revision or evidence cursor', () => {
|
||||
const paused = reduceGoalControl(goalRecord(), {
|
||||
request: {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 150,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-150' },
|
||||
});
|
||||
const resumed = reduceGoalControl(paused, {
|
||||
request: {
|
||||
action: 'resume',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
});
|
||||
|
||||
expect(paused).toMatchObject({
|
||||
status: 'paused',
|
||||
revision: 1,
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
});
|
||||
expect(resumed).toMatchObject({
|
||||
status: 'active',
|
||||
revision: 1,
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects resuming an already-active goal', () => {
|
||||
expect(() =>
|
||||
reduceGoalControl(goalRecord(), {
|
||||
request: {
|
||||
action: 'resume',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
}),
|
||||
).toThrow(GoalInvalidTransitionError);
|
||||
});
|
||||
|
||||
it.each(['blocked', 'usage_limited'] as const)(
|
||||
'resumes a %s goal without changing revision or evidence cursor',
|
||||
(status) => {
|
||||
const resumed = reduceGoalControl(goalRecord({ status, revision: 4 }), {
|
||||
request: {
|
||||
action: 'resume',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 4,
|
||||
},
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
});
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
status: 'active',
|
||||
revision: 4,
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unsupported control action instead of resuming', () => {
|
||||
expect(() =>
|
||||
reduceGoalControl(goalRecord({ status: 'paused' }), {
|
||||
request: {
|
||||
action: 'archive',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
} as unknown as GoalControlRequest,
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
}),
|
||||
).toThrow(GoalInvalidTransitionError);
|
||||
});
|
||||
|
||||
it.each(['paused', 'blocked', 'usage_limited'] as const)(
|
||||
'edits a %s goal without changing its status',
|
||||
(status) => {
|
||||
const next = reduceGoalControl(goalRecord({ status, revision: 4 }), {
|
||||
request: {
|
||||
action: 'edit',
|
||||
objective: 'new objective',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 4,
|
||||
},
|
||||
now: 300,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-300' },
|
||||
});
|
||||
|
||||
expect(next).toMatchObject({ status, revision: 5 });
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects editing or resuming a completed goal', () => {
|
||||
const complete = goalRecord({ status: 'complete' });
|
||||
|
||||
for (const request of [
|
||||
{
|
||||
action: 'edit' as const,
|
||||
objective: 'new objective',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
{
|
||||
action: 'resume' as const,
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
]) {
|
||||
expect(() =>
|
||||
reduceGoalControl(complete, {
|
||||
request,
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
}),
|
||||
).toThrow(GoalInvalidTransitionError);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears a matching goal', () => {
|
||||
expect(
|
||||
reduceGoalControl(goalRecord(), {
|
||||
request: {
|
||||
action: 'clear',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 200,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-200' },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('folds active elapsed time before each persisted transition', () => {
|
||||
const paused = reduceGoalControl(goalRecord(), {
|
||||
request: {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 160,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-160' },
|
||||
});
|
||||
const resumed = reduceGoalControl(paused, {
|
||||
request: {
|
||||
action: 'resume',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 250,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-250' },
|
||||
});
|
||||
const pausedAgain = reduceGoalControl(resumed, {
|
||||
request: {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
now: 275,
|
||||
nextGoalId: 'unused',
|
||||
cursor: { recordId: 'r-275' },
|
||||
});
|
||||
|
||||
expect(paused?.activeTimeMs).toBe(60);
|
||||
expect(resumed?.activeTimeMs).toBe(60);
|
||||
expect(pausedAgain?.activeTimeMs).toBe(85);
|
||||
expect(elapsedActiveTime(resumed!, 275)).toBe(85);
|
||||
});
|
||||
|
||||
it('never derives a terminal status from turn count or elapsed time', () => {
|
||||
let goal = goalRecord();
|
||||
for (let turn = 1; turn <= 150; turn += 1) {
|
||||
goal = reduceGoalTurnFinished(goal, {
|
||||
now: 100 + turn,
|
||||
});
|
||||
}
|
||||
|
||||
expect(goal).toMatchObject({
|
||||
status: 'active',
|
||||
revision: 1,
|
||||
turnCount: 150,
|
||||
activeTimeMs: 150,
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
});
|
||||
});
|
||||
|
||||
it('finishes an in-flight turn after pause without resuming active time', () => {
|
||||
const paused = goalRecord({
|
||||
revision: 4,
|
||||
status: 'paused',
|
||||
turnCount: 2,
|
||||
activeTimeMs: 60,
|
||||
updatedAt: 160,
|
||||
});
|
||||
|
||||
const finished = reduceGoalTurnFinished(paused, { now: 225 });
|
||||
|
||||
expect(finished).toMatchObject({
|
||||
status: 'paused',
|
||||
revision: 4,
|
||||
evidenceCursor: { recordId: 'r-100' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 60,
|
||||
updatedAt: 225,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['blocked', 'usage_limited', 'complete'] as const)(
|
||||
'rejects finishing a turn for a %s goal',
|
||||
(status) => {
|
||||
expect(() =>
|
||||
reduceGoalTurnFinished(goalRecord({ status }), { now: 200 }),
|
||||
).toThrow(GoalInvalidTransitionError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[null, 'idle', false],
|
||||
[goalRecord(), 'idle', true],
|
||||
[goalRecord({ status: 'paused' }), 'idle', false],
|
||||
[goalRecord({ status: 'paused' }), 'running', true],
|
||||
] as const)(
|
||||
'requires an exact permit for the matching goal and activity state',
|
||||
(goal, activity, expected) => {
|
||||
expect(goalRequiresExactPermit({ ...snapshot(goal), activity })).toBe(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('strictly parses persisted idle goal snapshots and control requests', () => {
|
||||
const record = goalRecord();
|
||||
expect(
|
||||
parseGoalStateRecordPayloadV2({
|
||||
v: 2,
|
||||
cause: 'create',
|
||||
snapshot: snapshot(record),
|
||||
}),
|
||||
).toEqual({ v: 2, cause: 'create', snapshot: snapshot(record) });
|
||||
expect(
|
||||
parseGoalStateRecordPayloadV2({
|
||||
v: 2,
|
||||
cause: 'create',
|
||||
snapshot: { ...snapshot(record), activity: 'running' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseGoalControlRequest({ action: 'create', objective: 'ship' }),
|
||||
).toEqual({
|
||||
action: 'create',
|
||||
objective: 'ship',
|
||||
});
|
||||
expect(
|
||||
parseGoalControlRequest({
|
||||
action: 'edit',
|
||||
objective: ' ',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 1,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseGoalControlRequest({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'g-1',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseGoalControlRequest({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'g-1',
|
||||
expectedRevision: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['idle', 'running', 'verifying'] as const)(
|
||||
'parses %s activity in public wire snapshots',
|
||||
(activity) => {
|
||||
const value = { ...snapshot(goalRecord()), activity };
|
||||
|
||||
expect(parseGoalSnapshotV2(value)).toEqual(value);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['zero count', { fingerprint: 'same', count: 0, turnIds: [] }],
|
||||
[
|
||||
'count above the blocker threshold',
|
||||
{
|
||||
fingerprint: 'same',
|
||||
count: 4,
|
||||
turnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'count and turn ID mismatch',
|
||||
{ fingerprint: 'same', count: 2, turnIds: ['turn-1'] },
|
||||
],
|
||||
['empty fingerprint', { fingerprint: '', count: 1, turnIds: ['turn-1'] }],
|
||||
['empty turn ID', { fingerprint: 'same', count: 1, turnIds: [''] }],
|
||||
[
|
||||
'extra key',
|
||||
{
|
||||
fingerprint: 'same',
|
||||
count: 1,
|
||||
turnIds: ['turn-1'],
|
||||
unexpected: true,
|
||||
},
|
||||
],
|
||||
])('rejects a blocked audit with %s', (_label, blockedAudit) => {
|
||||
expect(
|
||||
parseGoalStateRecordPayloadV2({
|
||||
v: 2,
|
||||
cause: 'turn_finished',
|
||||
snapshot: snapshot(goalRecord()),
|
||||
blockedAudit,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses and clones a valid blocked audit', () => {
|
||||
const blockedAudit = {
|
||||
fingerprint: 'same',
|
||||
count: 2,
|
||||
turnIds: ['turn-1', 'turn-2'],
|
||||
};
|
||||
const parsed = parseGoalStateRecordPayloadV2({
|
||||
v: 2,
|
||||
cause: 'turn_finished',
|
||||
snapshot: snapshot(goalRecord()),
|
||||
blockedAudit,
|
||||
});
|
||||
|
||||
expect(parsed?.blockedAudit).toEqual(blockedAudit);
|
||||
expect(parsed?.blockedAudit).not.toBe(blockedAudit);
|
||||
});
|
||||
});
|
||||
485
packages/core/src/goals/goal-reducer.ts
Normal file
485
packages/core/src/goals/goal-reducer.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
GOAL_STATE_VERSION,
|
||||
type GoalControlRequest,
|
||||
type GoalRecord,
|
||||
type GoalSnapshotV2,
|
||||
type GoalStateCause,
|
||||
type GoalStateRecordPayloadV2,
|
||||
type GoalStatus,
|
||||
type TranscriptCursor,
|
||||
} from './goal-protocol.js';
|
||||
|
||||
const MAX_BLOCKED_AUDIT_COUNT = 3;
|
||||
|
||||
export interface GoalControlTransition {
|
||||
request: GoalControlRequest;
|
||||
now: number;
|
||||
nextGoalId: string;
|
||||
cursor: TranscriptCursor;
|
||||
}
|
||||
|
||||
export interface GoalTurnFinishedTransition {
|
||||
now: number;
|
||||
lastReason?: string;
|
||||
}
|
||||
|
||||
export class GoalConflictError extends Error {
|
||||
constructor(readonly current: GoalSnapshotV2) {
|
||||
super('Goal version does not match the current session Goal');
|
||||
this.name = 'GoalConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GoalInvalidTransitionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly current: GoalSnapshotV2,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GoalInvalidTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export function elapsedActiveTime(goal: GoalRecord, now: number): number {
|
||||
return (
|
||||
goal.activeTimeMs +
|
||||
(goal.status === 'active' ? Math.max(0, now - goal.updatedAt) : 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function reduceGoalControl(
|
||||
current: GoalRecord | null,
|
||||
transition: GoalControlTransition,
|
||||
): GoalRecord | null {
|
||||
const { request } = transition;
|
||||
if (request.action === 'create') {
|
||||
if (current) throw new GoalConflictError(snapshotOf(current));
|
||||
return createGoal(
|
||||
transition.nextGoalId,
|
||||
normalizeObjective(request.objective, snapshotOf(null)),
|
||||
transition.now,
|
||||
transition.cursor,
|
||||
);
|
||||
}
|
||||
|
||||
assertExpectedVersion(
|
||||
current,
|
||||
request.expectedGoalId,
|
||||
request.expectedRevision,
|
||||
);
|
||||
|
||||
if (request.action === 'clear') return null;
|
||||
|
||||
if (request.action === 'replace') {
|
||||
return createGoal(
|
||||
transition.nextGoalId,
|
||||
normalizeObjective(request.objective, snapshotOf(current)),
|
||||
transition.now,
|
||||
transition.cursor,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.action === 'edit') {
|
||||
if (current.status === 'complete') {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'A completed Goal cannot be edited',
|
||||
snapshotOf(current),
|
||||
);
|
||||
}
|
||||
return transitionGoal(current, transition.now, {
|
||||
revision: current.revision + 1,
|
||||
objective: normalizeObjective(request.objective, snapshotOf(current)),
|
||||
evidenceCursor: copyCursor(transition.cursor),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.action === 'pause') {
|
||||
if (current.status !== 'active') {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'Only an active Goal can be paused',
|
||||
snapshotOf(current),
|
||||
);
|
||||
}
|
||||
return transitionGoal(current, transition.now, { status: 'paused' });
|
||||
}
|
||||
|
||||
if (current.status === 'complete') {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'A completed Goal cannot be resumed',
|
||||
snapshotOf(current),
|
||||
);
|
||||
}
|
||||
if (current.status === 'active') {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'An active Goal cannot be resumed',
|
||||
snapshotOf(current),
|
||||
);
|
||||
}
|
||||
if (request.action !== 'resume') {
|
||||
return assertNever(request, snapshotOf(current));
|
||||
}
|
||||
return transitionGoal(current, transition.now, { status: 'active' });
|
||||
}
|
||||
|
||||
export function reduceGoalTurnFinished(
|
||||
current: GoalRecord,
|
||||
transition: GoalTurnFinishedTransition,
|
||||
): GoalRecord {
|
||||
if (current.status !== 'active' && current.status !== 'paused') {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'Only an active or paused Goal can finish a turn',
|
||||
snapshotOf(current),
|
||||
);
|
||||
}
|
||||
return transitionGoal(current, transition.now, {
|
||||
turnCount: current.turnCount + 1,
|
||||
...(transition.lastReason === undefined
|
||||
? {}
|
||||
: { lastReason: transition.lastReason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseGoalControlRequest(
|
||||
value: unknown,
|
||||
): GoalControlRequest | undefined {
|
||||
if (!isRecord(value) || typeof value['action'] !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (value['action']) {
|
||||
case 'create':
|
||||
if (!hasOnlyKeys(value, ['action', 'objective'])) return undefined;
|
||||
return typeof value['objective'] === 'string'
|
||||
? parseObjectiveRequest(value['action'], value['objective'])
|
||||
: undefined;
|
||||
case 'replace':
|
||||
case 'edit':
|
||||
if (
|
||||
!hasOnlyKeys(value, [
|
||||
'action',
|
||||
'objective',
|
||||
'expectedGoalId',
|
||||
'expectedRevision',
|
||||
]) ||
|
||||
typeof value['objective'] !== 'string' ||
|
||||
!isExpectedVersion(value)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return parseObjectiveVersionedRequest(
|
||||
value['action'],
|
||||
value['objective'],
|
||||
value['expectedGoalId'],
|
||||
value['expectedRevision'],
|
||||
);
|
||||
case 'pause':
|
||||
case 'resume':
|
||||
case 'clear':
|
||||
if (
|
||||
!hasOnlyKeys(value, ['action', 'expectedGoalId', 'expectedRevision']) ||
|
||||
!isExpectedVersion(value)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
action: value['action'],
|
||||
expectedGoalId: value['expectedGoalId'],
|
||||
expectedRevision: value['expectedRevision'],
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGoalStateRecordPayloadV2(
|
||||
value: unknown,
|
||||
): GoalStateRecordPayloadV2 | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, ['v', 'cause', 'snapshot', 'blockedAudit']) ||
|
||||
value['v'] !== GOAL_STATE_VERSION ||
|
||||
!isGoalStateCause(value['cause']) ||
|
||||
!isBlockedAudit(value['blockedAudit'])
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedSnapshot = parseGoalSnapshotV2(value['snapshot']);
|
||||
return parsedSnapshot?.activity === 'idle'
|
||||
? {
|
||||
v: GOAL_STATE_VERSION,
|
||||
cause: value['cause'],
|
||||
snapshot: parsedSnapshot,
|
||||
...(value['blockedAudit']
|
||||
? { blockedAudit: structuredClone(value['blockedAudit']) }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function parseGoalSnapshotV2(
|
||||
value: unknown,
|
||||
): GoalSnapshotV2 | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, ['v', 'goal', 'activity']) ||
|
||||
value['v'] !== GOAL_STATE_VERSION ||
|
||||
!isGoalActivity(value['activity'])
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (value['goal'] === null) {
|
||||
return {
|
||||
v: GOAL_STATE_VERSION,
|
||||
goal: null,
|
||||
activity: value['activity'],
|
||||
};
|
||||
}
|
||||
const goal = parseGoalRecord(value['goal']);
|
||||
return goal
|
||||
? { v: GOAL_STATE_VERSION, goal, activity: value['activity'] }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function createGoal(
|
||||
goalId: string,
|
||||
objective: string,
|
||||
now: number,
|
||||
cursor: TranscriptCursor,
|
||||
): GoalRecord {
|
||||
return {
|
||||
goalId,
|
||||
revision: 1,
|
||||
objective,
|
||||
status: 'active',
|
||||
evidenceCursor: copyCursor(cursor),
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function assertExpectedVersion(
|
||||
current: GoalRecord | null,
|
||||
expectedGoalId: string,
|
||||
expectedRevision: number,
|
||||
): asserts current is GoalRecord {
|
||||
if (
|
||||
!current ||
|
||||
current.goalId !== expectedGoalId ||
|
||||
current.revision !== expectedRevision
|
||||
) {
|
||||
throw new GoalConflictError(snapshotOf(current));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeObjective(
|
||||
objective: string,
|
||||
current: GoalSnapshotV2,
|
||||
): string {
|
||||
const normalized = objective.trim();
|
||||
if (!normalized) {
|
||||
throw new GoalInvalidTransitionError(
|
||||
'Goal objective must not be empty',
|
||||
current,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function transitionGoal(
|
||||
goal: GoalRecord,
|
||||
now: number,
|
||||
changes: Partial<GoalRecord>,
|
||||
): GoalRecord {
|
||||
return {
|
||||
...goal,
|
||||
...changes,
|
||||
activeTimeMs: elapsedActiveTime(goal, now),
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotOf(goal: GoalRecord | null): GoalSnapshotV2 {
|
||||
return { v: GOAL_STATE_VERSION, goal, activity: 'idle' };
|
||||
}
|
||||
|
||||
function copyCursor(cursor: TranscriptCursor): TranscriptCursor {
|
||||
return { recordId: cursor.recordId };
|
||||
}
|
||||
|
||||
function parseObjectiveRequest(
|
||||
action: 'create',
|
||||
objective: string,
|
||||
): GoalControlRequest | undefined {
|
||||
const normalized = objective.trim();
|
||||
return normalized ? { action, objective: normalized } : undefined;
|
||||
}
|
||||
|
||||
function parseObjectiveVersionedRequest(
|
||||
action: 'replace' | 'edit',
|
||||
objective: string,
|
||||
expectedGoalId: string,
|
||||
expectedRevision: number,
|
||||
): GoalControlRequest | undefined {
|
||||
const normalized = objective.trim();
|
||||
return normalized
|
||||
? { action, objective: normalized, expectedGoalId, expectedRevision }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseGoalRecord(value: unknown): GoalRecord | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, [
|
||||
'goalId',
|
||||
'revision',
|
||||
'objective',
|
||||
'status',
|
||||
'evidenceCursor',
|
||||
'turnCount',
|
||||
'activeTimeMs',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'lastReason',
|
||||
]) ||
|
||||
typeof value['goalId'] !== 'string' ||
|
||||
!value['goalId'] ||
|
||||
!isNonNegativeInteger(value['revision']) ||
|
||||
value['revision'] === 0 ||
|
||||
typeof value['objective'] !== 'string' ||
|
||||
!value['objective'].trim() ||
|
||||
!isGoalStatus(value['status']) ||
|
||||
!isTranscriptCursor(value['evidenceCursor']) ||
|
||||
!isNonNegativeInteger(value['turnCount']) ||
|
||||
!isNonNegativeNumber(value['activeTimeMs']) ||
|
||||
!isFiniteNumber(value['createdAt']) ||
|
||||
!isFiniteNumber(value['updatedAt']) ||
|
||||
(value['lastReason'] !== undefined &&
|
||||
typeof value['lastReason'] !== 'string')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
goalId: value['goalId'],
|
||||
revision: value['revision'],
|
||||
objective: value['objective'],
|
||||
status: value['status'],
|
||||
evidenceCursor: copyCursor(value['evidenceCursor']),
|
||||
turnCount: value['turnCount'],
|
||||
activeTimeMs: value['activeTimeMs'],
|
||||
createdAt: value['createdAt'],
|
||||
updatedAt: value['updatedAt'],
|
||||
...(value['lastReason'] === undefined
|
||||
? {}
|
||||
: { lastReason: value['lastReason'] }),
|
||||
};
|
||||
}
|
||||
|
||||
function isExpectedVersion(value: Record<string, unknown>): value is Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
} {
|
||||
return (
|
||||
typeof value['expectedGoalId'] === 'string' &&
|
||||
value['expectedGoalId'].length > 0 &&
|
||||
isNonNegativeInteger(value['expectedRevision']) &&
|
||||
value['expectedRevision'] > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isTranscriptCursor(value: unknown): value is TranscriptCursor {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ['recordId']) &&
|
||||
(typeof value['recordId'] === 'string' || value['recordId'] === null)
|
||||
);
|
||||
}
|
||||
|
||||
function isGoalStatus(value: unknown): value is GoalStatus {
|
||||
return (
|
||||
value === 'active' ||
|
||||
value === 'paused' ||
|
||||
value === 'blocked' ||
|
||||
value === 'usage_limited' ||
|
||||
value === 'complete'
|
||||
);
|
||||
}
|
||||
|
||||
function isGoalActivity(value: unknown): value is GoalSnapshotV2['activity'] {
|
||||
return value === 'idle' || value === 'running' || value === 'verifying';
|
||||
}
|
||||
|
||||
function isGoalStateCause(value: unknown): value is GoalStateCause {
|
||||
return (
|
||||
value === 'create' ||
|
||||
value === 'replace' ||
|
||||
value === 'edit' ||
|
||||
value === 'pause' ||
|
||||
value === 'resume' ||
|
||||
value === 'turn_finished' ||
|
||||
value === 'verifier_accept' ||
|
||||
value === 'verifier_reject' ||
|
||||
value === 'complete' ||
|
||||
value === 'blocked' ||
|
||||
value === 'usage_limited' ||
|
||||
value === 'clear' ||
|
||||
value === 'migrated'
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedAudit(
|
||||
value: unknown,
|
||||
): value is GoalStateRecordPayloadV2['blockedAudit'] {
|
||||
return (
|
||||
value === undefined ||
|
||||
(isRecord(value) &&
|
||||
hasOnlyKeys(value, ['fingerprint', 'count', 'turnIds']) &&
|
||||
typeof value['fingerprint'] === 'string' &&
|
||||
value['fingerprint'].length > 0 &&
|
||||
isNonNegativeInteger(value['count']) &&
|
||||
value['count'] > 0 &&
|
||||
value['count'] <= MAX_BLOCKED_AUDIT_COUNT &&
|
||||
Array.isArray(value['turnIds']) &&
|
||||
value['turnIds'].length === value['count'] &&
|
||||
value['turnIds'].every(
|
||||
(turnId) => typeof turnId === 'string' && turnId.length > 0,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
||||
return Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function assertNever(value: never, snapshot: GoalSnapshotV2): never {
|
||||
throw new GoalInvalidTransitionError(
|
||||
`Unsupported Goal control action: ${String(value)}`,
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
34
packages/core/src/goals/goal-wire.ts
Normal file
34
packages/core/src/goals/goal-wire.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
GOAL_STATE_VERSION,
|
||||
goalRequiresExactPermit,
|
||||
type GoalActivity,
|
||||
type GoalControlRequest,
|
||||
type GoalExpectedVersion,
|
||||
type GoalRecord,
|
||||
type GoalSnapshotV2,
|
||||
type GoalStateCause,
|
||||
type GoalStateRecordPayloadV2,
|
||||
type GoalStateResponse,
|
||||
type GoalStatus,
|
||||
type GoalTerminalProposal,
|
||||
type GoalTurnPermit,
|
||||
type TranscriptCursor,
|
||||
} from './goal-protocol.js';
|
||||
export {
|
||||
parseGoalSnapshotV2,
|
||||
parseGoalStateRecordPayloadV2,
|
||||
} from './goal-reducer.js';
|
||||
export {
|
||||
projectGoalStateToLegacy,
|
||||
type LegacyActiveGoal,
|
||||
type LegacyGoalProjection,
|
||||
type LegacyGoalStatus,
|
||||
type LegacyGoalStatusKind,
|
||||
type LegacyGoalTerminal,
|
||||
} from './goal-legacy-projection.js';
|
||||
|
|
@ -35,3 +35,27 @@ export {
|
|||
} from './goalHook.js';
|
||||
export { judgeGoal } from './goalJudge.js';
|
||||
export type { GoalJudgeOutcome, JudgeResult } from './goalJudge.js';
|
||||
export * from './goal-protocol.js';
|
||||
export {
|
||||
GoalConflictError,
|
||||
GoalInvalidTransitionError,
|
||||
elapsedActiveTime,
|
||||
parseGoalControlRequest,
|
||||
parseGoalSnapshotV2,
|
||||
parseGoalStateRecordPayloadV2,
|
||||
reduceGoalControl,
|
||||
reduceGoalTurnFinished,
|
||||
} from './goal-reducer.js';
|
||||
export type {
|
||||
GoalControlTransition,
|
||||
GoalTurnFinishedTransition,
|
||||
} from './goal-reducer.js';
|
||||
export * from './goal-persistence.js';
|
||||
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';
|
||||
export type {
|
||||
LegacyActiveGoal,
|
||||
LegacyGoalProjection,
|
||||
LegacyGoalStatus,
|
||||
LegacyGoalStatusKind,
|
||||
LegacyGoalTerminal,
|
||||
} from './goal-legacy-projection.js';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue