Skip to content

Commit 4cb8f24

Browse files
committed
Ship idle-with-fleet so Enter is a turn while workers run
The prompts told Skywalker to dispatch and go idle, but the runtime still treated a settled parent turn with live workers as an ordinary settle: the run went idle, queued follow-ups drained while the fleet was still running (breaking session-idle semantics), and nothing distinguished "parent busy" from "parent idle, fleet live". The runner now emits a fleet event carrying the live-lane count on every transition. The bridge holds the run busy on that count after the parent turn settles: Enter upgrades to a new primary turn sent immediately instead of queueing a steer behind a parent tool that no longer exists, Alt+Enter follow-ups keep waiting for true session-idle, and the last lane terminalizing releases the hold and drains them. A steer left pending when the hold engages delivers at once — the parent it was steering has already stopped. Base orchestrator guidelines no longer push spawn-then-wait in one breath; they teach ending the turn and collecting on a later one. Docs flip "idle-with-fleet is not shipped" to the shipped behavior.
1 parent 91ccb75 commit 4cb8f24

13 files changed

Lines changed: 332 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### TUI
17+
18+
- Idle-with-fleet is real: after Skywalker dispatches workers with `spawn_agent`
19+
and ends its turn, the session holds itself open while they run. Enter starts
20+
a new primary turn immediately instead of queueing a soft-steer behind a
21+
parent tool that no longer exists; Alt+Enter follow-ups still wait for true
22+
session-idle (parent idle and no live fleet lanes), delivering the moment the
23+
last worker terminalizes. Ctrl+C stop policy is unchanged.
24+
1625
## [0.3.2] - 2026-08-25
1726

1827
### Agent

‎docs/IMPLEMENTATION.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,10 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r
187187

188188
`ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true, drain timing is **parent-idle** vs **session-idle**:
189189

190-
- **Enter** soft-steers — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers.
190+
- **Enter** soft-steers while the parent is busy — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers.
191191
- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.
192192

193-
A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent idle after dispatch so Enter is a turn while workers run) is not shipped.
193+
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped.
194194

195195
`src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks.
196196

@@ -382,7 +382,7 @@ the directors guard on; the full set of reactor and stream event types is
382382
treat that as canonical rather than this section or any other doc's partial
383383
list.
384384

385-
Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is not shipped.
385+
Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups.
386386

387387
### Lifecycle Hooks
388388

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ 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) or via stdin when the product agent runs under `corbits exec`.
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 — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `task()` is parent-busy, so Enter is a queued steer, not a new turn. **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. A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent goes idle after dispatch so Enter is a turn while workers run) is not shipped. **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).
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` / `task()` is parent-busy, so Enter is a queued steer, not a new turn. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, 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).
4545
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` / `wait_agents` / `search_agents` (`task` remains a fused spawn-plus-wait wrapper). 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

‎docs/TUI.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,17 @@ Two mid-run gestures, two delivery times (CL-6290):
456456
When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
457457
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).
458458
Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is
459-
unchanged. Idle-with-fleet is not shipped — Enter stays a queued steer until
460-
the parent tool finishes, not a new turn while workers run.
459+
unchanged.
460+
461+
**Idle-with-fleet** is shipped. After a non-blocking `spawn_agent` dispatch
462+
the parent turn settles while workers keep running; the runner emits `fleet`
463+
events carrying the live-lane count and the bridge holds the run busy on it.
464+
During the hold, Enter upgrades to a new primary turn sent immediately —
465+
there is no parent tool left to steer — while Alt+Enter follow-ups keep
466+
waiting for true session-idle. A steer still pending when the hold engages
467+
delivers at once (the parent it was steering has stopped), and the last lane
468+
terminalizing releases the hold, drains follow-ups, and returns the session
469+
to idle.
461470

462471
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
463472
— the transcript literally said `interrupt — discarded N pending`, and an

‎src/agent/prompts.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function buildGuidelines(
150150
: [
151151
"",
152152
"Orchestration:",
153-
"- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then `wait_agents` for the reports you need. `task` remains the deprecated fused spawn+wait fallback for a single blocking worker.",
153+
"- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked. `task` remains the deprecated fused spawn+wait fallback for a single blocking worker.",
154154
"- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.",
155155
"- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.",
156156
"- If a worker comes back without finishing, change the brief rather than repeating it: narrow the scope, name the files, or state the done-when more sharply.",

‎src/subagent/fleet-report.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, test } from "bun:test";
2-
import { createFleetWatch, fleetDigest, observeFleet, type FleetLane } from "./fleet-report.js";
2+
import {
3+
createFleetWatch,
4+
fleetDigest,
5+
liveFleetCount,
6+
observeFleet,
7+
type FleetLane,
8+
} from "./fleet-report.js";
39

410
const T0 = 1_000_000;
511

@@ -16,6 +22,24 @@ function lane(overrides: Partial<FleetLane> & { id: string }): FleetLane {
1622
};
1723
}
1824

25+
describe("liveFleetCount", () => {
26+
test("counts only running lanes — the idle-with-fleet hold reads the same definition", () => {
27+
const lanes = [
28+
lane({ id: "a" }),
29+
lane({ id: "b", status: "done", report: "## Summary\nDone." }),
30+
lane({ id: "c", status: "failed", error: "boom" }),
31+
lane({ id: "d", status: "cancelled" }),
32+
lane({ id: "e" }),
33+
];
34+
expect(liveFleetCount(lanes)).toBe(2);
35+
});
36+
37+
test("an empty or fully-terminal fleet counts zero", () => {
38+
expect(liveFleetCount([])).toBe(0);
39+
expect(liveFleetCount([lane({ id: "a", status: "done", report: "x" })])).toBe(0);
40+
});
41+
});
42+
1943
describe("observeFleet", () => {
2044
test("the first observation seeds without announcing an in-flight fleet", () => {
2145
const { watch, updates } = observeFleet(

‎src/subagent/fleet-report.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean {
108108
return agentProgress(lane, nowMs, stallMs)?.stalled === true;
109109
}
110110

111+
/**
112+
* Lanes still running — the count the idle-with-fleet hold reads (CL-7057).
113+
* One definition lives here so the bridge feed and any other liveness reader
114+
* cannot drift from what the strip and digest call a running lane.
115+
*/
116+
export function liveFleetCount(lanes: readonly FleetLane[]): number {
117+
return lanes.filter((lane) => lane.status === "running").length;
118+
}
119+
111120
type Change =
112121
| { readonly kind: "dispatched"; readonly line: string }
113122
| { readonly kind: "done"; readonly line: string }

‎src/subagent/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
fleetDigest,
1717
FLEET_REPORT_SETTLE_MS,
1818
FLEET_STALL_POLL_MS,
19+
liveFleetCount,
1920
observeFleet,
2021
type FleetLane,
2122
type FleetObservation,

‎src/tui/runner.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ import {
149149
fleetDigest,
150150
FLEET_REPORT_SETTLE_MS,
151151
FLEET_STALL_POLL_MS,
152+
liveFleetCount,
152153
observeFleet,
153154
taskToolDefinition,
154155
} from "../subagent/index.js";
@@ -2598,7 +2599,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
25982599
for (const update of observation.updates) surfaceSystemNotice(host.shell, update);
25992600
};
26002601
let fleetSettle: ReturnType<typeof setTimeout> | null = null;
2602+
// Live-lane count feeds the bridge's idle-with-fleet hold (CL-7057): the
2603+
// run stays busy after the parent turn settles until the last lane
2604+
// terminalizes. Store notifications fire per child event, not per status
2605+
// flip, so emit only when the count itself moves.
2606+
let lastLiveFleet = 0;
26012607
const unsubscribeFleetReport = subAgentSessions.subscribe(() => {
2608+
const liveFleet = liveFleetCount(subAgentSessions.list());
2609+
if (liveFleet !== lastLiveFleet) {
2610+
lastLiveFleet = liveFleet;
2611+
emitter.emit("event", { type: "fleet", running: liveFleet });
2612+
}
26022613
if (fleetSettle !== null) return;
26032614
fleetSettle = setTimeout(() => {
26042615
fleetSettle = null;

0 commit comments

Comments
 (0)