diff --git a/.changeset/backlog-turn-signals.md b/.changeset/backlog-turn-signals.md new file mode 100644 index 00000000..3440dbbb --- /dev/null +++ b/.changeset/backlog-turn-signals.md @@ -0,0 +1,5 @@ +--- +"@gemstack/framework": patch +--- + +Fix backlog turns dropping the agent's signals. The backlog loop prompts through the run's own driver session, so a backlog turn carries the same signal protocol as any other turn, but it only ever parsed await gates. `showMarkdown()` views never reached the dashboard rail, `setSessionName()` was ignored, and `setReadyForMerge()` never emitted `ready-for-merge`, so `--post-merge` could not fire from backlog work. The turn-signal parsing (views, session name, ready-for-merge) was duplicated verbatim in the build path and the direct prompt path and missing from the third; it is now one `createTurnSignalEmitter` used by all three, with the backlog loop sharing a single emitter so `ready-for-merge` still fires once across every item. diff --git a/packages/framework/src/prompt-run.ts b/packages/framework/src/prompt-run.ts index 4bcc133e..b732940f 100644 --- a/packages/framework/src/prompt-run.ts +++ b/packages/framework/src/prompt-run.ts @@ -2,7 +2,7 @@ import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { hasSessionIdPlaceholder, resolveSessionLink, type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js' import { resolveAwaitGate } from './run.js' import { composeRunSystem, renderSystemPrompt, type EcoOptions, type TfContext } from './system-prompt.js' -import { PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews, parseSessionName, parseReadyForMerge } from './turn-gate.js' +import { PLAN_DECLINED_MESSAGE, createTurnSignalEmitter, isDeclinedConfirmation, parseAwaitGate } from './turn-gate.js' import { UsageMeter } from './usage.js' import { CONSUMPTION_LIMIT_LABEL, type ConsumptionWindow } from './consumption.js' import { leaveResumeNote } from './todo-loop.js' @@ -166,20 +166,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise { - for (const view of parseMarkdownViews(text)) emit({ kind: 'view', ...view }) - const name = parseSessionName(text) - if (name && name !== named) { - named = name - emit({ kind: 'session-name', name }) - } - if (!ready && parseReadyForMerge(text)) { - ready = true - emit({ kind: 'ready-for-merge' }) - } - } + const emitTurnSignals = createTurnSignalEmitter(emit) try { let turn = await session.prompt(firstPrompt, { signal: runSignal }) diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 07c7b347..2a03c6a4 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -25,7 +25,7 @@ import { snapshotWorkspace } from './sandbox.js' import { CONSUMPTION_LIMIT_LABEL, type ConsumptionWindow } from './consumption.js' import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { composeRunSystem, type EcoOptions, type TfContext } from './system-prompt.js' -import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews, parseSessionName, parseReadyForMerge, type ParsedAwaitGate } from './turn-gate.js' +import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, createTurnSignalEmitter, isDeclinedConfirmation, parseAwaitGate, type ParsedAwaitGate } from './turn-gate.js' // Value import from todo-loop.js is a benign cycle: todo-loop.js only calls // run.js's hoisted function declarations (requestChoices / resolveAwaitGate). import { leaveResumeNote, runTodoLoop, type TodoLoopResult } from './todo-loop.js' @@ -572,20 +572,7 @@ function agentAwaitGate( // Non-blocking signals the agent emitted this turn: markdown views (#441) pushed to the // rail, and the #326 lifecycle signals (session name, ready-for-merge) that flip the run's // dashboard status. None stop the turn. - let named: string | undefined - let ready = false - const emitTurnSignals = (text: string): void => { - for (const view of parseMarkdownViews(text)) emit({ kind: 'view', ...view }) - const name = parseSessionName(text) - if (name && name !== named) { - named = name - emit({ kind: 'session-name', name }) - } - if (!ready && parseReadyForMerge(text)) { - ready = true - emit({ kind: 'ready-for-merge' }) - } - } + const emitTurnSignals = createTurnSignalEmitter(emit) let run = await base(ctx) emitTurnSignals(run.text) if (!requestChoice) return run diff --git a/packages/framework/src/todo-loop.test.ts b/packages/framework/src/todo-loop.test.ts index 2aa70c07..8182fbc4 100644 --- a/packages/framework/src/todo-loop.test.ts +++ b/packages/framework/src/todo-loop.test.ts @@ -249,3 +249,63 @@ test('an aborted signal ends the loop before starting another entry', async () = await rm(cwd, { recursive: true, force: true }) } }) + +test('a backlog turn emits its signals: views, session name, ready-for-merge', async () => { + const cwd = await tmpWorkspace() + const file = join(cwd, 'TODO_feat-x.agent.md') + await writeFile(file, '- [ ] tidy the login redirect\n') + try { + const events: FrameworkEvent[] = [] + // The protocols are unconditional, so the agent is told it can signal on ANY turn, + // a backlog turn included. Everything it emits has to reach the run stream. + const driver = new FakeDriver({ + respond: () => { + writeFileSync(file, '- [x] tidy the login redirect\n') + return [ + 'Done.', + '```show-markdown', + '# What I changed', + 'Rewrote the redirect guard.', + '```', + '```set-session-name', + 'login-redirect-fix', + '```', + '```ready-for-merge', + '```', + ].join('\n') + }, + }) + const session = await driver.start({ cwd }) + await runTodoLoop({ session, cwd, emit: e => events.push(e) }) + + const view = events.find(e => e.kind === 'view') + assert.equal(view?.title, 'What I changed') + assert.equal(events.find(e => e.kind === 'session-name')?.name, 'login-redirect-fix') + assert.equal(events.filter(e => e.kind === 'ready-for-merge').length, 1) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) + +test('ready-for-merge is emitted once across a multi-item backlog', async () => { + const cwd = await tmpWorkspace() + const file = join(cwd, 'TODO_feat-x.agent.md') + await writeFile(file, '- [ ] first task\n- [ ] second task\n') + try { + const events: FrameworkEvent[] = [] + // Both items signal ready-for-merge; the loop's one emitter dedupes them. + const driver = new FakeDriver({ + respond: (_prompt, i) => { + writeFileSync(file, i === 0 ? '- [x] first task\n- [ ] second task\n' : '- [x] first task\n- [x] second task\n') + return 'Done.\n```ready-for-merge\n```' + }, + }) + const session = await driver.start({ cwd }) + const result = await runTodoLoop({ session, cwd, emit: e => events.push(e) }) + + assert.equal(result.completed, 2) + assert.equal(events.filter(e => e.kind === 'ready-for-merge').length, 1) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/framework/src/todo-loop.ts b/packages/framework/src/todo-loop.ts index 3f9ceb93..3cff48b6 100644 --- a/packages/framework/src/todo-loop.ts +++ b/packages/framework/src/todo-loop.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import type { DriverSession } from './driver/index.js' import type { ChoicePick, ChoiceRequest, FrameworkEvent } from './events.js' import { requestChoices, resolveAwaitGate } from './run.js' -import { PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate } from './turn-gate.js' +import { PLAN_DECLINED_MESSAGE, createTurnSignalEmitter, isDeclinedConfirmation, parseAwaitGate } from './turn-gate.js' /** * The backlog loop (#323): once the main work settles, consume the agent's own @@ -193,13 +193,21 @@ const MAX_STALLS = 2 * to complete exactly that entry and check it off, and repeat. Caps make it safe * to leave unattended (#322's concern): the run's budget/abort signal ends any * turn, a hard item cap bounds the run, and two consecutive items that leave the - * next entry untouched stop the loop instead of spinning. Await gates inside an - * item turn (`showChoices()` / `showMultiSelect()`) are honored like anywhere else. + * next entry untouched stop the loop instead of spinning. A backlog turn is a turn + * like any other: await gates (`showChoices()` / `showMultiSelect()`) and the signals + * (`showMarkdown()`, `setSessionName()`, `setReadyForMerge()`) are honored here too. */ export async function runTodoLoop(opts: TodoLoopOptions): Promise { const { session, cwd, emit } = opts const maxItems = opts.maxItems ?? DEFAULT_MAX_TODO_ITEMS - const gateDeps = { requestChoice: opts.requestChoice, emit, signal: opts.signal } + // One emitter for the whole backlog, so ready-for-merge fires once across every item + // and a session name only re-emits on an actual rename. + const gateDeps = { + requestChoice: opts.requestChoice, + emit, + signal: opts.signal, + emitTurnSignals: createTurnSignalEmitter(emit), + } let completed = 0 let stalls = 0 @@ -287,11 +295,13 @@ async function promptItem( requestChoice?: ((req: ChoiceRequest) => Promise) | undefined emit: (event: FrameworkEvent) => void signal?: AbortSignal | undefined + emitTurnSignals: (text: string) => void }, ): Promise { const signalOpt = deps.signal ? { signal: deps.signal } : {} const prompt = `Open \`${fileName}\` at the workspace root and work on the FIRST open entry only. Complete it fully and verify your work. Then update \`${fileName}\`: check the entry off (or remove it). Do not start any other entry.` let turn = await session.prompt(prompt, signalOpt) + deps.emitTurnSignals(turn.text) let gate = parseAwaitGate(turn.text) for (let round = 0; round < MAX_AWAIT_ROUNDS && gate; round++) { const answer = await resolveAwaitGate(gate, round, deps) @@ -305,6 +315,7 @@ async function promptItem( `You paused to ask: "${gate.title}". The user chose: ${answer}. Continue the backlog entry with that decision, and do not ask again unless a genuinely new choice comes up.`, signalOpt, ) + deps.emitTurnSignals(turn.text) gate = parseAwaitGate(turn.text) } } diff --git a/packages/framework/src/turn-gate.ts b/packages/framework/src/turn-gate.ts index 1bd3e42d..46e07b7a 100644 --- a/packages/framework/src/turn-gate.ts +++ b/packages/framework/src/turn-gate.ts @@ -1,3 +1,4 @@ +import type { FrameworkEvent } from './events.js' import { PROTOCOLS_AWAIT, PROTOCOLS_SIGNAL } from './prompts.generated.js' import type { ChoicesOption } from './run.js' import type { MultiSelectOption } from './run.js' @@ -272,3 +273,31 @@ export function parseAwaitGate(text: string): ParsedAwaitGate | undefined { } return undefined } + +/** + * Emit the {@link PROTOCOLS_SIGNAL} signals an agent turn carries: markdown views, the + * session name, and `setReadyForMerge()`. Every turn the framework prompts goes through + * one of these, because the protocols are unconditional (see `composeRunSystem`) — the + * agent is told it can signal on any turn, so any turn we don't parse drops the signal. + * + * The returned function holds the dedupe state for the turns it covers: `ready-for-merge` + * fires once, and a session name only re-emits on an actual rename. Each caller makes one + * for its own span of turns (a build's await rounds, the whole backlog), so keep it for as + * many turns as should share that dedupe rather than making one per turn. + */ +export function createTurnSignalEmitter(emit: (event: FrameworkEvent) => void): (text: string) => void { + let named: string | undefined + let ready = false + return (text: string): void => { + for (const view of parseMarkdownViews(text)) emit({ kind: 'view', ...view }) + const name = parseSessionName(text) + if (name && name !== named) { + named = name + emit({ kind: 'session-name', name }) + } + if (!ready && parseReadyForMerge(text)) { + ready = true + emit({ kind: 'ready-for-merge' }) + } + } +}