Skip to content

Commit 2606e52

Browse files
committed
fix(agents): restore controller.dispose() and clear prompt turn on teardown
ClawSweeper review on the previous force-push found that the rebase that dropped the kesslerio drain commit also removed unrelated infrastructure: the EmbeddedAttemptSessionLockController.dispose() method, its outer- finally call site in runEmbeddedAttempt, and two existing test cases on main that exercise it. Without dispose() the eager session lock leaks on post-prompt error paths (the original #86014 fix). The new activePromptSessionTurn introduced by this PR makes the gap worse — teardown after releaseForPrompt without going through reacquireAfterPrompt leaves the file-scoped queue tail unresolved and the next same-file controller waits forever (bot finding, confidence 0.9). Restored: - EmbeddedAttemptSessionLockController.dispose(): Promise<void> back on the type and implementation. Behavior: vacate activePromptSessionTurn first (new), then release heldLock if held (the original main behavior). Idempotent — both heldLock and activePromptSessionTurn are cleared after first call. - releaseRetainedSessionLock infrastructure in runEmbeddedAttempt: declaration before the try block, assignment after the controller constructor, and the outer-finally invocation with the log.error fallback. Restored from main as it was before this branch's rebase. - The two existing dispose tests from main (#86014): "releases the eagerly-held attempt lock on dispose when cleanup is skipped" and "dispose does not double-release a lock already handed to cleanup". New regression for the prompt-turn cleanup invariant: - "dispose-after-releaseForPrompt vacates the prompt turn so the next same-file controller does not hang". Verifies the case the bot flagged: controller A calls releaseForPrompt, then dispose without going through reacquireAfterPrompt. Controller B on the same session file must be able to releaseForPrompt without hanging. The test races releaseForPrompt against a 200 ms timeout and asserts the release resolves first. 80 cases across 2 test files green. Closes #85913.
1 parent c4dacdc commit 2606e52

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,4 +1431,45 @@ describe("embedded attempt session lock lifecycle", () => {
14311431
await controllerB.releaseForPrompt();
14321432
expect(controllerB.hasSessionTakeover()).toBe(false);
14331433
});
1434+
1435+
it("dispose-after-releaseForPrompt vacates the prompt turn so the next same-file controller does not hang", async () => {
1436+
// Regression for the ClawSweeper P2 finding on #86067: after
1437+
// releaseForPrompt() opens the prompt window, heldLock is undefined but
1438+
// activePromptSessionTurn is still set until reacquireAfterPrompt clears
1439+
// it. If teardown runs in that window (mid-stream throw, etc.), dispose
1440+
// must release+clear the prompt turn or the file-scoped queue tail
1441+
// never resolves and the next same-file controller waits forever.
1442+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
1443+
const sessionFile = await createTempSessionFile();
1444+
const acquireSessionWriteLock = vi.fn(async () => ({
1445+
release: vi.fn(async () => {}),
1446+
}));
1447+
const controllerA = await createEmbeddedAttemptSessionLockController({
1448+
acquireSessionWriteLock,
1449+
lockOptions: { ...lockOptions, sessionFile },
1450+
});
1451+
1452+
await controllerA.releaseForPrompt();
1453+
// Teardown without going through reacquireAfterPrompt — the case the
1454+
// outer-finally `releaseRetainedSessionLock?.()` covers when the prompt
1455+
// stream throws.
1456+
await controllerA.dispose();
1457+
// Idempotent: second dispose must be safe (the outer finally can run
1458+
// even after acquireForCleanup has handed off the lock).
1459+
await controllerA.dispose();
1460+
1461+
// A new controller on the same file must be able to open its own
1462+
// prompt window without hanging on A's vacated turn.
1463+
const controllerB = await createEmbeddedAttemptSessionLockController({
1464+
acquireSessionWriteLock,
1465+
lockOptions: { ...lockOptions, sessionFile },
1466+
});
1467+
await expect(
1468+
Promise.race([
1469+
controllerB.releaseForPrompt().then(() => "released"),
1470+
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 200)),
1471+
]),
1472+
).resolves.toBe("released");
1473+
expect(controllerB.hasSessionTakeover()).toBe(false);
1474+
});
14341475
});

src/agents/pi-embedded-runner/run/attempt.session-lock.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,18 @@ export async function createEmbeddedAttemptSessionLockController(params: {
981981
return takeoverDetected;
982982
},
983983
async dispose(): Promise<void> {
984+
// Vacate the prompt turn first, regardless of whether heldLock is set.
985+
// After `releaseForPrompt`, heldLock is undefined but
986+
// activePromptSessionTurn is still set until `reacquireAfterPrompt`
987+
// clears it. If teardown runs in that window without going through
988+
// reacquireAfterPrompt (e.g. mid-stream throw before the outer
989+
// finally hits acquireForCleanup), the file-scoped queue tail never
990+
// resolves and later same-file controllers block on it forever.
991+
// Release the turn here so dispose-after-releaseForPrompt is safe.
992+
if (activePromptSessionTurn) {
993+
activePromptSessionTurn.release();
994+
activePromptSessionTurn = undefined;
995+
}
984996
if (!heldLock) {
985997
return;
986998
}

0 commit comments

Comments
 (0)