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:
@@ -2802,16 +2802,14 @@ export function createTuiChat(
|
||||
// prompt and its attached context together instead of stranding the
|
||||
// snapshot in history for the next unrelated prompt.
|
||||
let cleanedUp = false
|
||||
// Assigned after followup(); cleanup() can run earlier from the catch.
|
||||
let detachDiscard: (() => void) | undefined = undefined
|
||||
const cleanup = (): void => {
|
||||
// Both triggers detach themselves, so a second call needs a future
|
||||
// third trigger; kept so adding one cannot double-release.
|
||||
// Each trigger detaches both listeners, so a second call needs a
|
||||
// future third trigger; kept so adding one cannot double-release.
|
||||
/* v8 ignore next -- unreachable idempotence guard, see above */
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
detachSubmit()
|
||||
detachDiscard?.()
|
||||
detachDiscard()
|
||||
}
|
||||
// Prepended so this wrapper is outermost: it observes the admission
|
||||
// whether a downstream hook allows or blocks, and detaches either way.
|
||||
@@ -2822,7 +2820,14 @@ export function createTuiChat(
|
||||
if (decision.kind !== 'allow') return decision
|
||||
return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] }
|
||||
}, { prepend: true })
|
||||
let id: AgentMessageId
|
||||
// Installed BEFORE followup(): admission runs synchronously inside it on
|
||||
// the common path, and a listener registered after cleanup() already ran
|
||||
// would never be released. The id lands before any discard can name it —
|
||||
// discard is only ever emitted by a later cancel().
|
||||
let id: AgentMessageId | undefined
|
||||
const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject === agent && messages.some(message => message.id === id)) cleanup()
|
||||
})
|
||||
// followup() accepts any typed input and contains listener failures;
|
||||
// this guards a future synchronous throw so the wrapper cannot leak.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
@@ -2833,10 +2838,6 @@ export function createTuiChat(
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
// A prompt discarded before admission (broad cancel) releases the wrapper.
|
||||
detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject === agent && messages.some(message => message.id === id)) cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user