Skip to content

Commit b495023

Browse files
Merge pull request #866 from corbitsdev/cl-7615-drop-nothing-running-from-the-dry-fleet-tally
Drop nothing running from dry fleet tallies
2 parents 6ba2a96 + be3d9de commit b495023

12 files changed

Lines changed: 243 additions & 101 deletions

CHANGELOG.md

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

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Dry-fleet transcript and `/status` report the outcome tally only
19+
(`2 done, 1 failed`). They no longer claim `nothing running` when the
20+
parent may still continue.
21+
- The prompt-box lockup stays on while a fleet is live or a dry-fleet
22+
continuation is pending, even if the parent turn has settled. The word
23+
cycles through a closed live-activity set (`working`, `warping`,
24+
`buzzing`, `grinding`, `thinking`, `doing`, `cooking`, `creating`,
25+
`imagining`, `inventing`) instead of going blank.
26+
1627
## [0.3.19] - 2026-09-10
1728

1829
### Security

docs/TUI.md

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,16 @@ and `@mention` tokens anywhere paint `UI.action`. Bare skill or agent words
9494
(`implement`, `emil`, `brand review`) stay unstyled, as does a `/review`
9595
that appears mid-prose.
9696

97-
While a turn is live the lockup slot swaps the wordmark for a semantic
98-
activity word — never the raw tool, MCP server, or plugin identifier that is
99-
actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`)
100-
maps execution onto the closed set `ACTIVITY_STATES` exported from that
101-
module (`thinking`, `planning`, `researching`, `building`, `working`,
102-
`waiting`, `stalled`, `stopping`); that export is the source
103-
of truth for what the slot can say, not this list. It is led by a single density cell
97+
While a turn is live — or the session is still occupied by a live fleet
98+
or a pending dry-fleet continuation — the lockup slot swaps the wordmark
99+
for a semantic activity word — never the raw tool, MCP server, or plugin
100+
identifier that is actually executing. Live occupation cycles
101+
`LIVE_ACTIVITY_WORDS` (`working`, `warping`, `buzzing`, `grinding`,
102+
`thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`)
103+
on `LIVE_WORD_MS`; gated turns still read `waiting` or `stopping`.
104+
`ACTIVITY_STATES` exported from the session chrome module is the source
105+
of truth for what the slot can say, not this list. It is led by a single
106+
density cell
104107
(`rampPulse`, `src/tui/ramp.ts`). The cell, not the word,
105108
is what says whether the session is healthy, and it carries four states:
106109

@@ -120,16 +123,16 @@ printed identically, so the only way to tell them apart was to wait.
120123
waiting on something outside itself — and are told apart by motion: `blocked`
121124
holds perfectly still, which is the signal that the session is waiting on _you_.
122125

123-
While fleet agents are running, the slot reports the _fleet_, not the parent.
124-
`resolveTurnLabel` and `resolveRampPhase` take a `FleetProgress` roll-up and
125-
rank it above the parent's own stall clock: with live lanes the parent is
126-
idle by design, so its silence says nothing about whether the session is
127-
progressing, and reporting it was how a session with every lane wedged still
128-
read as `working`. A fleet with no stalled lane reads `working`; one
129-
stalled lane makes the whole indicator read `stalled`, which is the state that
130-
should pull an operator's eye to the panel. A blocked gate and a stopping turn
131-
still outrank the fleet. With zero running fleet agents the roll-up is empty and
132-
every path through both functions behaves exactly as it does for a plain
126+
While fleet agents are running, the slot stays live even when the parent
127+
turn has settled (idle-with-fleet). `resolveTurnLabel` and
128+
`resolveRampPhase` take a `FleetProgress` roll-up plus session occupancy:
129+
with live lanes the parent is idle by design, so its silence says nothing
130+
about whether the session is progressing, and blanking the lockup made a
131+
busy fleet look hung. Occupied sessions keep cycling a live-activity
132+
word; recovery stays silent rather than painting `stalled`. A blocked
133+
gate and a stopping turn still outrank the fleet. With zero running fleet
134+
agents and no pending continuation the roll-up is empty and every path
135+
through both functions behaves exactly as it does for a plain
133136
single-agent turn.
134137

135138
The stall phase is driven by the watchdog's own silence clock
@@ -255,8 +258,10 @@ Parent prose owns success narratives. Transcript fleet notices exist only for
255258
attention live spawn_agent rows cannot keep: a lane **failed** or **cancelled**
256259
while other work is still running, and **one** dry-fleet line when the last
257260
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`).
261+
(`N done`; failed and cancelled counts appear only
262+
when non-zero, e.g. `N done, M failed, K cancelled`).
263+
The line does not claim the run is idle — the parent often continues.
264+
The prompt-box lockup is what names that occupation, not this tally.
260265
Per-lane `done — summary` walls and live `dispatched` re-announcements
261266
are never printed. That dry-fleet line stays operator-facing. If tasks
262267
are still todo/doing, the runtime re-enters the parent with collected

src/subagent/fleet-report.test.ts

Lines changed: 8 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", () => {
@@ -236,11 +234,11 @@ describe("fleetDigest", () => {
236234
expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed");
237235
});
238236

239-
test("a fleet with nothing left running says so rather than going blank", () => {
237+
test("a dry fleet is the outcome tally, not an idle claim", () => {
240238
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
241-
"nothing running · 1 done",
239+
"1 done",
242240
);
243-
expect(fleetDigest([], T0)).toBe("nothing running");
241+
expect(fleetDigest([], T0)).toBe("");
244242
});
245243

246244
test("cancelled lanes are named separately from failed", () => {
@@ -252,7 +250,7 @@ describe("fleetDigest", () => {
252250
],
253251
T0,
254252
),
255-
).toBe("nothing running · 1 failed · 1 cancelled");
253+
).toBe("1 failed · 1 cancelled");
256254
});
257255
});
258256

src/subagent/fleet-report.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ type Change =
185185
| { readonly kind: "cancelled"; readonly line: string }
186186
| { readonly kind: "stalled"; readonly line: string };
187187

188+
export interface FleetReportOptions {
189+
readonly stallMs?: number;
190+
}
191+
188192
export interface FleetObservation {
189193
readonly watch: FleetWatch;
190194
/** Ready-to-print lines, already coalesced. Usually empty. */
@@ -195,8 +199,9 @@ export function observeFleet(
195199
previous: FleetWatch,
196200
lanes: readonly FleetLane[],
197201
nowMs: number,
198-
stallMs: number = DEFAULT_STALL_MS,
202+
options: FleetReportOptions = {},
199203
): FleetObservation {
204+
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
200205
const marks = new Map<string, LaneMark>();
201206
const changes: Change[] = [];
202207
let running = 0;
@@ -262,9 +267,7 @@ export function observeFleet(
262267
if (wentDry) {
263268
return {
264269
watch,
265-
updates: [
266-
clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS),
267-
],
270+
updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)],
268271
};
269272
}
270273

@@ -355,14 +358,12 @@ function idleSummary(lanes: readonly FleetLane[]): string {
355358
export function fleetDigest(
356359
lanes: readonly FleetLane[],
357360
nowMs: number,
358-
stallMs: number = DEFAULT_STALL_MS,
361+
options: FleetReportOptions = {},
359362
): string {
360-
if (lanes.length === 0) return "nothing running";
363+
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
361364
const running = lanes.filter((l) => l.status === "running");
362365
const parts: string[] = [];
363-
if (running.length === 0) {
364-
parts.push("nothing running");
365-
} else {
366+
if (running.length > 0) {
366367
const named = running
367368
.slice(0, DIGEST_NAMED_LANES)
368369
.map((lane) => {

src/subagent/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
pendingAskWakeText,
2323
type FleetLane,
2424
type FleetObservation,
25+
type FleetReportOptions,
2526
type FleetWatch,
2627
type PendingAskWake,
2728
} from "./fleet-report.js";

src/tui/commands/built-in.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ describe("/status command", () => {
7979
});
8080
});
8181

82+
it("noops when the live fleet digest is empty so idle /status paints no blank row", () => {
83+
const ctx: CommandContext = {
84+
signalClear: () => undefined,
85+
getFleetStatus: () => "",
86+
};
87+
expect(defined(getCommand("status"), "status").handler("", ctx)).toEqual({
88+
type: "noop",
89+
});
90+
});
91+
8292
it("says so rather than throwing when no fleet source is wired", () => {
8393
expect(
8494
defined(getCommand("status"), "status").handler("", makeCtx()),

src/tui/commands/built-in.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ export function registerBuiltInCommands(): void {
146146
text: "Fleet status is not available in this session.",
147147
};
148148
}
149+
if (status.length === 0) {
150+
return { type: "noop" };
151+
}
149152
return { type: "message", text: status };
150153
},
151154
});

src/tui/runtime-bridge.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { streamRowCount } from "./shell/transcript";
1414
import { STEER_WAIT_NOTICE_MS } from "./notice-line";
1515
import { withTestRenderer } from "./harness";
1616
import { badgeCount } from "./session-queue";
17+
import { LIVE_ACTIVITY_WORDS } from "./session-chrome";
1718

1819
describe("mapReactorLike", () => {
1920
test("message.received → user", () => {
@@ -1377,6 +1378,12 @@ describe("idle-with-fleet (CL-7057)", () => {
13771378
// The parent turn settled but the fleet is live: the run stays
13781379
// busy and the follow-up does not drain at mere parent-idle.
13791380
expect(shell.session.run).toBe("busy");
1381+
expect(shell.lockupPhase).not.toBeNull();
1382+
expect(
1383+
(LIVE_ACTIVITY_WORDS as readonly string[]).includes(
1384+
shell.lockupPhase ?? "",
1385+
),
1386+
).toBe(true);
13801387
expect(badgeCount(shell.session)).toBe(1);
13811388
expect(port.calls).toEqual([]);
13821389
await h.renderOnce();
@@ -1616,9 +1623,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
16161623
bridge.handle({ type: "fleet", running: 0 });
16171624
expect(drives).toBe(0);
16181625
expect(shell.session.run).toBe("busy");
1626+
expect(shell.lockupPhase).not.toBeNull();
16191627
settleToollessTurn(bridge);
16201628
expect(drives).toBe(1);
16211629
expect(shell.session.run).toBe("busy");
1630+
expect(shell.lockupPhase).not.toBeNull();
16221631
bridge.submit("when it finishes, summarize", "queue");
16231632
expect(badgeCount(shell.session)).toBe(1);
16241633
port.clear();

src/tui/runtime-bridge.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,11 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
10111011
}
10121012
}
10131013

1014+
function occupancyHold(bag: BridgeBag, runBusy: boolean): boolean {
1015+
if (bag.liveFleet > 0 || bag.awaitingContinuationInference) return true;
1016+
return runBusy && bag.pendingDryOpenDrive;
1017+
}
1018+
10141019
/**
10151020
* Release the run to idle and drain everything queued — but only at true
10161021
* session-idle. A live fleet holds the run busy after the parent turn settles
@@ -1286,15 +1291,18 @@ export function attachSessionBridge(
12861291
status: turn.status,
12871292
currentToolName: turn.currentToolName,
12881293
streamingType: turn.streamingType,
1294+
nowMs,
1295+
sessionActive: occupancyHold(bag, shell.session.run === "busy"),
12891296
};
12901297
const fleet = fleetProgress(bag.agentSessions, nowMs);
12911298
const label = resolveTurnLabel(input, isStalled, fleet);
1299+
const sessionLive = label !== undefined;
12921300
if (label === undefined) {
12931301
// The bottom-left status slot rides the same re-entry as the landing
12941302
// mark, so it crossfades between phases without a timer of its own.
12951303
setLockupFrame(shell, {
12961304
nowMs,
1297-
animating: turn.isProcessing,
1305+
animating: false,
12981306
phase: null,
12991307
rampPhase: null,
13001308
stalledForMs: null,
@@ -1309,7 +1317,7 @@ export function attachSessionBridge(
13091317
const stalledFor = stalledForMs(nowMs, rampPhase === "stalled");
13101318
setLockupFrame(shell, {
13111319
nowMs,
1312-
animating: turn.isProcessing,
1320+
animating: sessionLive,
13131321
phase: label,
13141322
rampPhase,
13151323
stalledForMs: stalledFor,
@@ -1383,13 +1391,19 @@ export function attachSessionBridge(
13831391
if (onTurnBoundary(event) && bag.turn.activeToolCalls.length > 0) {
13841392
drainLiveSteersAtBoundary(shell, bag);
13851393
}
1386-
if (settled) settleRun();
1394+
if (settled) {
1395+
settleRun();
1396+
paintPhase();
1397+
}
13871398
return;
13881399
}
13891400
if (isBridgeInbound(event)) {
13901401
applyInbound(shell, bag, event);
13911402
}
1392-
if (settled) settleRun();
1403+
if (settled) {
1404+
settleRun();
1405+
paintPhase();
1406+
}
13931407
};
13941408

13951409
const recordLastSent = (

0 commit comments

Comments
 (0)