Skip to content

Commit c506b76

Browse files
committed
Drop nothing running from dry fleet tallies
1 parent 2a8cf44 commit c506b76

5 files changed

Lines changed: 125 additions & 21 deletions

File tree

docs/TUI.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,12 @@ Parent prose owns success narratives. Transcript fleet notices exist only for
255255
attention live spawn_agent rows cannot keep: a lane **failed** or **cancelled**
256256
while other work is still running, and **one** dry-fleet line when the last
257257
lane finishes
258-
(`N done · nothing running`; failed and cancelled counts appear only
259-
when non-zero, e.g. `N done, M failed, K cancelled · nothing running`).
258+
(`N done`; failed and cancelled counts appear only
259+
when non-zero, e.g. `N done, M failed, K cancelled`).
260+
When the parent is still in a turn or still has todo/doing work, that
261+
line appends `orchestrator continuing`. The suffix is omitted when the
262+
orchestrator is idle, and is not added while specialist lanes are still
263+
running. An empty fleet with no outcomes does not claim the job closed.
260264
Per-lane `done — summary` walls and live `dispatched` re-announcements
261265
are never printed. That dry-fleet line stays operator-facing. If tasks
262266
are still todo/doing, the runtime re-enters the parent with collected

src/subagent/fleet-report.test.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe("observeFleet", () => {
8989
],
9090
T0 + 1000,
9191
);
92-
expect(updates).toEqual(["2 done · nothing running"]);
92+
expect(updates).toEqual(["2 done"]);
9393
});
9494

9595
test("a failure names what went wrong while the fleet is still live", () => {
@@ -155,7 +155,7 @@ describe("observeFleet", () => {
155155
: { ...l, status: "failed" as const, error: "boom" },
156156
);
157157
const { updates } = observeFleet(seeded, after, T0 + 1000);
158-
expect(updates).toEqual(["9 done, 3 failed · nothing running"]);
158+
expect(updates).toEqual(["9 done, 3 failed"]);
159159
});
160160

161161
test("a cancelled-only dry fleet counts cancelled, not failed", () => {
@@ -172,7 +172,7 @@ describe("observeFleet", () => {
172172
],
173173
T0 + 1000,
174174
);
175-
expect(updates).toEqual(["0 done, 2 cancelled · nothing running"]);
175+
expect(updates).toEqual(["0 done, 2 cancelled"]);
176176
});
177177

178178
test("a mixed dry fleet names done, failed, and cancelled separately", () => {
@@ -190,9 +190,7 @@ describe("observeFleet", () => {
190190
],
191191
T0 + 1000,
192192
);
193-
expect(updates).toEqual([
194-
"1 done, 1 failed, 1 cancelled · nothing running",
195-
]);
193+
expect(updates).toEqual(["1 done, 1 failed, 1 cancelled"]);
196194
});
197195

198196
test("a burst of live cancels coalesces as cancelled, not failed", () => {
@@ -216,6 +214,46 @@ describe("observeFleet", () => {
216214
const { updates } = observeFleet(seeded, after, T0 + 1000);
217215
expect(updates).toEqual(["2 failed, 2 cancelled"]);
218216
});
217+
218+
test("a dry fleet appends orchestrator continuing only when the parent is still working", () => {
219+
const seeded = observeFleet(
220+
createFleetWatch(),
221+
[lane({ id: "api" }), lane({ id: "docs", status: "done" })],
222+
T0,
223+
).watch;
224+
const lanes = [
225+
lane({ id: "api", status: "done" as const, report: "done" }),
226+
lane({ id: "docs", status: "done" as const }),
227+
];
228+
expect(observeFleet(seeded, lanes, T0 + 1000).updates).toEqual(["2 done"]);
229+
expect(
230+
observeFleet(seeded, lanes, T0 + 1000, {
231+
orchestratorContinuing: true,
232+
}).updates,
233+
).toEqual(["2 done · orchestrator continuing"]);
234+
});
235+
236+
test("orchestrator continuing is not added while a specialist lane is still running", () => {
237+
const seeded = observeFleet(
238+
createFleetWatch(),
239+
[lane({ id: "build" }), lane({ id: "docs" })],
240+
T0,
241+
).watch;
242+
const { updates } = observeFleet(
243+
seeded,
244+
[
245+
lane({
246+
id: "build",
247+
status: "failed",
248+
error: "typecheck exited 1",
249+
}),
250+
lane({ id: "docs" }),
251+
],
252+
T0 + 1000,
253+
{ orchestratorContinuing: true },
254+
);
255+
expect(updates).toEqual(["build failed — typecheck exited 1"]);
256+
});
219257
});
220258

221259
describe("fleetDigest", () => {
@@ -236,11 +274,11 @@ describe("fleetDigest", () => {
236274
expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed");
237275
});
238276

239-
test("a fleet with nothing left running says so rather than going blank", () => {
277+
test("a dry fleet with outcomes is the tally only — idle does not claim the job closed", () => {
240278
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
241-
"nothing running · 1 done",
279+
"1 done",
242280
);
243-
expect(fleetDigest([], T0)).toBe("nothing running");
281+
expect(fleetDigest([], T0)).toBe("");
244282
});
245283

246284
test("cancelled lanes are named separately from failed", () => {
@@ -252,7 +290,32 @@ describe("fleetDigest", () => {
252290
],
253291
T0,
254292
),
255-
).toBe("nothing running · 1 failed · 1 cancelled");
293+
).toBe("1 failed · 1 cancelled");
294+
});
295+
296+
test("orchestrator continuing is a dry-fleet suffix, never a live-lane claim", () => {
297+
expect(
298+
fleetDigest([lane({ id: "api", status: "done" })], T0, {
299+
orchestratorContinuing: true,
300+
}),
301+
).toBe("1 done · orchestrator continuing");
302+
expect(fleetDigest([], T0, { orchestratorContinuing: true })).toBe(
303+
"orchestrator continuing",
304+
);
305+
expect(
306+
fleetDigest(
307+
[
308+
lane({
309+
id: "api",
310+
startedAt: T0 - 80_000,
311+
lastActivityAt: T0 - 1000,
312+
}),
313+
lane({ id: "web", status: "done" }),
314+
],
315+
T0,
316+
{ orchestratorContinuing: true },
317+
),
318+
).toBe("1 running (api 1:20) · 1 done");
256319
});
257320
});
258321

src/subagent/fleet-report.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,19 @@ export interface FleetObservation {
191191
readonly updates: readonly string[];
192192
}
193193

194+
export interface FleetReportOpts {
195+
readonly stallMs?: number;
196+
readonly orchestratorContinuing?: boolean;
197+
}
198+
194199
export function observeFleet(
195200
previous: FleetWatch,
196201
lanes: readonly FleetLane[],
197202
nowMs: number,
198-
stallMs: number = DEFAULT_STALL_MS,
203+
opts: FleetReportOpts = {},
199204
): FleetObservation {
205+
const stallMs = opts.stallMs ?? DEFAULT_STALL_MS;
206+
const orchestratorContinuing = opts.orchestratorContinuing === true;
200207
const marks = new Map<string, LaneMark>();
201208
const changes: Change[] = [];
202209
let running = 0;
@@ -263,7 +270,7 @@ export function observeFleet(
263270
return {
264271
watch,
265272
updates: [
266-
clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS),
273+
clip(dryFleetLine(lanes, orchestratorContinuing), MAX_UPDATE_CHARS),
267274
],
268275
};
269276
}
@@ -348,21 +355,37 @@ function idleSummary(lanes: readonly FleetLane[]): string {
348355
}).join(", ");
349356
}
350357

358+
function withOrchestratorContinuing(line: string, continuing: boolean): string {
359+
if (!continuing) return line;
360+
return line.length === 0
361+
? "orchestrator continuing"
362+
: `${line} · orchestrator continuing`;
363+
}
364+
365+
function dryFleetLine(
366+
lanes: readonly FleetLane[],
367+
orchestratorContinuing: boolean,
368+
): string {
369+
return withOrchestratorContinuing(idleSummary(lanes), orchestratorContinuing);
370+
}
371+
351372
/**
352373
* The answer to "where are we" on demand — the same picture the unprompted
353374
* lines build up to, in one row, so asking never costs an interrupt.
354375
*/
355376
export function fleetDigest(
356377
lanes: readonly FleetLane[],
357378
nowMs: number,
358-
stallMs: number = DEFAULT_STALL_MS,
379+
opts: FleetReportOpts = {},
359380
): string {
360-
if (lanes.length === 0) return "nothing running";
381+
const stallMs = opts.stallMs ?? DEFAULT_STALL_MS;
382+
const orchestratorContinuing = opts.orchestratorContinuing === true;
383+
if (lanes.length === 0) {
384+
return orchestratorContinuing ? "orchestrator continuing" : "";
385+
}
361386
const running = lanes.filter((l) => l.status === "running");
362387
const parts: string[] = [];
363-
if (running.length === 0) {
364-
parts.push("nothing running");
365-
} else {
388+
if (running.length > 0) {
366389
const named = running
367390
.slice(0, DIGEST_NAMED_LANES)
368391
.map((lane) => {
@@ -379,5 +402,8 @@ export function fleetDigest(
379402
parts.push(
380403
...formatOutcomeParts(outcomeCounts(lanes), { includeZeroDone: false }),
381404
);
382-
return parts.join(" · ");
405+
const digest = parts.join(" · ");
406+
// Live lanes already name themselves; the suffix is only for a dry fleet.
407+
if (running.length > 0) return digest;
408+
return withOrchestratorContinuing(digest, orchestratorContinuing);
383409
}

src/tui/runner/commands.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "../../cost/cost-summary.js";
3636
import { contextTokensFromUsage } from "../../provider/context-window.js";
3737
import { fleetDigest } from "../../subagent/index.js";
38+
import { hasActiveTasks } from "../../agent/tasks.js";
3839
import { renameSession } from "../../session/index.js";
3940
import { truncateSessionLabel } from "../../session/session-label.js";
4041
import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js";
@@ -166,7 +167,11 @@ export function createCommandLayer(
166167
},
167168
startWorkflow: (name) => services.workflowHost.start(name),
168169
getFleetStatus: () =>
169-
fleetDigest(services.subAgentSessions.list(), Date.now()),
170+
fleetDigest(services.subAgentSessions.list(), Date.now(), {
171+
orchestratorContinuing:
172+
hostOf(state).bridge.turn.isProcessing ||
173+
hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []),
174+
}),
170175
renameSession: (name) => {
171176
const trimmed = name.trim();
172177
if (trimmed.length === 0) return "Session name cannot be empty";

src/tui/runner/wiring.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { scheduleUpgradeNotice } from "../../upgrade/index.js";
3232
import pkg from "../../../package.json" with { type: "json" };
3333
import { hydrateTasksFromTurns } from "../../agent/director.js";
34+
import { hasActiveTasks } from "../../agent/tasks.js";
3435
import { cycleReasoningEffort } from "../../provider/reasoning-effort.js";
3536
import { isCodexProviderName } from "../../config/codex-providers.js";
3637
import { RUNTIME_FLASH_MS } from "../runtime-notices.js";
@@ -181,6 +182,11 @@ export function wirePostStartup(
181182
fleetWatch,
182183
services.subAgentSessions.list(),
183184
Date.now(),
185+
{
186+
orchestratorContinuing:
187+
sessionBridge.turn.isProcessing ||
188+
hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []),
189+
},
184190
);
185191
fleetWatch = observation.watch;
186192
for (const update of observation.updates)

0 commit comments

Comments
 (0)