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:
@@ -186,8 +186,11 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
|
||||
async whenIdle(): Promise<void> {
|
||||
// `done` is replaced per activity, so re-reading it follows chained turns;
|
||||
// a run failure still counts as quiescence for the waiter.
|
||||
// `done` is replaced per activity, so re-reading it follows chained turns.
|
||||
// Every driver failure today is contained before it can reject `done`,
|
||||
// but the waiter must not gamble quiescence on that: a future escape
|
||||
// still counts as settled activity.
|
||||
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
|
||||
while (this.abort !== undefined || this.queued.some(item => item.wakeup)) {
|
||||
await this.done.catch(() => undefined)
|
||||
}
|
||||
@@ -357,9 +360,18 @@ export class ReactLoopAgent implements Agent {
|
||||
if (!this.drainOutbox(turn)) break
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (this.stepOpen) {
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
try {
|
||||
if (this.stepOpen) {
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
} catch (closeError: unknown) {
|
||||
// Contained like the finally's turn close: a persistently rejecting
|
||||
// step boundary must not escape run(), or the post-finally tail would
|
||||
// never publish the terminal status and observers would see a
|
||||
// permanently running agent whose whenIdle() already resolved.
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
|
||||
}
|
||||
({ reason, idle } = this.settle(turn, step, caught, signal))
|
||||
} finally {
|
||||
|
||||
@@ -390,6 +390,29 @@ describe('post-turn continuation edges', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistent step-close rejection', () => {
|
||||
it('still publishes the terminal status when both step-close attempts are vetoed', async () => {
|
||||
const adapter = new MockAdapter([textResponse('will not close')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('stepend-double-veto'), { provider: 'mock', model: 'mock' })
|
||||
// Persistently reject step/end: the catch's own close attempt fails too,
|
||||
// and the contained failure must not strand status at running.
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/end') throw new Error('step close permanently rejected')
|
||||
})
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool result meta persistence', () => {
|
||||
it('records a presentationMeta payload on the tool/result event', async () => {
|
||||
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
||||
@@ -504,13 +527,12 @@ describe('driver bookkeeping edges', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
|
||||
// A persistent step/end veto escapes even the catch block's own close
|
||||
// attempt, so the driver promise REJECTS; the waiter's catch arm must
|
||||
// treat that rejection as quiescence instead of propagating it.
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/end') throw new Error('step close permanently rejected')
|
||||
// A throwing terminal-notification listener rejects the driver promise
|
||||
// (the run's containment covers only session appends); the waiter's
|
||||
// catch arm must treat that rejection as quiescence instead of
|
||||
// propagating it.
|
||||
ctx.on('agent/idle', (subject) => {
|
||||
if (subject === agent) throw new Error('idle listener exploded')
|
||||
})
|
||||
|
||||
send(agent, 'one')
|
||||
|
||||
Reference in New Issue
Block a user