Skip to content

Commit 92e4a5d

Browse files
committed
Drive open-task occupancy when the fleet dry edge is missed
1 parent 2a8cf44 commit 92e4a5d

3 files changed

Lines changed: 215 additions & 22 deletions

File tree

‎CHANGELOG.md‎

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

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Occupancy takes one dry-episode shot when the parent settles idle even if the
19+
live fleet 1→0 edge was never observed.
20+
1621
## [0.3.19] - 2026-09-10
1722

1823
### Security

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

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

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

‎src/tui/runtime-bridge.ts‎

Lines changed: 23 additions & 22 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;
@@ -1016,9 +1017,8 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
10161017
* session-idle. A live fleet holds the run busy after the parent turn settles
10171018
* (idle-with-fleet): Enter upgrades to a new primary turn during the hold and
10181019
* follow-ups keep waiting; the fleet event landing at zero re-enters here to
1019-
* release the hold. A latched dry edge with open tasks takes one occupancy
1020-
* shot here instead of idling, so a wentDry missed while processing cannot
1021-
* disagree with settle.
1020+
* release the hold. A dry fleet takes one occupancy shot here per dry episode
1021+
* instead of idling, even if the live 1→0 edge was never observed.
10221022
*/
10231023
function settleRunToIdle(shell: AppShell, bag: BridgeBag): void {
10241024
if (shell.session.run !== "busy") return;
@@ -1036,15 +1036,19 @@ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void {
10361036
bag.flushPendingAskWake?.();
10371037
return;
10381038
}
1039-
if (bag.pendingDryOpenDrive) {
1040-
bag.pendingDryOpenDrive = false;
1039+
if (!bag.droveOpenTasksThisDry) {
10411040
let driven = false;
10421041
try {
10431042
driven = bag.dryOpenTaskDriver?.() === true;
10441043
} catch {
10451044
driven = false;
10461045
}
1047-
if (driven) return;
1046+
// Consume only on a real continuation. A false/no-op leaves the latch
1047+
// open so a later missed-edge settle with open tasks can still fire.
1048+
if (driven) {
1049+
bag.droveOpenTasksThisDry = true;
1050+
return;
1051+
}
10481052
}
10491053
shell.session = setRunState(shell.session, "idle");
10501054
bag.awaitingContinuationInference = false;
@@ -1069,12 +1073,9 @@ function applyInbound(
10691073
// queued follow-ups drain now. While the parent is still working the
10701074
// count just updates — the ordinary turn settle does the draining.
10711075
if (event.type === "fleet") {
1072-
const previous = bag.liveFleet;
10731076
bag.liveFleet = event.running;
10741077
if (event.running > 0) {
1075-
bag.pendingDryOpenDrive = false;
1076-
} else if (previous > 0) {
1077-
bag.pendingDryOpenDrive = true;
1078+
bag.droveOpenTasksThisDry = false;
10781079
}
10791080
if (event.running === 0 && !bag.turn.isProcessing) {
10801081
settleRunToIdle(shell, bag);
@@ -1180,7 +1181,7 @@ export function attachSessionBridge(
11801181
pendingAskWake: new Map(),
11811182
deliveredAskWake: new Map(),
11821183
flushPendingAskWake: null,
1183-
pendingDryOpenDrive: false,
1184+
droveOpenTasksThisDry: false,
11841185
awaitingContinuationInference: false,
11851186
dryOpenTaskDriver: undefined,
11861187
lastSentMessage: "",
@@ -1559,7 +1560,7 @@ export function attachSessionBridge(
15591560
bag.liveFleet = 0;
15601561
bag.pendingAskWake.clear();
15611562
bag.deliveredAskWake.clear();
1562-
bag.pendingDryOpenDrive = false;
1563+
bag.droveOpenTasksThisDry = false;
15631564
bag.awaitingContinuationInference = false;
15641565
bag.pendingRowUpdates.clear();
15651566
paintChrome(shell);
@@ -1732,7 +1733,7 @@ export function attachSessionBridge(
17321733
}
17331734
}
17341735
bag.awaitingContinuationInference = false;
1735-
bag.pendingDryOpenDrive = true;
1736+
bag.droveOpenTasksThisDry = false;
17361737
bag.lastSentMessage = "";
17371738
flushOpenRow(shell, bag);
17381739
bag.turnThinking = null;

0 commit comments

Comments
 (0)