refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -52,6 +52,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
steer: () => AgentMessageId('stub'),
inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') },
cancel() { status = 'idle' },
retry() {},
whenIdle() { return Promise.resolve() },
}
return { agent, session }

View File

@@ -360,15 +360,6 @@ export function apply(ctx: Context): void {
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
}
return
case 'prompt/blocked':
if (state.attempt !== undefined && state.attempt.phase === 'queued'
&& isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) {
/* v8 ignore next -- this driver's rejected message always follows its observed turn/start */
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
state.attempt.rejectedReason = event.data.reason
if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true
}
return
case 'turn/end':
if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason
/* v8 ignore next -- balanced live turns close the open turn just observed by this listener */
@@ -407,11 +398,27 @@ export function apply(ctx: Context): void {
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
}
const decision = await next()
if (decision.kind === 'block') return decision
if (decision.kind === 'block') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
const goal = currentGoal(state)
if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
ctx.goals.block(agent, goalRef(goal), {
code: 'prompt-rejected',
message: decision.reason,
})
}
return decision
}
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
@@ -421,7 +428,11 @@ export function apply(ctx: Context): void {
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
}
return decision

View File

@@ -266,8 +266,7 @@ describe('same-session goal driving', () => {
expect(goal?.roundsStarted).toBe(0)
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'deployment policy')).toBe(true)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
@@ -391,9 +390,6 @@ describe('same-session goal driving', () => {
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 })
const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked')
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
.toBe('stale goal-round reservation')
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'goal' && event.data.source.round > 0)
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
@@ -419,8 +415,6 @@ describe('same-session goal driving', () => {
expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 })
expect(test.adapter.requests).toHaveLength(1)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
})
it('disarms without dispatch when a durability checkpoint fails', async () => {
@@ -447,38 +441,6 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => {
const test = await harness([textResponse('not durable')])
let injected = false
test.ctx.on('session/flush', (session) => {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message'
&& lastStart.data.trigger.source.kind === 'goal' && !injected) {
injected = true
test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], {
source: { kind: 'plugin', plugin: 'test' },
})
return Promise.reject(new Error('round flush failed'))
}
})
test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' })
const goal = await waitForGoal(
test.ctx,
test.agent,
current => current?.roundsStarted === 1 && current.activation === 'disarmed',
)
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(1)
const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
const goalTurn = turns.findIndex(event => event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'goal')
const injectedTurn = turns.findIndex(event => event.data.trigger.kind === 'injection'
&& event.data.trigger.source.kind === 'plugin')
expect(injectedTurn).toBeGreaterThan(goalTurn)
})
it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
const test = await harness([])
// Reject only the goal-sourced round follow-up, not the state-change injection
@@ -520,25 +482,6 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('contains a driver read failure and removes continuation authority', async () => {
const test = await harness([])
let flushes = 0
test.ctx.on('session/flush', () => {
flushes += 1
if (flushes !== 2) return
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('corrupt projection')
})
})
test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' })
await new Promise<void>((resolve) => { setImmediate(resolve) })
const goal = test.ctx.goals.get(test.agent)
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(0)
})
it('contains synchronous scheduler startup failure', async () => {
const test = await harness([])
vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => {
@@ -583,8 +526,6 @@ describe('same-session goal driving', () => {
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
expect(test.adapter.requests).toHaveLength(1)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
})
it('fails a post-hook read closed before the prompt can enter history', async () => {
@@ -615,8 +556,7 @@ describe('same-session goal driving', () => {
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not invent goal state when ordinary queued work is cancelled', async () => {
@@ -715,9 +655,9 @@ describe('same-session goal driving', () => {
expect(test.ctx.goals.get(test.agent)).toMatchObject({
phase: 'active',
activation: 'disarmed',
roundsStarted: 1,
roundsStarted: 0,
})
expect(test.adapter.requests).toHaveLength(1)
expect(test.adapter.requests).toHaveLength(0)
})
it('resets process-local scheduling state at a session-start edge', async () => {

View File

@@ -72,6 +72,7 @@ function stubAgentForSession(session: Session): StubAgent {
return AgentMessageId('stub')
},
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
return {
@@ -276,11 +277,6 @@ describe('GoalService creation and replay', () => {
}))
})
it('rejects a disposed live object even before registry teardown', async () => {
const test = await harness()
test.setStatus('disposed')
expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
})
})
describe('GoalService mutations', () => {

View File

@@ -43,6 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
return AgentMessageId('stub')
},
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }
@@ -336,7 +337,6 @@ describe('goal tool state transitions', () => {
goal_id: goal['id'], revision: goal['revision'], action: 'resume',
}, root.agent))
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1, testToolSignal)).toBeUndefined()
})
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
@@ -347,21 +347,20 @@ describe('goal tool state transitions', () => {
goal_id: created.id, revision: created.revision, action: 'pause',
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn, testToolSignal)).toBeUndefined()
expect(paused.concludesTurn).toBeUndefined()
const resumed = resultGoal(await execute(ctx, 'update_goal', {
goal_id: created.id, revision: 2, action: 'resume',
}, root.agent))
closeTurn(root, humanTurn)
const roundTurn = openTurn(root, {
openTurn(root, {
kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1,
})
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: resumed['revision'], action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toEqual({ action: 'stop' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toBeUndefined()
expect(complete.concludesTurn).toBe(true)
})
it('rearms a restored active goal only after a new direct human prompt', async () => {