refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user