Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/backlog-turn-signals.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 2 additions & 15 deletions packages/framework/src/prompt-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -166,20 +166,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise<RunPromptResult

// Non-blocking signals the agent emitted this turn: markdown views (#441) and the #326
// lifecycle signals (session name, ready-for-merge). 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)

try {
let turn = await session.prompt(firstPrompt, { signal: runSignal })
Expand Down
17 changes: 2 additions & 15 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/framework/src/todo-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
19 changes: 15 additions & 4 deletions packages/framework/src/todo-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TodoLoopResult> {
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
Expand Down Expand Up @@ -287,11 +295,13 @@ async function promptItem(
requestChoice?: ((req: ChoiceRequest) => Promise<ChoicePick>) | undefined
emit: (event: FrameworkEvent) => void
signal?: AbortSignal | undefined
emitTurnSignals: (text: string) => void
},
): Promise<void> {
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)
Expand All @@ -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)
}
}
29 changes: 29 additions & 0 deletions packages/framework/src/turn-gate.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' })
}
}
}
Loading