Skip to content

Commit ae96d35

Browse files
Merge pull request #916 from corbitsdev/cl-7739-latch-mailbox-mail-occupancy-so-a-worker-burst-cannot-fill
Stop mailbox-mail occupancy from flooding the send queue
2 parents bb6a342 + 57578a2 commit ae96d35

7 files changed

Lines changed: 280 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3636

3737
### Fixed
3838

39+
- Occupancy injects mailbox mail once when a worker burst finishes: one
40+
in-flight drive, in-flight ids until send succeeds, and wake copy that
41+
does not say the reports were already collected. Overlapping flushes
42+
no longer fill the send queue or replay the same reports as new turns.
3943
- Stalled workers get a full `stallTimeoutMs` grace after the first
4044
continuation nudge before salvage. Stall pings inside that window wait
4145
instead of counting toward escalation. Mailbox mail re-flushes from the

src/subagent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export {
2929
export { driveOpenTasksAfterFleetDry } from "./fleet-dry-drive.js";
3030
export {
3131
driveMailboxMail,
32+
latchMailboxMailDrive,
3233
MAILBOX_MAIL_WAKE_PREFIX,
34+
mailboxMailWakeLine,
3335
occupancyShouldYieldWait,
3436
} from "./mailbox-mail-drive.js";
3537
export {

src/subagent/mailbox-mail-drive.test.ts

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { createFleetMailbox } from "./agent-fleet.js";
33
import {
44
buildMailboxMailPrompt,
55
driveMailboxMail,
6+
latchMailboxMailDrive,
67
MAILBOX_MAIL_WAKE_PREFIX,
8+
mailboxMailWakeLine,
79
occupancyShouldYieldWait,
810
} from "./mailbox-mail-drive.js";
911
import { createSubAgentSessionStore } from "./session-store.js";
@@ -41,9 +43,8 @@ describe("buildMailboxMailPrompt", () => {
4143
expect(prompt.startsWith(MAILBOX_MAIL_WAKE_PREFIX)).toBe(true);
4244
expect(prompt).toContain("worker-1");
4345
expect(prompt).toContain("shipped");
44-
expect(prompt).toContain(
45-
"already collected — do not call wait_agents for these agent_ids",
46-
);
46+
expect(prompt).toContain(mailboxMailWakeLine());
47+
expect(prompt).not.toContain("already collected");
4748
});
4849
});
4950

@@ -211,6 +212,80 @@ describe("driveMailboxMail", () => {
211212
expect(records.get("w1")?.collected).toBe(true);
212213
});
213214

215+
test("two flushes while send is pending deliver once", async () => {
216+
const records = new Map<string, FleetDryMailboxRecord>([
217+
["w1", { status: "done", report: "ok" }],
218+
]);
219+
const mailbox = mapMailbox(records);
220+
const sends: string[] = [];
221+
let resolveSend: ((ok: boolean) => void) | undefined;
222+
const driven = await driveMailboxMail({
223+
parentProcessing: false,
224+
mailbox,
225+
lanes: [],
226+
beginSystemContinuation: () => undefined,
227+
send: (prompt) => {
228+
sends.push(prompt);
229+
return new Promise<boolean>((resolve) => {
230+
resolveSend = resolve;
231+
});
232+
},
233+
});
234+
expect(driven).toBe(true);
235+
expect(sends).toHaveLength(1);
236+
expect(records.get("w1")?.collected).not.toBe(true);
237+
expect(
238+
await driveMailboxMail({
239+
parentProcessing: false,
240+
mailbox,
241+
lanes: [],
242+
beginSystemContinuation: () => {
243+
throw new Error("must not begin");
244+
},
245+
send: () => {
246+
throw new Error("must not send");
247+
},
248+
}),
249+
).toBe(false);
250+
expect(sends).toHaveLength(1);
251+
resolveSend?.(true);
252+
await Promise.resolve();
253+
expect(records.get("w1")?.collected).toBe(true);
254+
});
255+
256+
test("failed send can retry once", async () => {
257+
const records = new Map<string, FleetDryMailboxRecord>([
258+
["w1", { status: "done", report: "ok" }],
259+
]);
260+
const mailbox = mapMailbox(records);
261+
const sends: string[] = [];
262+
expect(
263+
await driveMailboxMail({
264+
parentProcessing: false,
265+
mailbox,
266+
lanes: [],
267+
beginSystemContinuation: () => undefined,
268+
send: () => {
269+
throw new Error("send failed");
270+
},
271+
}),
272+
).toBe(false);
273+
expect(records.get("w1")?.collected).not.toBe(true);
274+
expect(
275+
await driveMailboxMail({
276+
parentProcessing: false,
277+
mailbox,
278+
lanes: [],
279+
beginSystemContinuation: () => undefined,
280+
send: (prompt) => {
281+
sends.push(prompt);
282+
},
283+
}),
284+
).toBe(true);
285+
expect(sends).toHaveLength(1);
286+
expect(records.get("w1")?.collected).toBe(true);
287+
});
288+
214289
test("awaiting_director is not mailbox mail", async () => {
215290
const records = new Map<string, FleetDryMailboxRecord>([
216291
["ask", { status: "awaiting_director" }],
@@ -232,6 +307,91 @@ describe("driveMailboxMail", () => {
232307
});
233308
});
234309

310+
describe("latchMailboxMailDrive", () => {
311+
test("overlapping flushes send once until the in-flight collect settles", async () => {
312+
const records = new Map<string, FleetDryMailboxRecord>([
313+
["done", { status: "done", report: "ok", description: "lane" }],
314+
]);
315+
const sends: string[] = [];
316+
let resolveSend: (() => void) | undefined;
317+
const sent = new Promise<void>((resolve) => {
318+
resolveSend = resolve;
319+
});
320+
const driver = latchMailboxMailDrive(() =>
321+
driveMailboxMail({
322+
parentProcessing: false,
323+
mailbox: mapMailbox(records),
324+
lanes: [],
325+
beginSystemContinuation: () => undefined,
326+
send: (prompt) => {
327+
sends.push(prompt);
328+
resolveSend?.();
329+
},
330+
}),
331+
);
332+
expect(driver()).toBe(true);
333+
expect(driver()).toBe(false);
334+
expect(driver()).toBe(false);
335+
await sent;
336+
expect(sends).toHaveLength(1);
337+
expect(sends[0]).toContain(MAILBOX_MAIL_WAKE_PREFIX);
338+
});
339+
340+
test("a false drive does not latch the next flush", () => {
341+
let calls = 0;
342+
const driver = latchMailboxMailDrive(() => {
343+
calls += 1;
344+
return false;
345+
});
346+
expect(driver()).toBe(false);
347+
expect(driver()).toBe(false);
348+
expect(calls).toBe(2);
349+
});
350+
351+
test("after the in-flight drive settles, a new terminal can send", async () => {
352+
const records = new Map<string, FleetDryMailboxRecord>([
353+
["first", { status: "done", report: "one" }],
354+
]);
355+
const mailbox = mapMailbox(records);
356+
const sends: string[] = [];
357+
let sawSend: (() => void) | undefined;
358+
const waitForSend = (): Promise<void> =>
359+
new Promise<void>((resolve) => {
360+
sawSend = resolve;
361+
});
362+
const driver = latchMailboxMailDrive(() =>
363+
driveMailboxMail({
364+
parentProcessing: false,
365+
mailbox,
366+
lanes: [],
367+
beginSystemContinuation: () => undefined,
368+
send: (prompt) => {
369+
sends.push(prompt);
370+
sawSend?.();
371+
},
372+
}),
373+
);
374+
const first = waitForSend();
375+
expect(driver()).toBe(true);
376+
await first;
377+
expect(sends).toHaveLength(1);
378+
records.set("second", { status: "done", report: "two" });
379+
const second = waitForSend();
380+
let retried = false;
381+
for (let i = 0; i < 10; i++) {
382+
await Promise.resolve();
383+
if (driver()) {
384+
retried = true;
385+
break;
386+
}
387+
}
388+
expect(retried).toBe(true);
389+
await second;
390+
expect(sends).toHaveLength(2);
391+
expect(sends[1]).toContain("second");
392+
});
393+
});
394+
235395
describe("occupancyShouldYieldWait", () => {
236396
test("yields on uncollected terminal, fail, or ask; not on live or collected", () => {
237397
expect(occupancyShouldYieldWait(undefined)).toBe(false);

src/subagent/mailbox-mail-drive.ts

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,46 @@ import {
1616

1717
export const MAILBOX_MAIL_WAKE_PREFIX = "mailbox mail";
1818

19+
/**
20+
* First-delivery instruction. Occupancy handed these reports to the parent;
21+
* do not call wait_agents. Must not say "already collected" — that makes the
22+
* first wake look like a replay.
23+
*/
24+
export function mailboxMailWakeLine(): string {
25+
return `${MAILBOX_MAIL_WAKE_PREFIX} — occupancy delivered these worker reports (do not call wait_agents for these agent_ids):`;
26+
}
27+
1928
function isPromiseLike(value: unknown): value is Promise<unknown> {
2029
return typeof value === "object" && value !== null && "then" in value;
2130
}
2231

32+
/**
33+
* Ids occupancy has snapshotted and handed to send, but not yet taken.
34+
* A second flush must not start another parent turn for the same reports.
35+
* Failed send clears the set so a later flush can retry. Weak-keyed so a
36+
* mailbox object can go away without a leak.
37+
*/
38+
const deliveringByMailbox = new WeakMap<FleetDryMailbox, Set<string>>();
39+
40+
function deliveringSet(mailbox: FleetDryMailbox): Set<string> {
41+
let ids = deliveringByMailbox.get(mailbox);
42+
if (ids === undefined) {
43+
ids = new Set();
44+
deliveringByMailbox.set(mailbox, ids);
45+
}
46+
return ids;
47+
}
48+
49+
function releaseDelivering(
50+
mailbox: FleetDryMailbox | undefined,
51+
ids: readonly string[],
52+
): void {
53+
if (mailbox === undefined) return;
54+
const delivering = deliveringByMailbox.get(mailbox);
55+
if (delivering === undefined) return;
56+
for (const id of ids) delivering.delete(id);
57+
}
58+
2359
export function occupancyShouldYieldWait(
2460
mailbox: FleetDryMailbox | undefined,
2561
): boolean {
@@ -37,10 +73,7 @@ export function occupancyShouldYieldWait(
3773
export function buildMailboxMailPrompt(
3874
reports: readonly CollectedWorkerReport[],
3975
): string {
40-
return [
41-
`${MAILBOX_MAIL_WAKE_PREFIX} — worker reports (already collected — do not call wait_agents for these agent_ids):`,
42-
JSON.stringify(reports),
43-
].join("\n");
76+
return [mailboxMailWakeLine(), JSON.stringify(reports)].join("\n");
4477
}
4578

4679
export function driveMailboxMail(args: {
@@ -59,20 +92,30 @@ export function driveMailboxMail(args: {
5992
async function driveMailboxMailAfterCollect(
6093
args: Parameters<typeof driveMailboxMail>[0],
6194
): Promise<boolean> {
62-
const reports = await collectUncollectedTerminals(
63-
args.mailbox,
64-
args.lanes,
65-
false,
66-
args.writeBlob,
67-
);
95+
const delivering =
96+
args.mailbox !== undefined
97+
? deliveringSet(args.mailbox)
98+
: new Set<string>();
99+
const reports = (
100+
await collectUncollectedTerminals(
101+
args.mailbox,
102+
args.lanes,
103+
false,
104+
args.writeBlob,
105+
)
106+
).filter((report) => !delivering.has(report.agent_id));
68107
if (reports.length === 0) return false;
108+
const ids = reports.map((report) => report.agent_id);
109+
for (const id of ids) delivering.add(id);
69110
const prompt = buildMailboxMailPrompt(reports);
70111
const takeReports = (): void => {
71-
for (const report of reports) {
72-
args.mailbox?.take(report.agent_id);
112+
for (const id of ids) {
113+
args.mailbox?.take(id);
73114
}
115+
releaseDelivering(args.mailbox, ids);
74116
};
75117
const fail = (): boolean => {
118+
releaseDelivering(args.mailbox, ids);
76119
args.onSendFailure?.();
77120
return false;
78121
};
@@ -83,10 +126,10 @@ async function driveMailboxMailAfterCollect(
83126
void sent.then(
84127
(result) => {
85128
if (result !== false) takeReports();
86-
else args.onSendFailure?.();
129+
else fail();
87130
},
88131
() => {
89-
args.onSendFailure?.();
132+
fail();
90133
},
91134
);
92135
return true;
@@ -98,3 +141,25 @@ async function driveMailboxMailAfterCollect(
98141
}
99142
return true;
100143
}
144+
145+
/**
146+
* Store-subscribe, stall-poll, and idle-with-fleet settle all flush mailbox
147+
* mail. `driveMailboxMail` does not mark the parent busy until after an
148+
* awaited collect, so overlapping flushes would each call `send()` and fill
149+
* the agent's depth-16 queue. Hold one drive until that promise settles.
150+
*/
151+
export function latchMailboxMailDrive(
152+
drive: () => boolean | Promise<boolean>,
153+
): () => boolean {
154+
let inFlight = false;
155+
return () => {
156+
if (inFlight) return false;
157+
const driven = drive();
158+
if (driven === false) return false;
159+
inFlight = true;
160+
void Promise.resolve(driven).finally(() => {
161+
inFlight = false;
162+
});
163+
return true;
164+
};
165+
}

src/tui/queued-delivery.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2+
import { mailboxMailWakeLine } from "../subagent/mailbox-mail-drive.js";
23
import type { PendingImageAttachment } from "./image-attachments.js";
34
import {
45
createDeliveryGeneration,
@@ -296,15 +297,13 @@ describe("createLeftoverSend", () => {
296297
});
297298

298299
leftoverSend("ask_director wake — see @src/foo.ts");
299-
leftoverSend(
300-
"mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]",
301-
);
300+
leftoverSend(`${mailboxMailWakeLine()}\n[]`);
302301
leftoverSend("please read @src/foo.ts");
303302
await awaitTail();
304303
expect(ingested).toEqual(["please read @src/foo.ts"]);
305304
expect(sent).toEqual([
306305
"ask_director wake — see @src/foo.ts",
307-
"mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]",
306+
`${mailboxMailWakeLine()}\n[]`,
308307
"please read @src/foo.ts ingested",
309308
]);
310309
});

0 commit comments

Comments
 (0)