Skip to content

Commit dde35ae

Browse files
committed
Keep occupation visible in the prompt lockup
A dry fleet no longer means the run is over, so the tally must not claim nothing is running. The lockup is what names that the parent or fleet is still working.
1 parent 6bdcd7c commit dde35ae

11 files changed

Lines changed: 182 additions & 175 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: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,17 @@ 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. `resolveTurnLabel`
101+
(`src/tui/session-chrome.ts`) maps execution onto the closed set
102+
`ACTIVITY_STATES` exported from that module. Live occupation cycles
103+
`LIVE_ACTIVITY_WORDS` (`working`, `warping`, `buzzing`, `grinding`,
104+
`thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`)
105+
on `LIVE_WORD_MS`; gated turns still read `waiting` or `stopping`.
106+
That export is the source of truth for what the slot can say, not this
107+
list. It is led by a single density cell
104108
(`rampPulse`, `src/tui/ramp.ts`). The cell, not the word,
105109
is what says whether the session is healthy, and it carries four states:
106110

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

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
127+
While fleet agents are running, the slot stays live even when the parent
128+
turn has settled (idle-with-fleet). `resolveTurnLabel` and
129+
`resolveRampPhase` take a `FleetProgress` roll-up plus session occupancy:
130+
with live lanes the parent is idle by design, so its silence says nothing
131+
about whether the session is progressing, and blanking the lockup made a
132+
busy fleet look hung. Occupied sessions keep cycling a live-activity
133+
word; recovery stays silent rather than painting `stalled`. A blocked
134+
gate and a stopping turn still outrank the fleet. With zero running fleet
135+
agents and no pending continuation the roll-up is empty and every path
136+
through both functions behaves exactly as it does for a plain
133137
single-agent turn.
134138

135139
The stall phase is driven by the watchdog's own silence clock
@@ -257,10 +261,8 @@ while other work is still running, and **one** dry-fleet line when the last
257261
lane finishes
258262
(`N done`; failed and cancelled counts appear only
259263
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.
264+
The line does not claim the run is idle — the parent often continues.
265+
The prompt-box lockup is what names that occupation, not this tally.
264266
Per-lane `done — summary` walls and live `dispatched` re-announcements
265267
are never printed. That dry-fleet line stays operator-facing. If tasks
266268
are still todo/doing, the runtime re-enters the parent with collected

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

Lines changed: 2 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -214,46 +214,6 @@ describe("observeFleet", () => {
214214
const { updates } = observeFleet(seeded, after, T0 + 1000);
215215
expect(updates).toEqual(["2 failed, 2 cancelled"]);
216216
});
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-
});
257217
});
258218

259219
describe("fleetDigest", () => {
@@ -274,10 +234,8 @@ describe("fleetDigest", () => {
274234
expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed");
275235
});
276236

277-
test("a dry fleet with outcomes is the tally only — idle does not claim the job closed", () => {
278-
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
279-
"1 done",
280-
);
237+
test("a dry fleet is the outcome tally, not an idle claim", () => {
238+
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe("1 done");
281239
expect(fleetDigest([], T0)).toBe("");
282240
});
283241

@@ -292,31 +250,6 @@ describe("fleetDigest", () => {
292250
),
293251
).toBe("1 failed · 1 cancelled");
294252
});
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");
319-
});
320253
});
321254

322255
describe("forced-stop reasons", () => {

‎src/subagent/fleet-report.ts‎

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -185,25 +185,23 @@ 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. */
191195
readonly updates: readonly string[];
192196
}
193197

194-
export interface FleetReportOpts {
195-
readonly stallMs?: number;
196-
readonly orchestratorContinuing?: boolean;
197-
}
198-
199198
export function observeFleet(
200199
previous: FleetWatch,
201200
lanes: readonly FleetLane[],
202201
nowMs: number,
203-
opts: FleetReportOpts = {},
202+
options: FleetReportOptions = {},
204203
): FleetObservation {
205-
const stallMs = opts.stallMs ?? DEFAULT_STALL_MS;
206-
const orchestratorContinuing = opts.orchestratorContinuing === true;
204+
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
207205
const marks = new Map<string, LaneMark>();
208206
const changes: Change[] = [];
209207
let running = 0;
@@ -269,9 +267,7 @@ export function observeFleet(
269267
if (wentDry) {
270268
return {
271269
watch,
272-
updates: [
273-
clip(dryFleetLine(lanes, orchestratorContinuing), MAX_UPDATE_CHARS),
274-
],
270+
updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)],
275271
};
276272
}
277273

@@ -355,34 +351,16 @@ function idleSummary(lanes: readonly FleetLane[]): string {
355351
}).join(", ");
356352
}
357353

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-
372354
/**
373355
* The answer to "where are we" on demand — the same picture the unprompted
374356
* lines build up to, in one row, so asking never costs an interrupt.
375357
*/
376358
export function fleetDigest(
377359
lanes: readonly FleetLane[],
378360
nowMs: number,
379-
opts: FleetReportOpts = {},
361+
options: FleetReportOptions = {},
380362
): string {
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-
}
363+
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
386364
const running = lanes.filter((l) => l.status === "running");
387365
const parts: string[] = [];
388366
if (running.length > 0) {
@@ -402,8 +380,5 @@ export function fleetDigest(
402380
parts.push(
403381
...formatOutcomeParts(outcomeCounts(lanes), { includeZeroDone: false }),
404382
);
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);
383+
return parts.join(" · ");
409384
}

‎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/runner/commands.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ 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";
3938
import { renameSession } from "../../session/index.js";
4039
import { truncateSessionLabel } from "../../session/session-label.js";
4140
import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js";
@@ -167,11 +166,7 @@ export function createCommandLayer(
167166
},
168167
startWorkflow: (name) => services.workflowHost.start(name),
169168
getFleetStatus: () =>
170-
fleetDigest(services.subAgentSessions.list(), Date.now(), {
171-
orchestratorContinuing:
172-
hostOf(state).bridge.turn.isProcessing ||
173-
hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []),
174-
}),
169+
fleetDigest(services.subAgentSessions.list(), Date.now()),
175170
renameSession: (name) => {
176171
const trimmed = name.trim();
177172
if (trimmed.length === 0) return "Session name cannot be empty";

‎src/tui/runner/wiring.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ 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";
3534
import { cycleReasoningEffort } from "../../provider/reasoning-effort.js";
3635
import { isCodexProviderName } from "../../config/codex-providers.js";
3736
import { RUNTIME_FLASH_MS } from "../runtime-notices.js";
@@ -182,11 +181,6 @@ export function wirePostStartup(
182181
fleetWatch,
183182
services.subAgentSessions.list(),
184183
Date.now(),
185-
{
186-
orchestratorContinuing:
187-
sessionBridge.turn.isProcessing ||
188-
hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []),
189-
},
190184
);
191185
fleetWatch = observation.watch;
192186
for (const update of observation.updates)

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 5 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,10 @@ 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((LIVE_ACTIVITY_WORDS as readonly string[]).includes(shell.lockupPhase ?? "")).toBe(
1383+
true,
1384+
);
13801385
expect(badgeCount(shell.session)).toBe(1);
13811386
expect(port.calls).toEqual([]);
13821387
await h.renderOnce();

‎src/tui/runtime-bridge.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,15 +1286,18 @@ export function attachSessionBridge(
12861286
status: turn.status,
12871287
currentToolName: turn.currentToolName,
12881288
streamingType: turn.streamingType,
1289+
nowMs,
1290+
sessionActive: bag.liveFleet > 0,
12891291
};
12901292
const fleet = fleetProgress(bag.agentSessions, nowMs);
12911293
const label = resolveTurnLabel(input, isStalled, fleet);
1294+
const sessionLive = label !== undefined;
12921295
if (label === undefined) {
12931296
// The bottom-left status slot rides the same re-entry as the landing
12941297
// mark, so it crossfades between phases without a timer of its own.
12951298
setLockupFrame(shell, {
12961299
nowMs,
1297-
animating: turn.isProcessing,
1300+
animating: false,
12981301
phase: null,
12991302
rampPhase: null,
13001303
stalledForMs: null,
@@ -1309,7 +1312,7 @@ export function attachSessionBridge(
13091312
const stalledFor = stalledForMs(nowMs, rampPhase === "stalled");
13101313
setLockupFrame(shell, {
13111314
nowMs,
1312-
animating: turn.isProcessing,
1315+
animating: sessionLive,
13131316
phase: label,
13141317
rampPhase,
13151318
stalledForMs: stalledFor,

0 commit comments

Comments
 (0)