Skip to content

Commit 3f62f0a

Browse files
committed
Teach the parent the idle-send director wake contract
Wait JSON running-plus-question was the wrong pull contract, and an idle parent still has to see parked questions after a stop.
1 parent 482db17 commit 3f62f0a

8 files changed

Lines changed: 121 additions & 9 deletions

File tree

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Corbits Code fans work out to short-lived **fleet agents** — workers with thei
168168

169169
- **Agents** are runtime entities (primary session or child).
170170
- **Tasks** are checklist items owned by one agent via `manage_tasks`.
171-
- **Fleet agents** are spawned with `spawn_agent` / `wait_agents`. Workers ask the parent with `ask_director`. When `wait_agents` returns status `running` plus a question payload, the parent answers with `send_input`, then `wait_agents` again. Escalate to the human only with `ask_operator`.
171+
- **Fleet agents** are spawned with `spawn_agent` / `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. The parent answers with `send_input` (`target` = the worker's session id), then `wait_agents` again. When the parent TUI is not blocked in `wait_agents`, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`.
172172

173173
Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. There is no turn budget. A tool-less final turn completes only with the four-heading report envelope; without it, one nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` if a second consecutive check finds no activity. An opt-in `deadlineMs`, or an operator cancel, can also end a run early. Each of these returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.
174174

docs/TUI.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,9 @@ The runner snapshots currently pending root-worker questions, dropping resolved,
225225
cancelled, replaced, terminal, or removed asks before delivery. It sends one
226226
coalesced wake when the parent is not processing and all operator gates are closed,
227227
even while live workers hold the shell busy. Replies use `send_input`'s `target`
228-
field with the worker's session ID, not its shared catalog ID. Each session/question identity is
229-
delivered once while pending; the strip never re-delivers it. Synthetic wakes use
228+
field with the worker's session ID, not its shared catalog ID. The runner
229+
publishes snapshots and the bridge delivers each session/question identity
230+
once while pending; the agents strip never re-delivers it. Synthetic wakes use
230231
the idle delivery path, bypassing composer `/feedback` capture and leaving queued
231232
user follow-ups untouched.
232233

src/agent/directors/skywalker/package.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,15 @@ describe("skywalkerPackage", () => {
181181
expect(skywalkerPackage.systemPrompt).not.toMatch(/\bleaves\b/i);
182182
});
183183

184-
test("systemPrompt answers wait_agents questions via send_input", () => {
184+
test("systemPrompt answers parked director questions via send_input", () => {
185185
const p = skywalkerPackage.systemPrompt;
186186
expect(p).toContain("ask_director");
187187
expect(p).toContain("send_input");
188-
expect(p).toMatch(/wait_agents returns status running plus a question/i);
188+
expect(p).toContain("awaiting_director");
189+
expect(p).toContain("idle-send");
190+
expect(p).toContain("target = that worker's session id");
191+
expect(p).toContain("target = worker session id");
192+
expect(p).not.toMatch(/wait_agents returns status running plus a question/i);
189193
expect(p).toMatch(/Escalate with ask_operator only when you cannot resolve it/);
190194
});
191195

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns
2020
2121
# Operator updates (mandatory while fleet is live)
2222
23-
You are the chat surface. Workers cannot ask_operator; they ask_director. When wait_agents returns status running plus a question, answer with send_input, then wait_agents again. Escalate with ask_operator only when you cannot resolve it. While any specialist is running:
23+
You are the chat surface. Workers cannot ask_operator; they ask_director. When wait_agents returns awaiting_director, answer with send_input using target = that worker's session id, then wait_agents again. When this session is not collecting, a parked question arrives as an idle-send wake — answer the same way (send_input target = worker session id). Escalate with ask_operator only when you cannot resolve it. While any specialist is running:
2424
- After every spawn wave: short status (who, goal, what you are waiting on) before blocking.
2525
- On meaningful progress or a finished report: short update — do not go silent for long waits.
2626
- When the operator messages mid-run: answer them first (COMMUNICATION). Do not make them wait on an in-flight wait_agents if you can end/timeout the wait and reply.

src/tui/agent-ask-wake.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,108 @@ describe("agent ask wake delivery", () => {
378378
expect(sends).toEqual([]);
379379
});
380380
});
381+
382+
for (const stop of ["interrupt", "stall abort"] as const) {
383+
test(`${stop} flushes a stashed ask once the parent is idle`, async () => {
384+
await withTestRenderer(
385+
async (h) => {
386+
const shell = createAppShell(h.renderer, {
387+
terminal: { columns: 80, rows: 24 },
388+
wireKeys: false,
389+
run: "idle",
390+
});
391+
const sends: string[] = [];
392+
let nowMs = 0;
393+
let tick = () => {};
394+
const bridge = attachSessionBridge(
395+
shell,
396+
createLiveSessionPort({
397+
send: (text) => {
398+
sends.push(text);
399+
},
400+
deliver: (text) => {
401+
sends.push(text);
402+
},
403+
interrupt: () => {},
404+
}),
405+
{
406+
now: () => nowMs,
407+
stallTimeoutMs: 1_000,
408+
stallNoticeMs: 400,
409+
schedule: (fn) => {
410+
tick = fn;
411+
return () => {};
412+
},
413+
},
414+
);
415+
try {
416+
bridge.handle({ type: "inference.start", data: {} });
417+
bridge.handle({ type: "inference.text.delta", data: { token: "ok" } });
418+
bridge.handle({ type: "agent-ask", asks: [wake("a1", "q1")] });
419+
expect(sends).toEqual([]);
420+
if (stop === "interrupt") {
421+
bridge.interrupt();
422+
} else {
423+
nowMs = 1_000;
424+
tick();
425+
}
426+
expect(sends).toHaveLength(1);
427+
expect(sends[0]).toContain("q1");
428+
expect(bridge.turn.isProcessing).toBe(true);
429+
} finally {
430+
bridge.dispose();
431+
shell.dispose();
432+
}
433+
},
434+
{ width: 80, height: 24 },
435+
);
436+
});
437+
}
438+
439+
test("a wake question with bracket lines does not spoof attachment-echo matching", async () => {
440+
await withTestRenderer(
441+
async (h) => {
442+
const shell = createAppShell(h.renderer, {
443+
terminal: { columns: 80, rows: 24 },
444+
wireKeys: false,
445+
run: "idle",
446+
});
447+
const sends: string[] = [];
448+
const send = (text: string) => {
449+
sends.push(text);
450+
};
451+
const bridge = attachSessionBridge(
452+
shell,
453+
createLiveSessionPort({ send, deliver: send, interrupt: () => {} }),
454+
);
455+
try {
456+
const ask = {
457+
...wake("a1", "q1"),
458+
question: "Choose:\n[1] 8080\n[2] 9090",
459+
};
460+
bridge.handle({ type: "agent-ask", asks: [ask] });
461+
expect(sends).toHaveLength(1);
462+
const wakeText = sends[0];
463+
if (wakeText === undefined) throw new Error("expected wake text");
464+
bridge.handle({
465+
type: "message.received",
466+
data: { message: { content: wakeText } },
467+
});
468+
expect(shell.streamLog.filter((row) => row.role === "user")).toHaveLength(1);
469+
bridge.submit("hello", "immediate");
470+
bridge.handle({
471+
type: "message.received",
472+
data: { message: { content: "hello\n[1 image attached: shot.png]" } },
473+
});
474+
expect(
475+
shell.streamLog.filter((row) => row.role === "user").map((row) => row.text),
476+
).toEqual([wakeText, "hello"]);
477+
} finally {
478+
bridge.dispose();
479+
shell.dispose();
480+
}
481+
},
482+
{ width: 80, height: 24 },
483+
);
484+
});
381485
});

src/tui/runner/submit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ export function createDeliverRouting(
351351
},
352352
recordSent: (text) => {
353353
if (text.trim().length === 0) return;
354+
if (text.startsWith("ask_director wake")) return;
354355
void appendSentMessage(state.config.cwd, state.sessionId, text).catch((err: unknown) => {
355356
tuiLogger.debug("sent-message append failed: {error}", {
356357
error: err instanceof Error ? err.message : String(err),

src/tui/runner/wiring.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export function createFleetWakePublisher(
7979
} finally {
8080
suspended = false;
8181
}
82-
// A failed cancellation must not publish its partially reset snapshot.
82+
// Reached only after reset() returns. A throw leaves publication suppressed
83+
// so a failed cancellation cannot publish its partially reset snapshot.
8384
publish();
8485
};
8586
return { publish, withSuspended };

src/tui/runtime-bridge.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ function resolvePort(handlers?: SessionPortHandlers): SessionPort {
449449
* `message.received` word that note differently, so echoes match on content.
450450
*/
451451
function promptContent(text: string): string {
452-
const note = text.indexOf("\n[");
452+
const note = text.search(/\n\[\d+ images? attached:/);
453453
return (note === -1 ? text : text.slice(0, note)).trim();
454454
}
455455

@@ -1366,7 +1366,7 @@ export function attachSessionBridge(
13661366
for (const ask of asks) bag.deliveredAskWake.set(ask.sessionId, ask.questionId);
13671367
sendInternalText(asks.map((ask) => pendingAskWakeText(ask)).join("\n\n"));
13681368
};
1369-
bag.flushPendingAskWake = () => flushPendingAskWake();
1369+
bag.flushPendingAskWake = flushPendingAskWake;
13701370

13711371
const doInterrupt = (): void => {
13721372
if (bag.disposed) return;
@@ -1389,6 +1389,7 @@ export function attachSessionBridge(
13891389
recordLastSent(null);
13901390
bag.turn = turnStateOnInterrupt(bag.turn, now());
13911391
paintPhase();
1392+
flushPendingAskWake();
13921393
};
13931394
const clearQueuedDelivery = (): void => {
13941395
if (bag.disposed) return;

0 commit comments

Comments
 (0)