Skip to content

Commit 93353fc

Browse files
committed
Re-flush mailbox mail from the fleet stall poll
1 parent 440b9cb commit 93353fc

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { attachSessionBridge, createRecordingPort } from "../runtime-bridge.js";
3+
import { createAppShell } from "../shell/index.js";
4+
import { withTestRenderer } from "../harness.js";
5+
import { createFleetStallPollTick } from "./wiring.js";
6+
7+
function settleToollessTurn(
8+
bridge: ReturnType<typeof attachSessionBridge>,
9+
): void {
10+
bridge.handle({ type: "inference.start", data: {} });
11+
bridge.handle({ type: "inference.done", data: {} });
12+
}
13+
14+
describe("fleet stall poll tick (CL-7676)", () => {
15+
test("tick runs the fleet report and re-flushes mailbox mail", () => {
16+
const order: string[] = [];
17+
const tick = createFleetStallPollTick(
18+
() => {
19+
order.push("report");
20+
},
21+
() => {
22+
order.push("flush");
23+
},
24+
);
25+
tick();
26+
expect(order).toEqual(["report", "flush"]);
27+
});
28+
29+
test("missed edge (driver throws once) self-heals on the next poll without duplicates", async () => {
30+
await withTestRenderer(
31+
async (h) => {
32+
const shell = createAppShell(h.renderer, {
33+
terminal: { columns: 80, rows: 24 },
34+
wireKeys: false,
35+
run: "busy",
36+
});
37+
const port = createRecordingPort();
38+
const bridge = attachSessionBridge(shell, port);
39+
try {
40+
let reports = 0;
41+
let drives = 0;
42+
let taken = false;
43+
let failNext = true;
44+
bridge.handle({ type: "fleet", running: 1 });
45+
settleToollessTurn(bridge);
46+
bridge.setMailboxMailDriver(() => {
47+
if (failNext) {
48+
failNext = false;
49+
throw new Error("send failed");
50+
}
51+
if (taken) return false;
52+
taken = true;
53+
drives += 1;
54+
return true;
55+
});
56+
// The subscribe-time edge misses: the driver failure is swallowed as
57+
// retryable, with no later edge while the fleet stays quiet.
58+
bridge.flushMailboxMail();
59+
expect(drives).toBe(0);
60+
61+
const tick = createFleetStallPollTick(
62+
() => {
63+
reports += 1;
64+
},
65+
() => bridge.flushMailboxMail(),
66+
);
67+
tick();
68+
expect(reports).toBe(1);
69+
expect(drives).toBe(1);
70+
// The report is taken: a second poll must not re-send.
71+
tick();
72+
expect(reports).toBe(2);
73+
expect(drives).toBe(1);
74+
} finally {
75+
bridge.dispose();
76+
shell.dispose();
77+
}
78+
},
79+
{ width: 80, height: 24 },
80+
);
81+
});
82+
83+
test("terminal landing mid-turn is delivered on the next poll after settle", async () => {
84+
await withTestRenderer(
85+
async (h) => {
86+
const shell = createAppShell(h.renderer, {
87+
terminal: { columns: 80, rows: 24 },
88+
wireKeys: false,
89+
run: "idle",
90+
});
91+
const port = createRecordingPort();
92+
const bridge = attachSessionBridge(shell, port);
93+
try {
94+
let drives = 0;
95+
let taken = false;
96+
let failSettleFlush = true;
97+
bridge.setMailboxMailDriver(() => {
98+
if (failSettleFlush) {
99+
failSettleFlush = false;
100+
throw new Error("settle send failed");
101+
}
102+
if (taken) return false;
103+
taken = true;
104+
drives += 1;
105+
return true;
106+
});
107+
bridge.submit("dispatch workers", "immediate");
108+
bridge.handle({ type: "fleet", running: 1 });
109+
// Terminal lands while the parent is mid-turn: flush no-ops.
110+
bridge.flushMailboxMail();
111+
expect(drives).toBe(0);
112+
// Settle-time flush also misses (send fails, swallowed). No later
113+
// store edge fires while the fleet stays quiet.
114+
settleToollessTurn(bridge);
115+
expect(drives).toBe(0);
116+
117+
const tick = createFleetStallPollTick(
118+
() => undefined,
119+
() => bridge.flushMailboxMail(),
120+
);
121+
tick();
122+
expect(drives).toBe(1);
123+
tick();
124+
expect(drives).toBe(1);
125+
} finally {
126+
bridge.dispose();
127+
shell.dispose();
128+
}
129+
},
130+
{ width: 80, height: 24 },
131+
);
132+
});
133+
});

src/tui/runner/wiring.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ import { steerCount } from "../session-queue.js";
7171

7272
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
7373

74+
/**
75+
* One tick of the periodic fleet stall poll.
76+
*
77+
* The store-subscribe edge drives mailbox mail the moment a lane terminalizes,
78+
* but that edge is missable: the parent may be mid-turn (`isProcessing`, so
79+
* `flushMailboxMail` no-ops) or the driver send may fail (swallowed as
80+
* retryable with no later edge when the fleet is otherwise quiet). Re-flushing
81+
* here bounds the stall to one poll interval. Both halves are no-ops when
82+
* there is nothing to say: `reportFleet` diffs, `flushMailboxMail` no-ops
83+
* while processing or when no uncollected terminal waits.
84+
*/
85+
export function createFleetStallPollTick(
86+
reportFleet: () => void,
87+
flushMailboxMail: () => void,
88+
): () => void {
89+
return () => {
90+
reportFleet();
91+
flushMailboxMail();
92+
};
93+
}
94+
7495
export function createFleetWakePublisher(
7596
sessions: RunnerServices["subAgentSessions"],
7697
emitter: RunnerServices["emitter"],
@@ -265,7 +286,10 @@ export function wirePostStartup(
265286
}, FLEET_REPORT_SETTLE_MS);
266287
if (typeof fleetSettle.unref === "function") fleetSettle.unref();
267288
});
268-
const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS);
289+
const fleetStallPollTick = createFleetStallPollTick(reportFleet, () =>
290+
sessionBridge.flushMailboxMail(),
291+
);
292+
const fleetStallPoll = setInterval(fleetStallPollTick, FLEET_STALL_POLL_MS);
269293
if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref();
270294
state.stopFleetReporting = (): void => {
271295
clearInterval(fleetStallPoll);

0 commit comments

Comments
 (0)