fix(goal): simplify blockers into one durable phase

This commit is contained in:
Tianyi Cui
2026-07-20 16:39:40 +08:00
parent 6e876e497a
commit 1468773dcd
18 changed files with 219 additions and 286 deletions

View File

@@ -254,10 +254,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'goals',
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
methods: [
{
signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec',
jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */',
},
{
signature: 'get(agent: Agent): GoalView | undefined',
jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */',
@@ -283,16 +279,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
},
{
signature: 'block(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */',
},
{
signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */',
},
{
signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView',
jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */',
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
},
{
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
@@ -1137,10 +1125,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CreateGoalRequest',
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
},
{
name: 'CreateGoalSpec',
declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
@@ -1241,13 +1225,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'GoalActivation',
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
},
{
name: 'GoalBlockReason',
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
},
{
name: 'GoalId',
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
},
{
name: 'GoalPhase',
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';',
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
},
{
name: 'GoalRef',
@@ -1255,7 +1243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'GoalSnapshot',
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}',
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
},
{
name: 'GoalView',

View File

@@ -11,13 +11,13 @@ Event-sourced same-session goal state. The service retains one current completio
defaultMaxGoalRounds: 256
```
`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it.
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
## Service contract
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md).
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb.
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation.
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
@@ -35,7 +35,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
#### What the model sees
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log.
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
#### Token effect

View File

@@ -6,6 +6,7 @@ import { renderGoalChange } from './render.ts'
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
import type {
FoldedGoal,
GoalBlockReason,
GoalChangeMeta,
GoalClearChangeMeta,
GoalMessageSource,
@@ -25,17 +26,8 @@ const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Se
'resume',
'complete',
'block',
'mark-usage-limited',
'mark-budget-limited',
])
const PHASES: ReadonlySet<GoalPhase> = new Set([
'active',
'paused',
'blocked',
'usage-limited',
'budget-limited',
'complete',
])
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
/** Mutable accumulator kept private to the pure fold. */
export interface GoalFoldState {
@@ -83,13 +75,24 @@ function nonNegativeInteger(value: unknown, field: string): number {
return value
}
/** Decode one canonical blocker explanation. */
function decodeBlockReason(value: unknown): GoalBlockReason {
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
throw new Error('goal change goal.blockedReason has an invalid shape')
}
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
}
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|| value['message'] !== value['message'].trim()) {
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
}
return { code: value['code'], message: value['message'] }
}
/** Decode and validate one snapshot. */
function decodeSnapshot(value: unknown): GoalSnapshot {
if (!isRecord(value)) throw new Error('goal change goal must be a record')
const keys = Object.keys(value).sort()
if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') {
throw new Error('goal change goal has an invalid shape')
}
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
throw new Error('goal change goal.id must be a non-empty string')
}
@@ -100,12 +103,20 @@ function decodeSnapshot(value: unknown): GoalSnapshot {
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
throw new Error('goal change goal.phase is invalid')
}
const phase = value['phase'] as GoalPhase
const expectedKeys = phase === 'blocked'
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
: 'id,maxGoalRounds,objective,phase,revision'
if (Object.keys(value).sort().join(',') !== expectedKeys) {
throw new Error('goal change goal has an invalid shape')
}
return {
id: GoalId(value['id']),
revision: positiveInteger(value['revision'], 'goal.revision'),
objective: value['objective'],
phase: value['phase'] as GoalPhase,
phase,
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
}
}
@@ -208,7 +219,10 @@ function validateSnapshotTransition(
}
switch (change.operation) {
case 'edit':
if (next.phase !== current.phase) throw new Error('goal edit cannot change phase')
if (next.phase !== current.phase
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
throw new Error('goal edit cannot change phase or blocked reason')
}
break
case 'pause':
requireSameDefinition(current, next, change.operation)
@@ -220,8 +234,6 @@ function validateSnapshotTransition(
'active',
'paused',
'blocked',
'usage-limited',
'budget-limited',
])
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
@@ -236,19 +248,6 @@ function validateSnapshotTransition(
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
break
case 'mark-usage-limited':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'usage-limited') {
throw new Error('goal mark-usage-limited has an invalid phase transition')
}
break
case 'mark-budget-limited':
requireSameDefinition(current, next, change.operation)
if (current.phase !== 'active' || next.phase !== 'budget-limited'
|| state.roundsStarted < next.maxGoalRounds) {
throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget')
}
break
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
case 'create':
throw new Error('goal create cannot be validated as a current-goal transition')

View File

@@ -27,9 +27,9 @@ import {
} from './runtime.ts'
import type {
CreateGoalRequest,
CreateGoalSpec,
EditGoalRequest,
GoalActivation,
GoalBlockReason,
GoalChangeMeta,
GoalChanged,
GoalClearChangeMeta,
@@ -79,6 +79,12 @@ interface GoalCache {
readonly pending: PendingGoalChange[]
}
/** Validated create input with every deployment default materialized. */
interface ResolvedCreateGoal {
readonly objective: string
readonly maxGoalRounds: number
}
/** Validate a caller-visible positive safe-integer round cap. */
function resolveMaxGoalRounds(value: number): number {
if (!Number.isSafeInteger(value) || value < 1) {
@@ -95,6 +101,31 @@ function resolveObjective(value: string): string {
return value.trim()
}
/** Materialize deployment defaults and validate one create request. */
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
return {
objective: resolveObjective(request.objective),
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
}
}
/** Validate and detach one policy-owned blocker explanation. */
function resolveBlockReason(reason: unknown): GoalBlockReason {
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
? reason as Record<string, unknown>
: undefined
const code = record?.['code']
const message = record?.['message']
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|| typeof message !== 'string' || message.trim().length === 0) {
throw new GoalError(
'goal block reason requires a lower-kebab-case code and a non-empty message',
'GOAL_INVALID_BLOCK_REASON',
)
}
return { code, message: message.trim() }
}
/** Compare the complete canonical payloads used for deferred reconciliation. */
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
return JSON.stringify(left) === JSON.stringify(right)
@@ -121,18 +152,6 @@ export class GoalService extends Service {
})
}
/**
* Materialize deployment defaults and validate one create request.
* @param request - objective plus optional caller-selected round cap.
* @returns detached, fully resolved create specification.
*/
resolveCreate(request: CreateGoalRequest): CreateGoalSpec {
return {
objective: resolveObjective(request.objective),
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds),
}
}
/**
* Read the current goal for one exact live agent.
* @param agent - owning live agent.
@@ -154,7 +173,7 @@ export class GoalService extends Service {
* @returns the created live view.
*/
create(agent: Agent, request: CreateGoalRequest): GoalView {
const spec = this.resolveCreate(request)
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
const cache = this.prepareMutation(agent)
const current = cache.state.goal
if (current !== undefined && current.phase !== 'complete') {
@@ -213,7 +232,7 @@ export class GoalService extends Service {
resume(agent: Agent, ref: GoalRef): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited']
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
if (!resumable.includes(current.phase)) {
throw this.transitionError(current, 'resume', resumable)
}
@@ -240,7 +259,7 @@ export class GoalService extends Service {
agent,
ref,
'complete',
['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'],
['active', 'paused', 'blocked'],
'complete',
'disarmed',
)
@@ -250,45 +269,20 @@ export class GoalService extends Service {
* Mark an active goal blocked and disarm it.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the blocked view.
* @param reason - policy-owned stable code and human-readable explanation.
* @returns the blocked view with its durable reason.
*/
block(agent: Agent, ref: GoalRef): GoalView {
return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed')
}
/**
* Mark an active goal stopped by an external usage limit.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the usage-limited view.
*/
markUsageLimited(agent: Agent, ref: GoalRef): GoalView {
return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed')
}
/**
* Mark an active goal stopped at its configured round cap.
* @param agent - owning live agent.
* @param ref - expected current revision.
* @returns the budget-limited view.
*/
markBudgetLimited(agent: Agent, ref: GoalRef): GoalView {
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
const cache = this.prepareMutation(agent)
const current = this.expectCurrent(cache, ref)
if (current.phase !== 'active') {
throw this.transitionError(current, 'mark-budget-limited', ['active'])
}
if (cache.state.roundsStarted < current.maxGoalRounds) {
throw new GoalError(
`goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`,
'GOAL_INVALID_TRANSITION',
)
throw this.transitionError(current, 'block', ['active'])
}
return this.commitCurrent(
agent,
cache,
'mark-budget-limited',
this.withPhase(current, 'budget-limited'),
'block',
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
'disarmed',
)
}
@@ -384,7 +378,13 @@ export class GoalService extends Service {
/** Build a new revision with one replacement phase. */
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
return { ...current, revision: current.revision + 1, phase }
return {
id: current.id,
revision: current.revision + 1,
objective: current.objective,
phase,
maxGoalRounds: current.maxGoalRounds,
}
}
/** Shared validated phase transition. */

View File

@@ -22,16 +22,24 @@ export type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'usage-limited'
| 'budget-limited'
| 'complete'
/** Machine-routable and human-readable explanation for a blocked goal. */
export interface GoalBlockReason {
/** Stable lower-kebab-case classification chosen by the blocking policy. */
readonly code: string
/** Non-empty explanation shown to humans and models. */
readonly message: string
}
/** Full durable state written by every non-clear goal mutation. */
export interface GoalSnapshot extends GoalRef {
/** Human-requested completion objective. */
readonly objective: string
/** Durable lifecycle phase. */
readonly phase: GoalPhase
/** Present exactly while `phase` is `blocked`. */
readonly blockedReason?: GoalBlockReason
/** Total admitted goal-round cap. */
readonly maxGoalRounds: number
}
@@ -59,8 +67,6 @@ export type GoalOperation =
| 'resume'
| 'complete'
| 'block'
| 'mark-usage-limited'
| 'mark-budget-limited'
| 'clear'
/** Full-snapshot goal mutation retained in a model-visible context event. */
@@ -121,12 +127,6 @@ export interface CreateGoalRequest {
readonly maxGoalRounds?: number
}
/** Validated create input with every deployment default materialized. */
export interface CreateGoalSpec {
readonly objective: string
readonly maxGoalRounds: number
}
/** Fields changed by an edit; at least one must be present. */
export interface EditGoalRequest {
readonly objective?: string
@@ -149,6 +149,7 @@ export type GoalErrorCode =
| 'GOAL_STALE_REVISION'
| 'GOAL_INVALID_OBJECTIVE'
| 'GOAL_INVALID_MAX_ROUNDS'
| 'GOAL_INVALID_BLOCK_REASON'
| 'GOAL_INVALID_EDIT'
| 'GOAL_INVALID_TRANSITION'

View File

@@ -111,17 +111,13 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
}
describe('GoalService creation and replay', () => {
it('resolves the configured default and writes one balanced raw context snapshot', async () => {
it('applies the configured default and writes one balanced raw context snapshot', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({
objective: 'finish the feature',
maxGoalRounds: 17,
})
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
expect(goal).toMatchObject({
@@ -151,18 +147,19 @@ describe('GoalService creation and replay', () => {
vi.useRealTimers()
})
it('uses 256 rounds by default and validates create input at the owning resolver', async () => {
it('uses 256 rounds by default and validates create input inside create', async () => {
const { ctx, agent } = await harness()
expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 })
expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_OBJECTIVE',
}))
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError)
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
expect(() => ctx.goals.create(agent, {
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
})).toThrow(GoalError)
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
})
@@ -170,9 +167,10 @@ describe('GoalService creation and replay', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const goals = new GoalService(ctx)
expect(goals.resolveCreate({ objective: 'direct' })).toEqual({
objective: 'direct',
maxGoalRounds: 256,
const stub = stubAgent('goal-direct-construction')
ctx.agents.register(stub.agent)
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
objective: 'direct', maxGoalRounds: 256,
})
})
@@ -287,18 +285,19 @@ describe('GoalService mutations', () => {
}))
})
it('supports pause, resume, block, usage-limit, and completion transitions', async () => {
it('supports pause, resume, block, and completion transitions', async () => {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
goal = ctx.goals.pause(agent, goal)
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
goal = ctx.goals.block(agent, goal)
expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' })
goal = ctx.goals.resume(agent, goal)
goal = ctx.goals.markUsageLimited(agent, goal)
expect(goal.phase).toBe('usage-limited')
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
activation: 'disarmed',
})
goal = ctx.goals.resume(agent, goal)
goal = ctx.goals.pause(agent, goal)
goal = ctx.goals.complete(agent, goal)
@@ -307,15 +306,13 @@ describe('GoalService mutations', () => {
})
it('allows completion from every stopped phase and replacement only after completion', async () => {
const phases = ['paused', 'blocked', 'usage-limited'] as const
const phases = ['paused', 'blocked'] as const
for (const phase of phases) {
const { ctx, agent } = await harness()
let goal = ctx.goals.create(agent, { objective: phase })
goal = phase === 'paused'
? ctx.goals.pause(agent, goal)
: phase === 'blocked'
? ctx.goals.block(agent, goal)
: ctx.goals.markUsageLimited(agent, goal)
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
const complete = ctx.goals.complete(agent, goal)
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
expect(complete.phase).toBe('complete')
@@ -333,32 +330,45 @@ describe('GoalService mutations', () => {
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
const paused = ctx.goals.pause(agent, goal)
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({
expect(() => ctx.goals.block(agent, paused, {
code: 'test-blocker', message: 'Blocked for the test.',
})).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
})
it('enforces the goal-round cap before budget limiting and resuming', async () => {
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_BLOCK_REASON',
}))
}
expect(() => ctx.goals.block(agent, goal, {
code: 'Not Canonical', message: 'invalid code',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
expect(() => ctx.goals.block(agent, goal, {
code: 'round-limit', message: ' ',
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
appendRound(session, goal, 1)
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({
code: 'GOAL_INVALID_TRANSITION',
}))
appendRound(session, goal, 2)
goal = ctx.goals.markBudgetLimited(agent, goal)
expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' })
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
expect(goal).toMatchObject({
phase: 'blocked',
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
roundsStarted: 2,
activation: 'disarmed',
})
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
expect(goal.blockedReason).toBeUndefined()
appendRound(session, goal, 3)
goal = ctx.goals.markBudgetLimited(agent, goal)
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
})
@@ -598,7 +608,16 @@ describe('goal replay validation', () => {
return {
...current,
operation,
goal: { ...current.goal, revision: current.goal.revision + 1, phase },
goal: {
id: current.goal.id,
revision: current.goal.revision + 1,
objective: current.goal.objective,
phase,
...phase === 'blocked'
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
: {},
maxGoalRounds: current.goal.maxGoalRounds,
},
updatedAt: current.updatedAt + 1,
...overrides,
}
@@ -694,9 +713,6 @@ describe('goal replay validation', () => {
mutation(base, 'resume', 'paused'),
mutation(base, 'complete', 'active'),
mutation(base, 'block', 'active'),
mutation(base, 'mark-usage-limited', 'active'),
mutation(base, 'mark-budget-limited', 'active'),
mutation(base, 'mark-budget-limited', 'budget-limited'),
]
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
@@ -782,6 +798,12 @@ describe('goal replay validation', () => {
{ ...base.goal, objective: ' ' },
{ ...base.goal, objective: ' padded ' },
{ ...base.goal, phase: 'unknown' },
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
{ ...base.goal, phase: 'blocked' },
{ ...base.goal, phase: 'blocked', blockedReason: null },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
{ ...base.goal, revision: 0 },
{ ...base.goal, maxGoalRounds: -1 },
]