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

@@ -1923,6 +1923,52 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('leak-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
appendUser(source, 'source background')
},
})
const send = async (): Promise<void> => {
result.terminal.send('@leak-source')
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · leak-source') })
result.terminal.send('\t')
await tick()
result.terminal.send('\r')
}
await send()
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
await send()
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
// Both wrappers released on the allowed path: a discard for either prompt
// finds no armed listener, and an unrelated admission is untouched. The
// leak regression: a listener installed after its cleanup already ran
// would survive every future cleanup.
result.ctx.emit('agent/inbox/discard', result.agent, [{
id: AgentMessageId('stub'), content: result.agent.sent[0]!, source: { kind: 'user' },
}])
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
// Replaying either sent prompt attaches nothing: the one-shot wrappers
// are gone, not merely spent.
for (const sent of result.agent.sent) {
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent, { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
}
await dispose(result)
})
it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
const result = await setup({
async configureContext(ctx) {