Skip to content

Commit e3fd87c

Browse files
Merge pull request #873 from corbitsdev/cl-7625-drive-open-tasks-when-idle-parent-misses-the-fleet-dry-edge
Drive open-task occupancy when the fleet dry edge is missed
2 parents bf385d1 + 51530e0 commit e3fd87c

3 files changed

Lines changed: 215 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2121

2222
### Fixed
2323

24+
- Occupancy takes one dry-episode shot when the parent settles idle even if the
25+
live fleet 1→0 edge was never observed.
2426
- Dry-fleet transcript and `/status` report the outcome tally only
2527
(`2 done, 1 failed`). They no longer claim `nothing running` when the
2628
parent may still continue.

src/tui/runtime-bridge.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,193 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
17181718
);
17191719
});
17201720

1721+
test("already-dry settle drives once even without a live 1→0 fleet event", async () => {
1722+
await withTestRenderer(
1723+
async (h) => {
1724+
const shell = createAppShell(h.renderer, {
1725+
terminal: { columns: 80, rows: 24 },
1726+
wireKeys: false,
1727+
run: "idle",
1728+
});
1729+
const port = createRecordingPort();
1730+
const bridge = attachSessionBridge(shell, port);
1731+
try {
1732+
const prompt =
1733+
"The fleet has gone dry. Remaining open tasks:\n- t1: keep going (todo)\n";
1734+
let drives = 0;
1735+
bridge.setDryOpenTaskDriver(() => {
1736+
drives += 1;
1737+
bridge.beginSystemContinuation(prompt);
1738+
return true;
1739+
});
1740+
bridge.submit("dispatch workers", "immediate");
1741+
bridge.handle({ type: "fleet", running: 0 });
1742+
expect(drives).toBe(0);
1743+
expect(shell.session.run).toBe("busy");
1744+
settleToollessTurn(bridge);
1745+
expect(drives).toBe(1);
1746+
expect(shell.session.run).toBe("busy");
1747+
settleToollessTurn(bridge);
1748+
expect(drives).toBe(1);
1749+
} finally {
1750+
bridge.dispose();
1751+
shell.dispose();
1752+
}
1753+
},
1754+
{ width: 80, height: 24 },
1755+
);
1756+
});
1757+
1758+
test("a no-op dry settle does not eat the shot for a later missed 1→0 with open tasks", async () => {
1759+
await withTestRenderer(
1760+
async (h) => {
1761+
const shell = createAppShell(h.renderer, {
1762+
terminal: { columns: 80, rows: 24 },
1763+
wireKeys: false,
1764+
run: "idle",
1765+
});
1766+
const port = createRecordingPort();
1767+
const bridge = attachSessionBridge(shell, port);
1768+
try {
1769+
const prompt =
1770+
"The fleet has gone dry. Remaining open tasks:\n- t1: keep going (todo)\n";
1771+
let openTasks = false;
1772+
let drives = 0;
1773+
bridge.setDryOpenTaskDriver(() => {
1774+
if (!openTasks) return false;
1775+
drives += 1;
1776+
bridge.beginSystemContinuation(prompt);
1777+
return true;
1778+
});
1779+
bridge.submit("first turn, no todos", "immediate");
1780+
bridge.handle({ type: "fleet", running: 0 });
1781+
settleToollessTurn(bridge);
1782+
expect(drives).toBe(0);
1783+
expect(shell.session.run).toBe("idle");
1784+
1785+
openTasks = true;
1786+
bridge.submit("dispatch workers", "immediate");
1787+
bridge.handle({ type: "fleet", running: 0 });
1788+
expect(drives).toBe(0);
1789+
expect(shell.session.run).toBe("busy");
1790+
settleToollessTurn(bridge);
1791+
expect(drives).toBe(1);
1792+
expect(shell.session.run).toBe("busy");
1793+
settleToollessTurn(bridge);
1794+
expect(drives).toBe(1);
1795+
} finally {
1796+
bridge.dispose();
1797+
shell.dispose();
1798+
}
1799+
},
1800+
{ width: 80, height: 24 },
1801+
);
1802+
});
1803+
1804+
test("a new live lane resets the dry-episode latch for one more occupancy shot", async () => {
1805+
await withTestRenderer(
1806+
async (h) => {
1807+
const shell = createAppShell(h.renderer, {
1808+
terminal: { columns: 80, rows: 24 },
1809+
wireKeys: false,
1810+
run: "idle",
1811+
});
1812+
const port = createRecordingPort();
1813+
const bridge = attachSessionBridge(shell, port);
1814+
try {
1815+
const prompt =
1816+
"The fleet has gone dry. Remaining open tasks:\n- t1: keep going (todo)\n";
1817+
let drives = 0;
1818+
bridge.setDryOpenTaskDriver(() => {
1819+
drives += 1;
1820+
bridge.beginSystemContinuation(prompt);
1821+
return true;
1822+
});
1823+
bridge.submit("dispatch workers", "immediate");
1824+
settleToollessTurn(bridge);
1825+
expect(drives).toBe(1);
1826+
expect(shell.session.run).toBe("busy");
1827+
settleToollessTurn(bridge);
1828+
expect(drives).toBe(1);
1829+
expect(shell.session.run).toBe("idle");
1830+
1831+
bridge.submit("dispatch more workers", "immediate");
1832+
bridge.handle({ type: "fleet", running: 1 });
1833+
settleToollessTurn(bridge);
1834+
expect(drives).toBe(1);
1835+
expect(shell.session.run).toBe("busy");
1836+
bridge.handle({ type: "fleet", running: 0 });
1837+
expect(drives).toBe(2);
1838+
expect(shell.session.run).toBe("busy");
1839+
} finally {
1840+
bridge.dispose();
1841+
shell.dispose();
1842+
}
1843+
},
1844+
{ width: 80, height: 24 },
1845+
);
1846+
});
1847+
1848+
test("parent still processing does not take the occupancy shot", async () => {
1849+
await withTestRenderer(
1850+
async (h) => {
1851+
const shell = createAppShell(h.renderer, {
1852+
terminal: { columns: 80, rows: 24 },
1853+
wireKeys: false,
1854+
run: "idle",
1855+
});
1856+
const port = createRecordingPort();
1857+
const bridge = attachSessionBridge(shell, port);
1858+
try {
1859+
let drives = 0;
1860+
bridge.setDryOpenTaskDriver(() => {
1861+
drives += 1;
1862+
return true;
1863+
});
1864+
bridge.submit("dispatch workers", "immediate");
1865+
bridge.handle({ type: "fleet", running: 0 });
1866+
expect(drives).toBe(0);
1867+
expect(shell.session.run).toBe("busy");
1868+
expect(bridge.turn.isProcessing).toBe(true);
1869+
} finally {
1870+
bridge.dispose();
1871+
shell.dispose();
1872+
}
1873+
},
1874+
{ width: 80, height: 24 },
1875+
);
1876+
});
1877+
1878+
test("live fleet still blocks the occupancy drive", async () => {
1879+
await withTestRenderer(
1880+
async (h) => {
1881+
const shell = createAppShell(h.renderer, {
1882+
terminal: { columns: 80, rows: 24 },
1883+
wireKeys: false,
1884+
run: "idle",
1885+
});
1886+
const port = createRecordingPort();
1887+
const bridge = attachSessionBridge(shell, port);
1888+
try {
1889+
let drives = 0;
1890+
bridge.setDryOpenTaskDriver(() => {
1891+
drives += 1;
1892+
return true;
1893+
});
1894+
bridge.submit("dispatch workers", "immediate");
1895+
bridge.handle({ type: "fleet", running: 1 });
1896+
settleToollessTurn(bridge);
1897+
expect(drives).toBe(0);
1898+
expect(shell.session.run).toBe("busy");
1899+
} finally {
1900+
bridge.dispose();
1901+
shell.dispose();
1902+
}
1903+
},
1904+
{ width: 80, height: 24 },
1905+
);
1906+
});
1907+
17211908
test("occupancy send failure re-arms, clears continuation hold, and drains follow-ups", async () => {
17221909
await withTestRenderer(
17231910
async (h) => {

src/tui/runtime-bridge.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,15 @@ export interface SessionBridge {
252252
beginSystemContinuation: (text: string) => void;
253253
/**
254254
* Occupancy send failed after beginSystemContinuation. Drop the occupancy
255-
* echo so a later matching inbound is not swallowed, re-arm the dry-open
255+
* echo so a later matching inbound is not swallowed, re-arm the dry-episode
256256
* latch, drop the continuation hold, and idle so follow-ups can drain and a
257257
* later settle can take another occupancy shot.
258258
*/
259259
abortSystemContinuation: () => void;
260260
/**
261-
* Occupancy owner for dry+open continuation. Called once from
262-
* settleRunToIdle when a latched fleet-dry edge is still dry. Return true
263-
* if a continuation was sent (run stays busy).
261+
* Occupancy owner for dry+open continuation. Called once per dry episode
262+
* from settleRunToIdle when the fleet is dry. Return true if a continuation
263+
* was sent (run stays busy).
264264
*/
265265
setDryOpenTaskDriver: (driver: (() => boolean) | undefined) => void;
266266
}
@@ -425,18 +425,19 @@ export interface BridgeBag {
425425
*/
426426
flushPendingAskWake: (() => void) | null;
427427
/**
428-
* One deferred occupancy shot for the last live-fleet → 0 edge. Consumed on
429-
* settle so a missed wentDry while the parent was processing still drives
430-
* once, and a later settle cannot loop.
428+
* One occupancy shot per dry episode. Reset when a live lane starts. Consumed
429+
* only when the driver actually sends a continuation — a no-op (no open
430+
* tasks) must not eat the shot, or a later missed 1→0 with leftover tasks
431+
* never drives. A later settle after a true drive cannot loop.
431432
*/
432-
pendingDryOpenDrive: boolean;
433+
droveOpenTasksThisDry: boolean;
433434
/**
434435
* beginSystemContinuation re-armed the turn during the previous cycle's
435436
* settle. Late connector.reply from that cycle must not settle this one
436437
* until its own inference.start arrives.
437438
*/
438439
awaitingContinuationInference: boolean;
439-
/** Occupancy driver: collect+send when settle takes the deferred dry shot. */
440+
/** Occupancy driver: collect+send when settle takes a dry-episode shot. */
440441
dryOpenTaskDriver: (() => boolean) | undefined;
441442
/** Last prompt actually sent — replay source for the quota auto-retry. */
442443
lastSentMessage: string;
@@ -1011,19 +1012,17 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
10111012
}
10121013
}
10131014

1014-
function occupancyHold(bag: BridgeBag, runBusy: boolean): boolean {
1015-
if (bag.liveFleet > 0 || bag.awaitingContinuationInference) return true;
1016-
return runBusy && bag.pendingDryOpenDrive;
1015+
function occupancyHold(bag: BridgeBag): boolean {
1016+
return bag.liveFleet > 0 || bag.awaitingContinuationInference;
10171017
}
10181018

10191019
/**
10201020
* Release the run to idle and drain everything queued — but only at true
10211021
* session-idle. A live fleet holds the run busy after the parent turn settles
10221022
* (idle-with-fleet): Enter upgrades to a new primary turn during the hold and
10231023
* follow-ups keep waiting; the fleet event landing at zero re-enters here to
1024-
* release the hold. A latched dry edge with open tasks takes one occupancy
1025-
* shot here instead of idling, so a wentDry missed while processing cannot
1026-
* disagree with settle.
1024+
* release the hold. A dry fleet takes one occupancy shot here per dry episode
1025+
* instead of idling, even if the live 1→0 edge was never observed.
10271026
*/
10281027
function settleRunToIdle(shell: AppShell, bag: BridgeBag): void {
10291028
if (shell.session.run !== "busy") return;
@@ -1041,15 +1040,19 @@ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void {
10411040
bag.flushPendingAskWake?.();
10421041
return;
10431042
}
1044-
if (bag.pendingDryOpenDrive) {
1045-
bag.pendingDryOpenDrive = false;
1043+
if (!bag.droveOpenTasksThisDry) {
10461044
let driven = false;
10471045
try {
10481046
driven = bag.dryOpenTaskDriver?.() === true;
10491047
} catch {
10501048
driven = false;
10511049
}
1052-
if (driven) return;
1050+
// Consume only on a real continuation. A false/no-op leaves the latch
1051+
// open so a later missed-edge settle with open tasks can still fire.
1052+
if (driven) {
1053+
bag.droveOpenTasksThisDry = true;
1054+
return;
1055+
}
10531056
}
10541057
shell.session = setRunState(shell.session, "idle");
10551058
bag.awaitingContinuationInference = false;
@@ -1074,12 +1077,9 @@ function applyInbound(
10741077
// queued follow-ups drain now. While the parent is still working the
10751078
// count just updates — the ordinary turn settle does the draining.
10761079
if (event.type === "fleet") {
1077-
const previous = bag.liveFleet;
10781080
bag.liveFleet = event.running;
10791081
if (event.running > 0) {
1080-
bag.pendingDryOpenDrive = false;
1081-
} else if (previous > 0) {
1082-
bag.pendingDryOpenDrive = true;
1082+
bag.droveOpenTasksThisDry = false;
10831083
}
10841084
if (event.running === 0 && !bag.turn.isProcessing) {
10851085
settleRunToIdle(shell, bag);
@@ -1185,7 +1185,7 @@ export function attachSessionBridge(
11851185
pendingAskWake: new Map(),
11861186
deliveredAskWake: new Map(),
11871187
flushPendingAskWake: null,
1188-
pendingDryOpenDrive: false,
1188+
droveOpenTasksThisDry: false,
11891189
awaitingContinuationInference: false,
11901190
dryOpenTaskDriver: undefined,
11911191
lastSentMessage: "",
@@ -1292,7 +1292,7 @@ export function attachSessionBridge(
12921292
currentToolName: turn.currentToolName,
12931293
streamingType: turn.streamingType,
12941294
nowMs,
1295-
sessionActive: occupancyHold(bag, shell.session.run === "busy"),
1295+
sessionActive: occupancyHold(bag),
12961296
};
12971297
const fleet = fleetProgress(bag.agentSessions, nowMs);
12981298
const label = resolveTurnLabel(input, isStalled, fleet);
@@ -1573,7 +1573,7 @@ export function attachSessionBridge(
15731573
bag.liveFleet = 0;
15741574
bag.pendingAskWake.clear();
15751575
bag.deliveredAskWake.clear();
1576-
bag.pendingDryOpenDrive = false;
1576+
bag.droveOpenTasksThisDry = false;
15771577
bag.awaitingContinuationInference = false;
15781578
bag.pendingRowUpdates.clear();
15791579
paintChrome(shell);
@@ -1746,7 +1746,7 @@ export function attachSessionBridge(
17461746
}
17471747
}
17481748
bag.awaitingContinuationInference = false;
1749-
bag.pendingDryOpenDrive = true;
1749+
bag.droveOpenTasksThisDry = false;
17501750
bag.lastSentMessage = "";
17511751
flushOpenRow(shell, bag);
17521752
bag.turnThinking = null;

0 commit comments

Comments
 (0)