Skip to content

Commit a11cd44

Browse files
committed
Scope remaining wait-path copy to the exec primary surface
TUI and nested runs collect through mailbox mail, so every prompt, doc, and comment that still teaches the wait path as live now says which surface it applies to. Drops the readerless queued-steer yield flag whose comment cited the unmounted wait.
1 parent ce84f13 commit a11cd44

9 files changed

Lines changed: 83 additions & 16 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather
190190
- `present` — Renders structured UI from a JSON view spec instead of pasting tables into chat.
191191
- `submit_output` — Completes a workflow step when `step` is set. The step id is compared atomically against the current step (`complete()`); already-complete ids (behind the cursor) and not-current ids (future or unknown) are acknowledged without advancing. Always advertised so activating a workflow does not grow the tools array.
192192

193-
Core agent tools (advertised in every chat turn) include `manage_tasks`, `tool_search`, `use_skill`, `skill_search` (catalog; callable without `tool_search`), **`spawn_agent`** (spawn fleet agents; collect through mailbox mail) and **`wait_agents`** (exec-primary collection verb; not mounted on TUI or nested runs), and **`search_agents`** when fleet-agent profiles are available — see Fleet agents below.
193+
Core agent tools (advertised per surface; exec-only verbs noted) include `manage_tasks`, `tool_search`, `use_skill`, `skill_search` (catalog; callable without `tool_search`), **`spawn_agent`** (spawn fleet agents; collect through mailbox mail) and **`wait_agents`** (exec-primary collection verb; not mounted on TUI or nested runs), and **`search_agents`** when fleet-agent profiles are available — see Fleet agents below.
194194

195195
### Workflows (`src/workflows/`)
196196

‎docs/PRODUCT.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ The evidence is in how the product fails today: the personas already produce exc
4141
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
4242
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
4343
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin.
44-
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
45-
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators still collect with `wait_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.
44+
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
45+
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.
4646

4747
## User Experience
4848

@@ -170,7 +170,7 @@ Corbits Code fans work out to short-lived **fleet agents** — workers with thei
170170

171171
- **Agents** are runtime entities (primary session or child).
172172
- **Tasks** are checklist items owned by one agent via `manage_tasks`.
173-
- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. Nested `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). 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`.
173+
- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — spawn then idle; do not poll. Nested orchestrators collect through mailbox mail the same way. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. On an exec-primary run, `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). On the TUI primary, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`.
174174

175175
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. Printed `<tool_call>` markup in assistant text gets one corrective nudge to issue a real tool call and does not count as the wrap-up; without the envelope, one incomplete-report nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. Counsel and `intent=plan` also require plan substance in Findings (files/paths, acceptance criteria, non-goals, risks, ordered steps); headings-only or stub Findings salvage as `incomplete-report`, not an attachable plan. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` only after a full `stallTimeoutMs` grace with still no activity — queued checks inside that window wait, they do not salvage. 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.
176176

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns
2323
You are the chat surface. Workers cannot ask_operator; they ask_director. A parked question arrives as an idle-send wake — answer with send_input using target = that worker's session id. Do not poll list_agents. 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) then end the turn.
2525
- On mailbox mail or a finished report: short update — do not go silent.
26-
- 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.
26+
- When the operator messages mid-run: answer them first (COMMUNICATION). Do not hold the reply on fleet collection — answer now and fold worker results in on the next turn.
2727
- Keep updates short; no wall of task dumps. manage_tasks is the checklist; chat is the narrative.
2828
2929
Example chains:

‎src/tui/runner/exit.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,8 @@ export async function createRunLifecycle(
512512
// and follow-up (queued drain / deliver) must never call this — those paths
513513
// leave in-flight workers running. Closing the agent is the only thing that
514514
// aborts the reactor mid-inference (the send signal only rejects the send
515-
// promise); that close cascades: operationController.abort → wait_agents parent
516-
// signal → child abort. Do not add cancelAll here — fleet cancelAll is
515+
// promise); that close cascades: operationController.abort → child
516+
// parent-abort forwarding → child abort. Do not add cancelAll here — fleet cancelAll is
517517
// reserved for /clear (newSession) and shutdown.
518518
// Close it, drain the old stream, and rebuild a fresh agent so the next send
519519
// works.

‎src/tui/runner/state.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,6 @@ export interface RunnerState {
295295
stopFleetReporting?: () => void;
296296
stopRunHeartbeat?: () => void;
297297
withFleetPublicationSuspended?: (reset: () => void) => void;
298-
/** TUI primary: true when a queued Enter steer should yield in-flight wait_agents. */
299-
hasQueuedSteer?: () => boolean;
300298
}
301299

302300
export function recordRunError(state: RunnerState, err: unknown): void {

‎src/tui/runner/wiring.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ import {
6868
buildFleetDryContinuationMessage,
6969
buildMailboxMailMessage,
7070
} from "../../session/runtime-assembly.js";
71-
import { steerCount } from "../session-queue.js";
7271

7372
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
7473

@@ -283,7 +282,6 @@ export function wirePostStartup(
283282
sessionBridge.setWaitYieldWake(() => {
284283
services.subAgentSessions.wake();
285284
});
286-
state.hasQueuedSteer = () => steerCount(hostOf(state).shell.session) > 0;
287285
const unsubscribeFleetReport = services.subAgentSessions.subscribe(() => {
288286
fleetWakePublisher.publish();
289287
sessionBridge.flushMailboxMail();

‎src/workflows/coordinator.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export class WorkflowCoordinator {
1616
// When true the workflow pauses after each step for user confirmation; the
1717
// directive tells the agent to gate via ask_operator before advancing.
1818
private readonly stepThrough = false,
19+
// True where the live tool surface mounted wait_agents (exec primary).
20+
// Picks the collection-path copy: wait_agents vs mailbox mail. Defaults
21+
// to the unmounted surface so the directive never advertises a missing tool.
22+
private readonly waitAgentsMounted = false,
1923
) {}
2024

2125
isActive(): boolean {
@@ -61,7 +65,8 @@ export class WorkflowCoordinator {
6165
"",
6266
];
6367
if (step.prompt !== undefined) lines.push(step.prompt, "");
64-
for (const guidance of guidanceFor(step)) lines.push(guidance);
68+
for (const guidance of guidanceFor(step, this.waitAgentsMounted))
69+
lines.push(guidance);
6570
if (this.stepThrough && step.type !== "gate") {
6671
lines.push(
6772
`Step-through mode is on: when this step is done, summarize it and call` +
@@ -108,7 +113,7 @@ function stepIdOf(args: unknown): string | null {
108113
return typeof step === "string" && step.length > 0 ? step : null;
109114
}
110115

111-
function guidanceFor(step: WorkflowStep): string[] {
116+
function guidanceFor(step: WorkflowStep, waitAgentsMounted: boolean): string[] {
112117
const out: string[] = [];
113118
if (step.skill !== undefined) {
114119
out.push(`First load the ${step.skill} skill, then follow this step.`);
@@ -118,11 +123,16 @@ function guidanceFor(step: WorkflowStep): string[] {
118123
if (step.parallel === true && agents.length > 1) {
119124
out.push(
120125
`Delegate this step to these sub-agents in parallel via spawn_agent: ${agents.join(", ")}.` +
121-
` Use wait_agents to collect all of them before advancing.`,
126+
(waitAgentsMounted
127+
? ` Use wait_agents to collect all of them before advancing.`
128+
: ` Then idle: mailbox mail arrives as inbound as each worker finishes — read it before advancing.`),
122129
);
123130
} else {
124131
out.push(
125-
`Delegate this step to the ${agents.join(", ")} sub-agent via spawn_agent, then collect it with wait_agents.`,
132+
`Delegate this step to the ${agents.join(", ")} sub-agent via spawn_agent, ` +
133+
(waitAgentsMounted
134+
? `then collect it with wait_agents.`
135+
: `then idle: mailbox mail arrives as inbound when it finishes — read it before advancing.`),
126136
);
127137
}
128138
}

‎src/workflows/host.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ export class WorkflowHost {
157157
this.notify();
158158
},
159159
workflow.stepThrough === true,
160+
// The directive's collect copy follows the live surface: wait_agents on
161+
// exec primary, mailbox mail where it is unmounted (TUI, nested).
162+
this.args
163+
.getToolDefinitions()
164+
.some((definition) => definition.name === "wait_agents"),
160165
);
161166
this.listen(runtime);
162167
this.runtime = runtime;

‎tests/unit/workflows-runtime.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,30 @@ const parent: Workflow = {
6161
],
6262
};
6363

64+
const withAgentStep: Workflow = {
65+
name: "agented",
66+
description: "delegates a step",
67+
steps: [{ id: "a", label: "A", prompt: "do a", agent: "builder" }],
68+
};
69+
70+
const withParallelAgents: Workflow = {
71+
name: "parallel-agents",
72+
description: "delegates a step in parallel",
73+
steps: [
74+
{
75+
id: "a",
76+
label: "A",
77+
prompt: "do a",
78+
agent: ["builder", "critic"],
79+
parallel: true,
80+
},
81+
],
82+
};
83+
6484
function resolver(name: string): Workflow | undefined {
65-
return [simple, withGatedStep, child, parent].find((w) => w.name === name);
85+
return [simple, withGatedStep, child, parent, withAgentStep].find(
86+
(w) => w.name === name,
87+
);
6688
}
6789

6890
function collect(runtime: WorkflowRuntime): WorkflowEvent[] {
@@ -71,6 +93,12 @@ function collect(runtime: WorkflowRuntime): WorkflowEvent[] {
7193
return events;
7294
}
7395

96+
function coordDirective(rt: WorkflowRuntime): string {
97+
const directive = new WorkflowCoordinator(rt).directive();
98+
if (directive === null) throw new Error("expected an active directive");
99+
return directive;
100+
}
101+
74102
test("start lands on the first executable step", () => {
75103
const rt = new WorkflowRuntime(empty, resolver);
76104
rt.start(simple);
@@ -165,6 +193,34 @@ test("coordinator directive includes the ordinal, label, prompt, and completion
165193
expect(directive).not.toContain("advance_workflow");
166194
});
167195

196+
test("coordinator directive defaults to mailbox collect when wait_agents is unmounted", () => {
197+
const rt = new WorkflowRuntime(empty, resolver);
198+
rt.start(withAgentStep);
199+
const directive = coordDirective(rt);
200+
expect(directive).toContain("mailbox mail");
201+
expect(directive).not.toContain("wait_agents");
202+
});
203+
204+
test("coordinator directive keeps the wait_agents collect path when mounted", () => {
205+
const rt = new WorkflowRuntime(empty, resolver);
206+
rt.start(withAgentStep);
207+
const coord = new WorkflowCoordinator(rt, () => undefined, false, true);
208+
const directive = coord.directive();
209+
expect(directive).toContain("collect it with wait_agents");
210+
});
211+
212+
test("coordinator parallel-agent guidance is mount-gated", () => {
213+
const parallelResolver = (n: string): Workflow | undefined =>
214+
n === "parallel-agents" ? withParallelAgents : undefined;
215+
const unmounted = new WorkflowRuntime(empty, parallelResolver);
216+
unmounted.start(withParallelAgents);
217+
expect(coordDirective(unmounted)).not.toContain("wait_agents");
218+
const mounted = new WorkflowRuntime(empty, parallelResolver);
219+
mounted.start(withParallelAgents);
220+
const coord = new WorkflowCoordinator(mounted, () => undefined, false, true);
221+
expect(coord.directive()).toContain("wait_agents");
222+
});
223+
168224
test("runtime complete is a compare-and-advance against the current step", () => {
169225
const rt = new WorkflowRuntime(empty, resolver);
170226
rt.start(simple);

0 commit comments

Comments
 (0)