Skip to content

Commit 7801f23

Browse files
Merge pull request #841 from corbitsdev/cl-7524-wake-parents-for-parked-director-questions
Wake parents for parked director questions
2 parents 3aae8b0 + 7034fe0 commit 7801f23

20 files changed

Lines changed: 1255 additions & 39 deletions

docs/ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source (
211211

212212
Workers ask the spawning parent with **`ask_director`** (not the human). 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`**, then **`wait_agents`** again. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it.
213213

214+
When the parent TUI is not blocked in `wait_agents`, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture.
215+
214216
When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.
215217

216218
Built-in directors with `spawn.maySpawn` may themselves call `spawn_agent` (one hop only): nested dispatch installs the mailbox-scoped fleet verbs (`spawn_agent`, `wait_agents`, `list_agents`, …) with `allowOrchestrator: false` so the tree bottoms out. Profile-sourced `orchestrator: true` is rejected before a session starts because it has no trusted tier/authority semantics today. Fleet discovery (`search_agents`) stays Tier 1 only. Unknown `agent` ids fail closed.

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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ status / current tool) — Amp/Codex-style lanes without a FLEET header board:
221221
```
222222

223223
An `ask_director` lane stays live and reads as waiting on the director, not stalled.
224+
The runner snapshots currently pending root-worker questions, dropping resolved,
225+
cancelled, replaced, terminal, or removed asks before delivery. It sends one
226+
coalesced wake when the parent is not processing and all operator gates are closed,
227+
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. 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
231+
the idle delivery path, bypassing composer `/feedback` capture and leaving queued
232+
user follow-ups untouched.
224233

225234
`formatChromeZones``formatAgentsPanel` owns that paint. Geometry stays
226235
stack-only (`layoutMode: "stack"`, `railWidth: 0`); the zone max is

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

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

208-
test("systemPrompt answers wait_agents questions via send_input", () => {
208+
test("systemPrompt answers parked director questions via send_input", () => {
209209
const p = skywalkerPackage.systemPrompt;
210210
expect(p).toContain("ask_director");
211211
expect(p).toContain("send_input");
212-
expect(p).toMatch(/wait_agents returns status running plus a question/i);
212+
expect(p).toContain("awaiting_director");
213+
expect(p).toContain("idle-send");
214+
expect(p).toContain("target = that worker's session id");
215+
expect(p).toContain("target = worker session id");
216+
expect(p).not.toMatch(/wait_agents returns status running plus a question/i);
213217
expect(p).toMatch(/Escalate with ask_operator only when you cannot resolve it/);
214218
});
215219

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.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { pendingAskSnapshot, pendingAskWakeText, type FleetLane } from "./fleet-report.js";
3+
4+
function lane(overrides: Partial<FleetLane> & { id: string }): FleetLane {
5+
return {
6+
description: overrides.id,
7+
status: "running",
8+
startedAt: 0,
9+
lastActivityAt: 0,
10+
currentToolName: null,
11+
currentToolPreview: null,
12+
currentToolStartedAt: null,
13+
...overrides,
14+
};
15+
}
16+
17+
describe("pendingAskSnapshot", () => {
18+
test("repeated calls return complete identical snapshots for distinct sessions sharing a catalog", () => {
19+
const lanes = [
20+
lane({ id: "a1", agentId: "builder", description: "Build the thing" }),
21+
lane({ id: "a2", agentId: "builder" }),
22+
];
23+
const peek = () => ({ question: "Which port?", questionId: "q1" });
24+
const expected = lanes.map((worker) => ({
25+
sessionId: worker.id,
26+
agentId: "builder",
27+
description: worker.description,
28+
question: "Which port?",
29+
questionId: "q1",
30+
}));
31+
expect(pendingAskSnapshot(lanes, peek)).toEqual(expected);
32+
expect(pendingAskSnapshot(lanes, peek)).toEqual(expected);
33+
});
34+
35+
test("resolution, removal and replacement are reflected without prior watch state", () => {
36+
const lanes = [lane({ id: "a1" })];
37+
const asks = new Map([["a1", { question: "A?", questionId: "q1" }]]);
38+
const peek = (id: string) => asks.get(id);
39+
expect(pendingAskSnapshot(lanes, peek)[0]?.questionId).toBe("q1");
40+
expect(pendingAskSnapshot([], peek)).toEqual([]);
41+
asks.clear();
42+
expect(pendingAskSnapshot(lanes, peek)).toEqual([]);
43+
asks.set("a1", { question: "B?", questionId: "q2" });
44+
expect(pendingAskSnapshot(lanes, peek)[0]).toMatchObject({
45+
sessionId: "a1",
46+
question: "B?",
47+
questionId: "q2",
48+
});
49+
});
50+
51+
test("only running root workers with a pending question are included", () => {
52+
const lanes = [
53+
lane({ id: "root" }),
54+
lane({ id: "child", parentSessionId: "orchestrator" }),
55+
lane({ id: "done", status: "done" }),
56+
lane({ id: "cancelled", status: "cancelled" }),
57+
lane({ id: "no-ask" }),
58+
];
59+
const asks = pendingAskSnapshot(lanes, (id) =>
60+
id === "no-ask" ? undefined : { question: "Q?", questionId: "q1" },
61+
);
62+
expect(asks.map((ask) => ask.sessionId)).toEqual(["root"]);
63+
});
64+
});
65+
66+
describe("pendingAskWakeText", () => {
67+
test("names agent, description, question and question id, and routes to send_input", () => {
68+
const text = pendingAskWakeText({
69+
sessionId: "a1",
70+
agentId: "builder",
71+
description: "Build the thing",
72+
question: "Which port?",
73+
questionId: "q1",
74+
});
75+
expect(text).toContain("builder");
76+
expect(text).toContain("Build the thing");
77+
expect(text).toContain("Which port?");
78+
expect(text).toContain("q1");
79+
expect(text).toContain("send_input");
80+
expect(text).toContain("using target a1");
81+
expect(text.toLowerCase()).toContain("worker");
82+
});
83+
});

src/subagent/fleet-report.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export interface FleetLane {
2929
readonly error?: string;
3030
/** Machine-readable forced-stop reason (see SubAgentSession.stopReason). */
3131
readonly stopReason?: string;
32+
/** Catalog agent id (SubAgentSession.agentId); the wake message names it. */
33+
readonly agentId?: string;
34+
/** Set on nested (one-hop) dispatches; such asks never wake the root. */
35+
readonly parentSessionId?: string;
3236
}
3337

3438
interface LaneMark {
@@ -117,6 +121,56 @@ export function liveFleetCount(lanes: readonly FleetLane[]): number {
117121
return lanes.filter((lane) => lane.status === "running").length;
118122
}
119123

124+
/**
125+
* One parked ask_director question. Replies target the unique `sessionId`;
126+
* `agentId` is only the descriptive catalog identity shared by workers.
127+
*/
128+
export interface PendingAskWake {
129+
readonly sessionId: string;
130+
readonly agentId: string;
131+
readonly description: string;
132+
readonly question: string;
133+
readonly questionId: string;
134+
}
135+
136+
/** Nested orchestrators own their children's questions; only root workers wake the TUI. */
137+
export function pendingAskSnapshot(
138+
lanes: readonly FleetLane[],
139+
peekAsk: (sessionId: string) => { question: string; questionId: string } | undefined,
140+
): readonly PendingAskWake[] {
141+
const asks: PendingAskWake[] = [];
142+
for (const lane of lanes) {
143+
if (lane.parentSessionId !== undefined || lane.status !== "running") continue;
144+
const ask = peekAsk(lane.id);
145+
if (ask === undefined) continue;
146+
asks.push({
147+
sessionId: lane.id,
148+
agentId: lane.agentId ?? lane.id,
149+
description: lane.description,
150+
question: ask.question,
151+
questionId: ask.questionId,
152+
});
153+
}
154+
return asks;
155+
}
156+
157+
export const ASK_DIRECTOR_WAKE_PREFIX = "ask_director wake";
158+
159+
/**
160+
* The wake turn text. It must read as the worker's question reaching the
161+
* parent, not as the operator being asked — the parent answers via
162+
* send_input itself and only escalates when it genuinely cannot.
163+
*/
164+
export function pendingAskWakeText(wake: PendingAskWake): string {
165+
return [
166+
`${ASK_DIRECTOR_WAKE_PREFIX} — worker ${wake.agentId} (${wake.description}) parked question ${wake.questionId} while this session was not collecting:`,
167+
"",
168+
wake.question,
169+
"",
170+
`The worker — not the operator — raised this. Answer it with send_input (soft) using target ${wake.sessionId}; do not relay to the operator unless it genuinely needs them.`,
171+
].join("\n");
172+
}
173+
120174
type Change =
121175
| { readonly kind: "dispatched"; readonly line: string }
122176
| { readonly kind: "done"; readonly line: string }

src/subagent/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ export {
1818
FLEET_STALL_POLL_MS,
1919
liveFleetCount,
2020
observeFleet,
21+
pendingAskSnapshot,
22+
pendingAskWakeText,
2123
type FleetLane,
2224
type FleetObservation,
2325
type FleetWatch,
26+
type PendingAskWake,
2427
} from "./fleet-report.js";
2528
export {
2629
EMPTY_THRASH_STATE,

0 commit comments

Comments
 (0)