Skip to content

Commit c89dbf7

Browse files
committed
fix(tui): count cancelled lanes with interrupted lifecycle
projectLifecycleStatus maps cancelled to interrupted, so outcomeCounts skipped cancelAll workers. Skip only live occupancy leftovers, not cancelled terminals.
1 parent 1cc4d07 commit c89dbf7

3 files changed

Lines changed: 142 additions & 17 deletions

File tree

‎docs/TUI.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,11 @@ 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`.
320+
Interrupted leftovers (TUI status still `running`, lifecycle interrupted)
321+
are occupancy leftovers, not a finished outcome, so interrupt-all does not
322+
print `0 done`. Operator cancel (`status: cancelled`, lifecycle still
323+
projected as interrupted) is a finished outcome and is counted
324+
(`0 done, K cancelled` on idle; digest names `K cancelled`).
322325
The line does not claim the run is idle — the parent often continues.
323326
The prompt-box lockup is what names that occupation, not this tally.
324327
Per-lane `done — summary` walls and live `dispatched` re-announcements

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

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,16 @@ describe("observeFleet", () => {
241241
const { watch, updates } = observeFleet(
242242
seeded,
243243
[
244-
lane({ id: "api", lifecycleStatus: "interrupted" }),
245-
lane({ id: "docs", lifecycleStatus: "interrupted" }),
244+
lane({
245+
id: "api",
246+
status: "running",
247+
lifecycleStatus: "interrupted",
248+
}),
249+
lane({
250+
id: "docs",
251+
status: "running",
252+
lifecycleStatus: "interrupted",
253+
}),
246254
],
247255
T0 + 1000,
248256
);
@@ -251,6 +259,58 @@ describe("observeFleet", () => {
251259
expect(updates).toEqual([]);
252260
});
253261

262+
test("cancel-all counts cancelled even when lifecycleStatus is interrupted", () => {
263+
const seeded = observeFleet(
264+
createFleetWatch(),
265+
[lane({ id: "api" }), lane({ id: "docs" })],
266+
T0,
267+
).watch;
268+
const { watch, updates } = observeFleet(
269+
seeded,
270+
[
271+
lane({
272+
id: "api",
273+
status: "cancelled",
274+
lifecycleStatus: "interrupted",
275+
}),
276+
lane({
277+
id: "docs",
278+
status: "cancelled",
279+
lifecycleStatus: "interrupted",
280+
}),
281+
],
282+
T0 + 1000,
283+
);
284+
expect(watch.running).toBe(0);
285+
expect(updates).toEqual(["0 done, 2 cancelled"]);
286+
});
287+
288+
test("a mixed dry fleet counts done and cancelled with interrupted lifecycle", () => {
289+
const seeded = observeFleet(
290+
createFleetWatch(),
291+
[lane({ id: "api" }), lane({ id: "docs" })],
292+
T0,
293+
).watch;
294+
const { updates } = observeFleet(
295+
seeded,
296+
[
297+
lane({
298+
id: "api",
299+
status: "done",
300+
lifecycleStatus: "completed",
301+
report: "ok",
302+
}),
303+
lane({
304+
id: "docs",
305+
status: "cancelled",
306+
lifecycleStatus: "interrupted",
307+
}),
308+
],
309+
T0 + 1000,
310+
);
311+
expect(updates).toEqual(["1 done, 1 cancelled"]);
312+
});
313+
254314
test("a mixed dry fleet does not count interrupted leftovers as done", () => {
255315
const seeded = observeFleet(
256316
createFleetWatch(),
@@ -260,8 +320,17 @@ describe("observeFleet", () => {
260320
const { updates } = observeFleet(
261321
seeded,
262322
[
263-
lane({ id: "api", status: "done", report: "ok" }),
264-
lane({ id: "docs", lifecycleStatus: "interrupted" }),
323+
lane({
324+
id: "api",
325+
status: "done",
326+
lifecycleStatus: "completed",
327+
report: "ok",
328+
}),
329+
lane({
330+
id: "docs",
331+
status: "running",
332+
lifecycleStatus: "interrupted",
333+
}),
265334
],
266335
T0 + 1000,
267336
);
@@ -295,8 +364,16 @@ describe("fleetDigest", () => {
295364
expect(
296365
fleetDigest(
297366
[
298-
lane({ id: "api", lifecycleStatus: "interrupted" }),
299-
lane({ id: "docs", lifecycleStatus: "interrupted" }),
367+
lane({
368+
id: "api",
369+
status: "running",
370+
lifecycleStatus: "interrupted",
371+
}),
372+
lane({
373+
id: "docs",
374+
status: "running",
375+
lifecycleStatus: "interrupted",
376+
}),
300377
],
301378
T0,
302379
),
@@ -307,13 +384,61 @@ describe("fleetDigest", () => {
307384
expect(
308385
fleetDigest(
309386
[
310-
lane({ id: "api", status: "cancelled" }),
311-
lane({ id: "cli", status: "failed" }),
387+
lane({
388+
id: "api",
389+
status: "cancelled",
390+
lifecycleStatus: "interrupted",
391+
}),
392+
lane({
393+
id: "cli",
394+
status: "failed",
395+
lifecycleStatus: "shutdown",
396+
}),
312397
],
313398
T0,
314399
),
315400
).toBe("1 failed · 1 cancelled");
316401
});
402+
403+
test("a cancelled-only fleet names cancelled even when lifecycle is interrupted", () => {
404+
expect(
405+
fleetDigest(
406+
[
407+
lane({
408+
id: "api",
409+
status: "cancelled",
410+
lifecycleStatus: "interrupted",
411+
}),
412+
lane({
413+
id: "docs",
414+
status: "cancelled",
415+
lifecycleStatus: "interrupted",
416+
}),
417+
],
418+
T0,
419+
),
420+
).toBe("2 cancelled");
421+
});
422+
423+
test("mixed done and cancelled-with-interrupted lifecycle counts both", () => {
424+
expect(
425+
fleetDigest(
426+
[
427+
lane({
428+
id: "api",
429+
status: "done",
430+
lifecycleStatus: "completed",
431+
}),
432+
lane({
433+
id: "docs",
434+
status: "cancelled",
435+
lifecycleStatus: "interrupted",
436+
}),
437+
],
438+
T0,
439+
),
440+
).toBe("1 done · 1 cancelled");
441+
});
317442
});
318443

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

‎src/subagent/fleet-report.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,10 @@ function outcomeCounts(lanes: readonly FleetLane[]): OutcomeCounts {
323323
let failed = 0;
324324
let cancelled = 0;
325325
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-
}
326+
// Occupancy leftovers (still TUI-running, including interrupted leftovers)
327+
// are not finished outcomes. Cancelled workers project lifecycleStatus
328+
// interrupted too — skip on live/running, not on interrupted alone.
329+
if (agentLaneIsLive(lane) || lane.status === "running") continue;
331330
switch (lane.status) {
332331
case "done":
333332
done += 1;
@@ -338,8 +337,6 @@ function outcomeCounts(lanes: readonly FleetLane[]): OutcomeCounts {
338337
case "cancelled":
339338
cancelled += 1;
340339
break;
341-
case "running":
342-
break;
343340
}
344341
}
345342
return { done, failed, cancelled };

0 commit comments

Comments
 (0)