Skip to content

Commit b4e8d79

Browse files
Merge pull request #1071 from corbitsdev/cl-7997-keep-approval-hold-across-retry-and-prove-shell-guard-kill
fix(tui): keep approved hold across retry and prove shell-guard kill
2 parents fe8ba89 + 36f803f commit b4e8d79

3 files changed

Lines changed: 89 additions & 2 deletions

File tree

‎src/subagent/run-shell-child-reap.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,54 @@ describe("CL-7990 shell-child reap: sessions holding a live shell child settle",
353353
{ timeout: 60_000 },
354354
);
355355
});
356+
357+
/**
358+
* CL-7997 kill proof: the tests above prove the run settles under a wedged
359+
* child, but none proves a shell-guard-tracked child is actually KILLED on
360+
* close/dispose. This drives a real `sleep` through `runGuardedShell` (the
361+
* shell-guard tracking primitive) and the exact `reapLiveChildren` call the
362+
* plugin dispose runs, then asserts on the ChildProcess handle itself that
363+
* the process is dead — not just that the run settled.
364+
*/
365+
describe("CL-7997 shell-guard kill proof: dispose leaves the tracked child dead", () => {
366+
test(
367+
"reapLiveChildren kills a shell-guard-tracked sleep child",
368+
async () => {
369+
if (process.platform === "win32") return;
370+
const { runGuardedShell, reapLiveChildren } =
371+
await import("../plugins/shell-guard-plugin.js");
372+
const liveChildren = new Set<ChildProcess>();
373+
const controller = new AbortController();
374+
const running = runGuardedShell(
375+
{ command: "sleep 60" },
376+
controller.signal,
377+
liveChildren,
378+
);
379+
let child: ChildProcess | undefined;
380+
try {
381+
const started = Date.now();
382+
while (liveChildren.size === 0 && Date.now() - started < 5_000) {
383+
await new Promise((resolve) => setTimeout(resolve, 10));
384+
}
385+
expect(liveChildren.size).toBe(1);
386+
child = defined([...liveChildren][0]);
387+
// Live before the reap, so the death assertion below is not vacuous.
388+
expect(child.exitCode).toBeNull();
389+
expect(child.signalCode).toBeNull();
390+
await reapLiveChildren(liveChildren);
391+
await waitForChildExit(child);
392+
await running;
393+
} finally {
394+
controller.abort();
395+
if (child !== undefined) {
396+
try {
397+
child.kill("SIGKILL");
398+
} catch {
399+
// Already dead — best-effort orphan guard.
400+
}
401+
}
402+
}
403+
},
404+
{ timeout: 30_000 },
405+
);
406+
});

‎src/tui/correlation-acceptance.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ describe("createCorrelationAcceptance", () => {
7474
expect(settled).toBe(true);
7575
});
7676

77+
test("an approved hold survives abandon so a late tool.start settles the retry waiter", async () => {
78+
const acceptance = createCorrelationAcceptance();
79+
const first = acceptance.wait("corr-1");
80+
let firstSettled = false;
81+
void first.then(() => {
82+
firstSettled = true;
83+
});
84+
acceptance.observe({
85+
type: "message.correlated",
86+
data: { correlationId: "corr-1", message: approvedMessage("corr-1") },
87+
});
88+
// Acceptance-timeout path: drops the waiter without resolving it, but the
89+
// approved hold must survive so the retry waiter below still settles.
90+
acceptance.abandon("corr-1");
91+
await new Promise((resolve) => setTimeout(resolve, 5));
92+
expect(firstSettled).toBe(false);
93+
const retry = acceptance.wait("corr-1");
94+
acceptance.observe({
95+
type: "tool.start",
96+
data: { call: { id: "call-1" } },
97+
});
98+
const settled = await Promise.race([
99+
retry.then(() => true),
100+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 50)),
101+
]);
102+
expect(settled).toBe(true);
103+
});
104+
105+
test("late settle after abandon is a safe no-op", () => {
106+
const acceptance = createCorrelationAcceptance();
107+
acceptance.abandon("corr-1");
108+
acceptance.settle("corr-1");
109+
});
110+
77111
test("a rejected correlation settles at message.correlated", async () => {
78112
const acceptance = createCorrelationAcceptance();
79113
const pending = acceptance.wait("corr-1");

‎src/tui/correlation-acceptance.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,12 @@ export function createCorrelationAcceptance() {
8181
/**
8282
* Drop a waiter without resolving it. The acceptance deadline path uses
8383
* this instead of settle: settling would fulfill the very promise the
84-
* deadline race is trying to reject, so the timeout could never fire.
84+
* deadline race is trying to reject, so the timeout could never fire. The
85+
* approved hold survives: the retry re-awaits acceptance for the same
86+
* correlation id, and a late tool.start must still settle that waiter
87+
* instead of finding an empty hold set and false-timing-out again.
8588
*/
8689
const abandon = (correlationId: string): void => {
87-
holdUntilToolStart.delete(correlationId);
8890
waiters.delete(correlationId);
8991
};
9092

0 commit comments

Comments
 (0)