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

@@ -62,6 +62,14 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (baselineLoaded.has(agent.session)) return
// The guard is mount-local, but the baseline is durable: a hot remount
// over a live session must fold the already-appended baseline from the
// log instead of injecting a duplicate.
if (agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) {
baselineLoaded.add(agent.session)
return
}
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
return

View File

@@ -979,6 +979,32 @@ describe('workspace context request injection', () => {
}
})
it('folds an already-appended baseline from the log after a plugin remount', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: '/' })
const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
// Hot remount over the live session: the durable baseline survived, so
// the fresh mount must fold it from the log instead of appending a
// duplicate on its next step.
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('tracks only baseline files that were actually included under the byte budget', async () => {
const root = await tempRepo()
const home = await tempRepo()