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

@@ -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')