diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 642d72406372..e95c58280e0b 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -3992,6 +3992,103 @@ describe("embedded attempt session lock lifecycle", () => { expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); }); + it("releaseHeldLockWithFence sets deferred flag when bailed out during active scope; re-attempted after scope deactivation (#95915)", async () => { + const events: string[] = []; + const releasePrep = vi.fn(async () => events.push("prep-release")); + const releaseRetained = vi.fn(async () => events.push("retained-release")); + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: releasePrep }) + .mockResolvedValueOnce({ release: releaseRetained }); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + await controller.withSessionWriteLock(async () => { + events.push("write-start"); + await controller.releaseHeldLockForAbort(); + events.push("write-end"); + }); + + expect(events).toEqual(["prep-release", "write-start", "write-end", "retained-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); + }); + + it("controls the held lock lifecycle across deferred abort release, reacquisition, and prompt release", async () => { + const events: string[] = []; + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("reacquire-release")) }); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + await controller.withSessionWriteLock(async () => { + events.push("write"); + await controller.releaseHeldLockForAbort(); + }); + + expect(events).toEqual(["init-release", "write", "held-release"]); + + await controller.reacquireAfterPrompt(); + await controller.releaseForPrompt(); + + expect(events).toEqual(["init-release", "write", "held-release", "reacquire-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); + }); + + it("takeHeldLockAfterRetainedIdle does not self-deadlock when called from inside active write scope (#95915)", async () => { + const events: string[] = []; + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) + .mockRejectedValueOnce( + new SessionWriteLockTimeoutError({ + timeoutMs: lockOptions.timeoutMs, + owner: "pid=test", + lockPath: `${lockOptions.sessionFile}.lock`, + }), + ); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + const takeoverError = await controller + .withSessionWriteLock(async () => { + events.push("write-start"); + const cleanupLock = await controller.acquireForCleanup(); + await cleanupLock.release(); + events.push("cleanup-inside-done"); + }) + .catch((error: unknown) => error); + + expect(takeoverError).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); + + const cleanupLock = await controller.acquireForCleanup(); + await cleanupLock.release(); + + expect(events).toEqual(["init-release", "write-start", "cleanup-inside-done", "held-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); + }); + it("returns a no-op cleanup lock after prompt lock reacquisition times out", async () => { const releases: string[] = []; const acquireSessionWriteLockResult = vi diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts index 449373f23dbe..d35b409d136f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts @@ -1171,6 +1171,9 @@ export async function createEmbeddedAttemptSessionLockController(params: { let fenceGeneration = 0; let fenceActive = false; let takeoverDetected = false; + // Set when an active retained write prevents immediate held-lock release. + // The scope completion path retries release after the retained use unwinds. + let releaseHeldLockDeferred = false; let retainedLockUseCount = 0; const retainedLockIdleWaiters = new Set<() => void>(); let heldLockDraining = false; @@ -1603,6 +1606,7 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + releaseHeldLockDeferred = true; return; } if (!heldLock) { @@ -1639,6 +1643,8 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + // Do not wait for retained idle from inside the active scope; that + // scope must unwind before the retained-use waiter can resolve. return undefined; } if (!heldLock) { @@ -1660,6 +1666,7 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + // Same active-scope self-deadlock guard as takeHeldLockAfterRetainedIdle. return; } if (!heldLock) { @@ -1721,6 +1728,12 @@ export async function createEmbeddedAttemptSessionLockController(params: { } } await releaseHeldLockAfterTakeover(); + // Retained use has been released and the active scope is no longer live, + // so a prior active-scope release bailout can drain the held file lock now. + if (releaseHeldLockDeferred) { + releaseHeldLockDeferred = false; + await releaseHeldLockWithFence(); + } if (!outcome.ok) { throw outcome.error; }