fix: close five composition seams found in review round five

goal-session rides retry turns and survives admission failures. A
recovery policy closes a goal round's failed turn and reopens its
history under a retry trigger; the attempt now adopts that turn and
drops the failed turn's provisional reason, so the round settles from
the retry's own outcome instead of blocking an armed goal with
turn-error after a successful response. A downstream admission hook
that throws (rather than blocks) used to strand the queued reservation
forever; the listener now clears a still-turnless matching attempt on
the rejection path and reschedules the round.

agent-loop contains a persistently rejecting step close in the catch
path the same way the finally contains the turn close, so the
post-finally tail always publishes the terminal status — previously a
double veto escaped run(), leaving status at running while whenIdle()
resolved. The whenIdle catch arm is annotated as the backstop it now
is: every driver rejection path is contained today.

workspace-context folds an already-appended baseline from the session
log when the plugin is hot-remounted over a live session, instead of
injecting a duplicate from its fresh mount-local guard.

The TUI's reference-admission discard listener installs before
followup(): admission runs synchronously inside it on the common path,
so a listener installed afterwards missed its own cleanup and leaked
one callback per referenced prompt.
This commit is contained in:
_Kerman
2026-07-26 23:03:32 +08:00
parent 344943e097
commit 0dc5d07ae7
8 changed files with 258 additions and 23 deletions

View File

@@ -358,6 +358,17 @@ export function apply(ctx: Context): void {
state.attempt.turn = event.data.turn
}
return
case 'retry':
// A recovery policy (llm-retry) closed the round's failed turn
// and reopened its history: the attempt rides the retry turn,
// and the failed turn's provisional reason no longer settles
// the round — the retry's own outcome does.
if (state.attempt !== undefined && state.attempt.reason !== undefined
&& state.attempt.reason.kind === 'error') {
state.attempt.turn = event.data.turn
state.attempt.reason = undefined
}
return
default:
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
return
@@ -415,7 +426,21 @@ export function apply(ctx: Context): void {
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
}
const decision = await next()
let decision: PromptDecision
try {
decision = await next()
} catch (error: unknown) {
// A throwing downstream hook drops the whole admission: the loop
// returns to idle without a turn, so a still-queued reservation would
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.turn === undefined) {
state.attempt = undefined
requestDrive(state)
}
throw error
}
if (decision.kind === 'block') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined

View File

@@ -479,6 +479,101 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('settles a goal round from its successful retry turn, not the failed original', async () => {
const test = await harness([
new LlmError('transient', 'SERVER'),
textResponse('retry succeeded'),
])
// The llm-retry shape: schedule one retry for the failed goal-round request.
let retried = false
test.ctx.on('agent/request-error', async (subject) => {
if (!retried) {
retried = true
subject.retry()
}
})
test.ctx.goals.create(test.agent, { objective: 'survive a transient failure', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
// The retry turn's completed outcome settles the round: round-limit, not
// the failed original turn's turn-error.
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(goal?.roundsStarted).toBe(1)
expect(test.adapter.requests).toHaveLength(2)
})
it('does not double-clear when a throwing hook already cancelled the round', async () => {
const test = await harness([])
// The downstream hook cancels (pausing the goal and clearing the queued
// attempt through cancel-requested) and THEN throws: the catch finds no
// matching reservation and must not reschedule a paused goal.
let fired = false
test.ctx.on('agent/prompt-submit', async (agent, _content, source, _signal, next) => {
if (source.kind === 'goal' && !fired) {
fired = true
agent.cancel({ kind: 'user' })
throw new Error('hook cancelled then exploded')
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'cancel then throw' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
await test.agent.whenIdle()
await new Promise((resolve) => { setImmediate(resolve) })
expect(goal?.roundsStarted).toBe(0)
expect(test.adapter.requests).toHaveLength(0)
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused' })
})
it('reschedules the round when a downstream admission hook throws', async () => {
const test = await harness([textResponse('second admission succeeded')])
// Registered after goal-session's own listener: the throw propagates back
// through goal-session's next() await, dropping the whole admission.
let threw = false
test.ctx.on('agent/prompt-submit', async (_agent, _content, source, _signal, next) => {
if (source.kind === 'goal' && !threw) {
threw = true
throw new Error('downstream admission hook exploded')
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'survive a throwing hook', maxGoalRounds: 1 })
// The cleared reservation lets the driver reschedule; the second
// admission passes and the round completes to its limit.
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(goal?.roundsStarted).toBe(1)
expect(test.adapter.requests).toHaveLength(1)
})
it('a retry turn on a non-goal failure leaves the goal reservation untouched', async () => {
const test = await harness([
new LlmError('transient on human turn', 'SERVER'),
textResponse('human retry succeeded'),
textResponse('goal round ran'),
])
let retried = false
test.ctx.on('agent/request-error', async (subject) => {
if (!retried) {
retried = true
subject.retry()
}
})
// A human prompt fails and retries while a goal is armed but its round
// is not yet reserved: the retry trigger must not adopt or clear
// anything (the attempt is absent), and the goal proceeds normally.
test.ctx.goals.create(test.agent, { objective: 'ignore foreign retries', maxGoalRounds: 1 })
test.agent.followup({ content: [{ type: 'text', text: 'human work' }], source: { kind: 'user' } })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.blockedReason?.code).toBe('round-limit')
expect(goal?.roundsStarted).toBe(1)
})
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