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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user