Skip to content

Commit b87d0e1

Browse files
committed
fix(tui): skip occupancy begin while the operator pause holds
1 parent 6de8863 commit b87d0e1

7 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎src/subagent/fleet-dry-drive.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,31 @@ describe("driveOpenTasksAfterFleetDry", () => {
680680
).toBe(false);
681681
});
682682

683+
test("does not send if begin does not claim the parent turn", async () => {
684+
const records = recordsOf({
685+
w1: { status: "done", report: "ok" },
686+
});
687+
const sends: string[] = [];
688+
expect(
689+
await driveOpenTasksAfterFleetDry({
690+
previousRunning: 1,
691+
running: 0,
692+
openTasks: [openTask],
693+
parentProcessing: false,
694+
isParentProcessing: () => false,
695+
mailbox: peekMailbox(records),
696+
lanes: [],
697+
beginSystemContinuation: () => undefined,
698+
send: (prompt) => {
699+
sends.push(prompt);
700+
return ACCEPTED_DELIVERY;
701+
},
702+
}),
703+
).toBe(false);
704+
expect(sends).toEqual([]);
705+
expect(records.get("w1")?.collected).not.toBe(true);
706+
});
707+
683708
test("deferred dry edge after parentProcessing still collects and sends", async () => {
684709
const records = recordsOf({
685710
w1: { status: "done", report: "ok" },

‎src/subagent/fleet-dry-drive.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,8 @@ export function driveOpenTasksAfterFleetDry(args: {
543543
running?: number | undefined;
544544
openTasks: readonly Task[];
545545
parentProcessing: boolean;
546+
/** Live check after awaited collect; occupancy must not start a turn already claimed. */
547+
isParentProcessing?: () => boolean;
546548
deferredDryEdge?: boolean;
547549
mailbox: FleetDryMailbox | undefined;
548550
lanes: readonly FleetDryLane[];
@@ -601,6 +603,13 @@ async function driveOpenTasksAfterFleetDrySpill(
601603
const reports = dedupeByAgentId([...digested, ...stubs]);
602604
prompt = buildFleetDryContinuationPrompt(tasks, reports);
603605
args.beginSystemContinuation(prompt);
606+
// begin is a no-op while paused. Do not send; release the delivering
607+
// claim so a later explicit send can still deliver. The dry latch
608+
// unclaims when this drive returns false.
609+
if (args.isParentProcessing?.() === false) {
610+
releaseOccupancyDelivering(args.mailbox, takeIds);
611+
return false;
612+
}
604613
} catch {
605614
return fail();
606615
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,28 @@ describe("driveMailboxMail", () => {
578578
expect(records.get("w1")?.collected).not.toBe(true);
579579
});
580580

581+
test("does not send if begin does not claim the parent turn", async () => {
582+
const records = recordsOf({
583+
w1: { status: "done", report: "ok" },
584+
});
585+
const sends: string[] = [];
586+
expect(
587+
await driveMailboxMail({
588+
parentProcessing: false,
589+
isParentProcessing: () => false,
590+
mailbox: collectingMailbox(records),
591+
lanes: [],
592+
beginSystemContinuation: () => undefined,
593+
send: (prompt) => {
594+
sends.push(prompt);
595+
return ACCEPTED_DELIVERY;
596+
},
597+
}),
598+
).toBe(false);
599+
expect(sends).toEqual([]);
600+
expect(records.get("w1")?.collected).not.toBe(true);
601+
});
602+
581603
test("not-delivered send leaves the wake retryable", async () => {
582604
const records = await driveMailNotDelivered();
583605
expect(

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ async function driveMailboxMailAfterCollect(
179179
await digestCollectedReports(reports, args.writeBlob),
180180
);
181181
args.beginSystemContinuation(prompt);
182+
// begin is a no-op while paused (and if disposed/empty). Do not send a
183+
// primary, and release the delivering claim so a later explicit send can
184+
// still deliver. The mailbox latch unclaims when this drive settles.
185+
if (args.isParentProcessing?.() === false) {
186+
releaseOccupancyDelivering(args.mailbox, ids);
187+
return false;
188+
}
182189
} catch {
183190
return fail();
184191
}

‎src/tui/agent-ask-wake.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,62 @@ describe("agent ask wake delivery", () => {
669669
);
670670
});
671671

672+
test("paused beginSystemContinuation does not mark the turn processing", async () => {
673+
await withWakeBridge((bridge, sends, shell) => {
674+
handleCtrlC(shell);
675+
expect(isPaused(shell.session)).toBe(true);
676+
bridge.beginSystemContinuation("mailbox occupancy");
677+
expect(bridge.turn.isProcessing).toBe(false);
678+
expect(shell.session.run).not.toBe("busy");
679+
expect(sends).toEqual([]);
680+
});
681+
});
682+
683+
test("in-flight mailbox collect after pause does not start a primary", async () => {
684+
await withWakeBridge(async (bridge, sends, shell) => {
685+
const order: string[] = [];
686+
let release: () => void = () => undefined;
687+
const hold = new Promise<void>((resolve) => {
688+
release = resolve;
689+
});
690+
const driver = latchAsyncMailboxMail(bridge, order, hold);
691+
bridge.setMailboxMailDriver(driver);
692+
bridge.flushMailboxMail();
693+
expect(order).toEqual(["mail"]);
694+
expect(driver.claimed()).toBe(true);
695+
expect(bridge.turn.isProcessing).toBe(false);
696+
697+
handleCtrlC(shell);
698+
expect(isPaused(shell.session)).toBe(true);
699+
700+
release();
701+
await hold;
702+
await Promise.resolve();
703+
await Promise.resolve();
704+
705+
expect(order).toEqual(["mail", "begin"]);
706+
expect(bridge.turn.isProcessing).toBe(false);
707+
expect(sends).toEqual([]);
708+
expect(shell.session.run).not.toBe("busy");
709+
expect(isPaused(shell.session)).toBe(true);
710+
expect(driver.claimed()).toBe(false);
711+
});
712+
});
713+
714+
test("unpaused interrupt still begins a system continuation", async () => {
715+
await withWakeBridge((bridge, sends, shell) => {
716+
bridge.handle({ type: "inference.start", data: {} });
717+
expect(bridge.turn.isProcessing).toBe(true);
718+
bridge.interrupt();
719+
expect(isPaused(shell.session)).toBe(false);
720+
expect(bridge.turn.isProcessing).toBe(false);
721+
bridge.beginSystemContinuation("mailbox occupancy");
722+
expect(bridge.turn.isProcessing).toBe(true);
723+
expect(shell.session.run).toBe("busy");
724+
expect(sends).toEqual([]);
725+
});
726+
});
727+
672728
test("a wake question with bracket lines does not spoof attachment-echo matching", async () => {
673729
await withWakeBridge((bridge, sends, shell) => {
674730
const ask = {

‎src/tui/runner/wiring.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ export function wirePostStartup(
407407
deferredDryEdge: true,
408408
openTasks: services.directorHolder.instance?.getTasks() ?? [],
409409
parentProcessing: false,
410+
isParentProcessing: () => sessionBridge.turn.isProcessing,
410411
mailbox: services.toolset.fleetRecords,
411412
lanes: services.subAgentSessions.list(),
412413
...(storage !== null

‎src/tui/runtime-bridge.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2482,6 +2482,10 @@ export function attachSessionBridge(
24822482
},
24832483
beginSystemContinuation: (text) => {
24842484
if (bag.disposed) return;
2485+
// Occupancy that already claimed a latch can still call begin after an
2486+
// awaited collect. Operator pause must not start a primary; stall/expire
2487+
// do not set paused, so they still begin.
2488+
if (isPaused(shell.session)) return;
24852489
const t = text.trim();
24862490
if (t.length === 0) return;
24872491
bag.lastSentMessage = t;

0 commit comments

Comments
 (0)