Skip to content

Commit e8a7a3f

Browse files
committed
fix(agents): force-release session lock on attempt teardown timeout
When releaseRetainedSessionLock() hangs beyond 30s (due to stuck tool execution), force-dispose the lock so the session recovers immediately instead of waiting up to 17 minutes for watchdog reclaim.
1 parent 2c76021 commit e8a7a3f

3 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { releaseRetainedSessionLockWithTimeout } from "./attempt.release-lock-timeout.js";
3+
4+
describe("releaseRetainedSessionLockWithTimeout", () => {
5+
it("returns 'released' when lock releases within timeout", async () => {
6+
const release = vi.fn().mockResolvedValue(undefined);
7+
const result = await releaseRetainedSessionLockWithTimeout({
8+
releaseRetainedSessionLock: release,
9+
timeoutMs: 5_000,
10+
});
11+
expect(result).toBe("released");
12+
expect(release).toHaveBeenCalledOnce();
13+
});
14+
15+
it("returns 'timeout' when lock release exceeds timeout", async () => {
16+
vi.useFakeTimers();
17+
const neverResolve = () => new Promise<void>(() => {});
18+
const resultPromise = releaseRetainedSessionLockWithTimeout({
19+
releaseRetainedSessionLock: neverResolve,
20+
timeoutMs: 30_000,
21+
});
22+
await vi.advanceTimersByTimeAsync(30_000);
23+
const result = await resultPromise;
24+
expect(result).toBe("timeout");
25+
vi.useRealTimers();
26+
});
27+
28+
it("calls forceDispose on the sessionLockController when timeout fires", async () => {
29+
vi.useFakeTimers();
30+
const neverResolve = () => new Promise<void>(() => {});
31+
const forceDispose = vi.fn().mockResolvedValue(undefined);
32+
const resultPromise = releaseRetainedSessionLockWithTimeout({
33+
releaseRetainedSessionLock: neverResolve,
34+
timeoutMs: 30_000,
35+
sessionLockController: { forceDispose },
36+
});
37+
await vi.advanceTimersByTimeAsync(30_000);
38+
const result = await resultPromise;
39+
expect(result).toBe("timeout");
40+
expect(forceDispose).toHaveBeenCalledOnce();
41+
vi.useRealTimers();
42+
});
43+
44+
it("does not call forceDispose when lock releases successfully", async () => {
45+
const release = vi.fn().mockResolvedValue(undefined);
46+
const forceDispose = vi.fn().mockResolvedValue(undefined);
47+
const result = await releaseRetainedSessionLockWithTimeout({
48+
releaseRetainedSessionLock: release,
49+
timeoutMs: 5_000,
50+
sessionLockController: { forceDispose },
51+
});
52+
expect(result).toBe("released");
53+
expect(forceDispose).not.toHaveBeenCalled();
54+
});
55+
56+
it("swallows errors from forceDispose and still returns 'timeout'", async () => {
57+
vi.useFakeTimers();
58+
const neverResolve = () => new Promise<void>(() => {});
59+
const forceDispose = vi.fn().mockRejectedValue(new Error("force dispose failed"));
60+
const resultPromise = releaseRetainedSessionLockWithTimeout({
61+
releaseRetainedSessionLock: neverResolve,
62+
timeoutMs: 30_000,
63+
sessionLockController: { forceDispose },
64+
});
65+
await vi.advanceTimersByTimeAsync(30_000);
66+
const result = await resultPromise;
67+
expect(result).toBe("timeout");
68+
expect(forceDispose).toHaveBeenCalledOnce();
69+
vi.useRealTimers();
70+
});
71+
72+
it("works when sessionLockController is undefined on timeout", async () => {
73+
vi.useFakeTimers();
74+
const neverResolve = () => new Promise<void>(() => {});
75+
const resultPromise = releaseRetainedSessionLockWithTimeout({
76+
releaseRetainedSessionLock: neverResolve,
77+
timeoutMs: 30_000,
78+
sessionLockController: undefined,
79+
});
80+
await vi.advanceTimersByTimeAsync(30_000);
81+
const result = await resultPromise;
82+
expect(result).toBe("timeout");
83+
vi.useRealTimers();
84+
});
85+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export async function releaseRetainedSessionLockWithTimeout(params: {
2+
releaseRetainedSessionLock: () => Promise<void>;
3+
timeoutMs: number;
4+
sessionLockController?: { forceDispose?: () => Promise<void> };
5+
}): Promise<"released" | "timeout"> {
6+
let timeout: ReturnType<typeof setTimeout> | undefined;
7+
try {
8+
const result = await Promise.race([
9+
params.releaseRetainedSessionLock().then(() => "released" as const),
10+
new Promise<"timeout">((resolve) => {
11+
timeout = setTimeout(() => resolve("timeout"), params.timeoutMs);
12+
timeout.unref?.();
13+
}),
14+
]);
15+
if (result === "timeout") {
16+
// Force-dispose the lock so the session recovers immediately instead of
17+
// waiting for the watchdog (up to 17 minutes).
18+
try {
19+
await params.sessionLockController?.forceDispose?.();
20+
} catch {
21+
// best-effort — the watchdog will still eventually reclaim
22+
}
23+
}
24+
return result;
25+
} finally {
26+
if (timeout) {
27+
clearTimeout(timeout);
28+
}
29+
}
30+
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ export type EmbeddedAttemptSessionLockController = {
12191219
acquireForCleanup(params?: { session?: unknown }): Promise<SessionLock>;
12201220
hasSessionTakeover(): boolean;
12211221
dispose(): Promise<void>;
1222+
forceDispose(): Promise<void>;
12221223
};
12231224

12241225
export async function createEmbeddedAttemptSessionLockController(params: {
@@ -2156,6 +2157,28 @@ export async function createEmbeddedAttemptSessionLockController(params: {
21562157
deactivateFence();
21572158
}
21582159
},
2160+
async forceDispose(): Promise<void> {
2161+
disposed = true;
2162+
// Force-resolve all retained lock idle waiters so any pending
2163+
// disposeHeldLockAfterRetainedIdle() can proceed immediately.
2164+
retainedLockUseCount = 0;
2165+
if (retainedLockIdleWaiters.size > 0) {
2166+
const waiters = Array.from(retainedLockIdleWaiters);
2167+
retainedLockIdleWaiters.clear();
2168+
for (const resolve of waiters) {
2169+
resolve();
2170+
}
2171+
}
2172+
try {
2173+
if (heldLock) {
2174+
const lock = heldLock;
2175+
heldLock = undefined;
2176+
await lock.release();
2177+
}
2178+
} finally {
2179+
deactivateFence();
2180+
}
2181+
},
21592182
};
21602183
}
21612184

0 commit comments

Comments
 (0)