Skip to content

Commit 1cc4d07

Browse files
committed
fix(tui): keep occupancy and idle-with-fleet on live lanes
Interrupted workers are not live occupancy and are not a dry-fleet done count. First assemble and new/clear resync idle-with-fleet from the live count instead of only on count change.
1 parent 445886d commit 1cc4d07

8 files changed

Lines changed: 282 additions & 23 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name
210210

211211
- **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.
212212

213-
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. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
213+
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 (same liveness rule as the progress strip: interrupted leftovers are not live); 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. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
214214

215215
`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.
216216

‎docs/TUI.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,8 @@ while other work is still running, and **one** dry-fleet line when the last
317317
lane finishes
318318
(`N done`; failed and cancelled counts appear only
319319
when non-zero, e.g. `N done, M failed, K cancelled`).
320+
Interrupted leftovers use the same liveness rule as occupancy: they are
321+
not a finished outcome, so interrupt-all does not print `0 done`.
320322
The line does not claim the run is idle — the parent often continues.
321323
The prompt-box lockup is what names that occupation, not this tally.
322324
Per-lane `done — summary` walls and live `dispatched` re-announcements
@@ -690,7 +692,8 @@ unchanged.
690692

691693
**Idle-with-fleet** is shipped. After a non-blocking `spawn_agent` dispatch
692694
the parent turn settles while workers keep running; the runner emits `fleet`
693-
events carrying the live-lane count and the bridge holds the run busy on it.
695+
events carrying the live-lane count (interrupted leftovers are not live)
696+
and the bridge holds the run busy on it.
694697
During the hold, Enter upgrades to a new primary turn sent immediately —
695698
there is no parent tool left to steer — while Alt+Enter follow-ups keep
696699
waiting for true session-idle. A child done or fail while siblings still run

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,23 @@ describe("liveFleetCount", () => {
3434
expect(liveFleetCount(lanes)).toBe(2);
3535
});
3636

37+
test("interrupted leftovers are not live occupancy", () => {
38+
const lanes = [
39+
lane({ id: "a", lifecycleStatus: "interrupted" }),
40+
lane({ id: "b", status: "done", report: "x" }),
41+
lane({ id: "c" }),
42+
];
43+
expect(liveFleetCount(lanes)).toBe(1);
44+
});
45+
46+
test("interrupt-all with no running workers is live count 0", () => {
47+
const lanes = [
48+
lane({ id: "a", lifecycleStatus: "interrupted" }),
49+
lane({ id: "b", lifecycleStatus: "interrupted" }),
50+
];
51+
expect(liveFleetCount(lanes)).toBe(0);
52+
});
53+
3754
test("an empty or fully-terminal fleet counts zero", () => {
3855
expect(liveFleetCount([])).toBe(0);
3956
expect(
@@ -214,6 +231,42 @@ describe("observeFleet", () => {
214231
const { updates } = observeFleet(seeded, after, T0 + 1000);
215232
expect(updates).toEqual(["2 failed, 2 cancelled"]);
216233
});
234+
235+
test("interrupt-all does not tally interrupted leftovers as 0 done", () => {
236+
const seeded = observeFleet(
237+
createFleetWatch(),
238+
[lane({ id: "api" }), lane({ id: "docs" })],
239+
T0,
240+
).watch;
241+
const { watch, updates } = observeFleet(
242+
seeded,
243+
[
244+
lane({ id: "api", lifecycleStatus: "interrupted" }),
245+
lane({ id: "docs", lifecycleStatus: "interrupted" }),
246+
],
247+
T0 + 1000,
248+
);
249+
expect(watch.running).toBe(0);
250+
expect(updates.join(" ")).not.toContain("0 done");
251+
expect(updates).toEqual([]);
252+
});
253+
254+
test("a mixed dry fleet does not count interrupted leftovers as done", () => {
255+
const seeded = observeFleet(
256+
createFleetWatch(),
257+
[lane({ id: "api" }), lane({ id: "docs" })],
258+
T0,
259+
).watch;
260+
const { updates } = observeFleet(
261+
seeded,
262+
[
263+
lane({ id: "api", status: "done", report: "ok" }),
264+
lane({ id: "docs", lifecycleStatus: "interrupted" }),
265+
],
266+
T0 + 1000,
267+
);
268+
expect(updates).toEqual(["1 done"]);
269+
});
217270
});
218271

219272
describe("fleetDigest", () => {
@@ -239,6 +292,15 @@ describe("fleetDigest", () => {
239292
"1 done",
240293
);
241294
expect(fleetDigest([], T0)).toBe("");
295+
expect(
296+
fleetDigest(
297+
[
298+
lane({ id: "api", lifecycleStatus: "interrupted" }),
299+
lane({ id: "docs", lifecycleStatus: "interrupted" }),
300+
],
301+
T0,
302+
),
303+
).toBe("");
242304
});
243305

244306
test("cancelled lanes are named separately from failed", () => {

‎src/subagent/fleet-report.ts‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,23 @@
1313
*/
1414

1515
import {
16+
agentLaneIsLive,
1617
agentProgress,
1718
clockLabel,
1819
DEFAULT_STALL_MS,
1920
} from "../tui/agent-progress.js";
20-
import type { SubAgentSessionStatus } from "./session-store.js";
21+
import type {
22+
AgentLifecycleStatus,
23+
SubAgentSessionStatus,
24+
} from "./session-store.js";
2125

2226
/** The lane fields a report is written from. `SubAgentSession` satisfies it. */
2327
export interface FleetLane {
2428
readonly id: string;
2529
readonly description: string;
2630
readonly status: SubAgentSessionStatus;
31+
/** Same projection the progress strip uses; interrupted leftovers are not live. */
32+
readonly lifecycleStatus?: AgentLifecycleStatus;
2733
readonly startedAt: number;
2834
readonly lastActivityAt: number;
2935
readonly currentToolName: string | null;
@@ -117,12 +123,12 @@ function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean {
117123
}
118124

119125
/**
120-
* Lanes still running — the count the idle-with-fleet hold reads (CL-7057).
121-
* One definition lives here so the bridge feed and any other liveness reader
122-
* cannot drift from what the strip and digest call a running lane.
126+
* Lanes still live — the count the idle-with-fleet hold reads (CL-7057).
127+
* Same rule as the progress strip (`agentLaneIsLive`): interrupted leftovers
128+
* keep TUI status "running" but are not occupancy.
123129
*/
124130
export function liveFleetCount(lanes: readonly FleetLane[]): number {
125-
return lanes.filter((lane) => lane.status === "running").length;
131+
return lanes.filter((lane) => agentLaneIsLive(lane)).length;
126132
}
127133

128134
/**
@@ -207,10 +213,10 @@ export function observeFleet(
207213
let running = 0;
208214

209215
for (const lane of lanes) {
210-
if (lane.status === "running") running += 1;
216+
if (agentLaneIsLive(lane)) running += 1;
211217
const before = previous.lanes.get(lane.id);
212218
const stalled =
213-
lane.status === "running" &&
219+
agentLaneIsLive(lane) &&
214220
(before?.stallReported === true || isStalled(lane, nowMs, stallMs));
215221
marks.set(lane.id, { status: lane.status, stallReported: stalled });
216222

@@ -265,9 +271,10 @@ export function observeFleet(
265271
// only: fail/cancel/stall while work is still running, or one dry-fleet tally.
266272
// Never per-lane "done — summary" walls.
267273
if (wentDry) {
274+
const summary = idleSummary(lanes);
268275
return {
269276
watch,
270-
updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)],
277+
updates: summary.length === 0 ? [] : [clip(summary, MAX_UPDATE_CHARS)],
271278
};
272279
}
273280

@@ -316,6 +323,11 @@ function outcomeCounts(lanes: readonly FleetLane[]): OutcomeCounts {
316323
let failed = 0;
317324
let cancelled = 0;
318325
for (const lane of lanes) {
326+
// Same liveness as liveFleetCount: interrupted leftovers are not occupancy
327+
// and are not a finished outcome, even if TUI status is still "running".
328+
if (agentLaneIsLive(lane) || lane.lifecycleStatus === "interrupted") {
329+
continue;
330+
}
319331
switch (lane.status) {
320332
case "done":
321333
done += 1;
@@ -346,8 +358,9 @@ function formatOutcomeParts(
346358
}
347359

348360
function idleSummary(lanes: readonly FleetLane[]): string {
349-
return formatOutcomeParts(outcomeCounts(lanes), {
350-
includeZeroDone: true,
361+
const counts = outcomeCounts(lanes);
362+
return formatOutcomeParts(counts, {
363+
includeZeroDone: counts.failed > 0 || counts.cancelled > 0,
351364
}).join(", ");
352365
}
353366

@@ -361,7 +374,7 @@ export function fleetDigest(
361374
options: FleetReportOptions = {},
362375
): string {
363376
const stallMs = options.stallMs ?? DEFAULT_STALL_MS;
364-
const running = lanes.filter((l) => l.status === "running");
377+
const running = lanes.filter((l) => agentLaneIsLive(l));
365378
const parts: string[] = [];
366379
if (running.length > 0) {
367380
const named = running

‎src/tui/runner/exit.test.ts‎

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { InferenceSource } from "@intx/types/runtime";
66

77
import * as codexSession from "../../auth/codex/session.js";
88
import { createChatDirector } from "../../agent/director.js";
9+
import * as sessionIndex from "../../session/index.js";
910
import { createSubAgentSessionStore } from "../../subagent/session-store.js";
1011
import type {
1112
ReactorAction,
@@ -23,6 +24,7 @@ import {
2324
createRunLifecycle,
2425
finalizeTUIRun,
2526
resetSessionForRotation,
27+
resyncIdleWithFleetFlag,
2628
} from "./exit.js";
2729
import type { RunnerServices, RunnerState } from "./state.js";
2830

@@ -201,7 +203,8 @@ function stubSendLifecycle(agent: Agent): {
201203
sessionOps: createSessionOperationQueue(),
202204
deliveryGeneration: createDeliveryGeneration(),
203205
toolset: { setToolPromoter: () => undefined },
204-
subAgentSessions: { cancelAll: async () => [] },
206+
directorHolder: {},
207+
subAgentSessions: { cancelAll: async () => [], list: () => [] },
205208
activeRunHandle: { task: "", startedAt: 0, model: "" },
206209
} as unknown as RunnerServices;
207210
return { state, services };
@@ -326,6 +329,71 @@ function rebuildTextTurn(): ReactorInboundEvent {
326329
}
327330

328331
describe("rebuild re-syncs idle-with-fleet while drained", () => {
332+
test("seed then fleet-0 does not leave idle-with-fleet stuck true", async () => {
333+
const store = createSubAgentSessionStore();
334+
const director = createChatDirector("base", [], {
335+
allowIdleWithFleet: true,
336+
});
337+
resyncIdleWithFleetFlag({
338+
directorHolder: { instance: director },
339+
subAgentSessions: store,
340+
});
341+
await director.decide(
342+
rebuildManageTasksEvent(),
343+
rebuildMockState,
344+
rebuildMockCapabilities,
345+
);
346+
const actions = await director.decide(
347+
rebuildTextTurn(),
348+
rebuildMockState,
349+
rebuildMockCapabilities,
350+
);
351+
const list = Array.isArray(actions) ? actions : [actions];
352+
expect(list.some((action) => action.type === "infer")).toBe(true);
353+
});
354+
355+
test("first assemble with a drained fleet does not leave idle-with-fleet stuck true", async () => {
356+
const store = createSubAgentSessionStore();
357+
const directorHolder: RunnerServices["directorHolder"] = {};
358+
const agent = recordingAgent([]);
359+
const { state, services } = stubSendLifecycle(agent);
360+
services.directorHolder =
361+
directorHolder as unknown as RunnerServices["directorHolder"];
362+
services.subAgentSessions =
363+
store as unknown as RunnerServices["subAgentSessions"];
364+
services.workflowHost = {
365+
reattach: () => undefined,
366+
} as unknown as RunnerServices["workflowHost"];
367+
services.cycleRecorder = {
368+
dispose: async () => "",
369+
reset: () => undefined,
370+
handleEvent: () => undefined,
371+
} as unknown as RunnerServices["cycleRecorder"];
372+
services.buildAgent = (async () => {
373+
directorHolder.instance = createChatDirector("base", [], {
374+
allowIdleWithFleet: true,
375+
});
376+
return agent;
377+
}) as unknown as RunnerServices["buildAgent"];
378+
await createRunLifecycle(state, services);
379+
const director = defined(
380+
directorHolder.instance,
381+
"directorHolder.instance",
382+
);
383+
await director.decide(
384+
rebuildManageTasksEvent(),
385+
rebuildMockState,
386+
rebuildMockCapabilities,
387+
);
388+
const actions = await director.decide(
389+
rebuildTextTurn(),
390+
rebuildMockState,
391+
rebuildMockCapabilities,
392+
);
393+
const list = Array.isArray(actions) ? actions : [actions];
394+
expect(list.some((action) => action.type === "infer")).toBe(true);
395+
});
396+
329397
test("reload-if-idle and interrupt rebuilds resume the open-task nudge with no fleet transition", async () => {
330398
const store = createSubAgentSessionStore();
331399
const directorHolder: RunnerServices["directorHolder"] = {};
@@ -388,4 +456,84 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => {
388456
expect(store.list()).toEqual([]);
389457
expect(fleetEvents).toEqual([]);
390458
});
459+
460+
test("newSession/clear with a drained fleet still nudges open tasks after rotation", async () => {
461+
const store = createSubAgentSessionStore();
462+
const directorHolder: RunnerServices["directorHolder"] = {};
463+
const agent = recordingAgent([]);
464+
const { state, services } = stubSendLifecycle(agent);
465+
services.directorHolder =
466+
directorHolder as unknown as RunnerServices["directorHolder"];
467+
services.subAgentSessions =
468+
store as unknown as RunnerServices["subAgentSessions"];
469+
services.workflowHost = {
470+
reattach: () => undefined,
471+
reset: () => undefined,
472+
} as unknown as RunnerServices["workflowHost"];
473+
services.cycleRecorder = {
474+
dispose: async () => "",
475+
reset: () => undefined,
476+
handleEvent: () => undefined,
477+
} as unknown as RunnerServices["cycleRecorder"];
478+
services.buildSessionSources = () => ({
479+
sources: [liveSource],
480+
defaultSource: liveSource.id,
481+
selected: liveSource,
482+
});
483+
services.permissionGate = {
484+
reset: () => undefined,
485+
} as unknown as RunnerServices["permissionGate"];
486+
services.runSink = {
487+
sink: () => undefined,
488+
reset: () => undefined,
489+
} as unknown as RunnerServices["runSink"];
490+
services.sessionCost = {
491+
addTurn: () => undefined,
492+
reset: () => undefined,
493+
} as unknown as RunnerServices["sessionCost"];
494+
services.activatedToolNames = {
495+
clear: () => undefined,
496+
activate: () => false,
497+
list: () => [],
498+
} as unknown as RunnerServices["activatedToolNames"];
499+
services.hostHolder = {} as unknown as RunnerServices["hostHolder"];
500+
services.buildAgent = (async () => {
501+
directorHolder.instance = createChatDirector("base", [], {
502+
allowIdleWithFleet: true,
503+
});
504+
return agent;
505+
}) as unknown as RunnerServices["buildAgent"];
506+
const initDir = spyOn(sessionIndex, "initSessionDir").mockImplementation(
507+
async () => "/tmp/rotated-session",
508+
);
509+
const contextDir = spyOn(
510+
sessionIndex,
511+
"sessionContextDir",
512+
).mockImplementation(() => "/tmp/rotated-session/context");
513+
try {
514+
await createRunLifecycle(state, services);
515+
defined(state.newSession, "newSession")();
516+
await services.sessionOps.awaitTail();
517+
expect(state.fatalBuildError).toBeNull();
518+
const director = defined(
519+
directorHolder.instance,
520+
"directorHolder.instance",
521+
);
522+
await director.decide(
523+
rebuildManageTasksEvent(),
524+
rebuildMockState,
525+
rebuildMockCapabilities,
526+
);
527+
const actions = await director.decide(
528+
rebuildTextTurn(),
529+
rebuildMockState,
530+
rebuildMockCapabilities,
531+
);
532+
const list = Array.isArray(actions) ? actions : [actions];
533+
expect(list.some((action) => action.type === "infer")).toBe(true);
534+
} finally {
535+
initDir.mockRestore();
536+
contextDir.mockRestore();
537+
}
538+
});
391539
});

0 commit comments

Comments
 (0)