From 2613de522f02e30c443ea1ac32bb363f4a4732a1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 03:41:02 +0900 Subject: [PATCH 1/2] fix(prompt-async-gate): timeout isSessionActive to prevent infinite hang on stale SDK status - Wrap isSessionActive in withDispatchTimeout (capped at 5s) so a stuck OpenCode SDK status() call cannot block internal prompts forever. - Catch the timeout and treat session as inactive so the prompt can proceed rather than hanging indefinitely. - Add regression test: session.status that never resolves now times out and allows dispatch instead of hanging the test (and production). Refs: AGENTS.md internal-message-injection safety note --- src/hooks/shared/prompt-async-gate.test.ts | 28 ++++++++++++++++++++++ src/shared/prompt-async-gate.ts | 14 ++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index 450b70de4..7f6dc6044 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -476,6 +476,34 @@ describe("promptAsyncAfterSessionIdle", () => { expect(promptCalls).toBe(1) }) + test("#given session.status never resolves #when promptAsync is requested #then isSessionActive times out and dispatch is attempted", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => new Promise(() => {}), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_status_hang", + input: { path: { id: "ses_status_hang" }, body: { parts: [] } }, + source: "test:status-hang", + settleMs: 0, + postDispatchHoldMs: 0, + dispatchTimeoutMs: 50, + }) + + // then + expect(result.status).toBe("dispatched") + expect(promptCalls).toBe(1) + }, 2000) + test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => { // given const session = { diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 3822ccf11..ff53a16cd 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -169,7 +169,19 @@ async function dispatchAfterSessionIdle(args: { await settleAfterSessionIdle(settleMs) } - if (canReadStatus && await isSessionActive(client, sessionID)) { + let sessionActive = false + if (canReadStatus) { + try { + sessionActive = await withDispatchTimeout( + isSessionActive(client, sessionID), + Math.min(dispatchTimeoutMs, 5000), + `[prompt-async-gate] ${sessionName} isSessionActive`, + ) + } catch { + sessionActive = false + } + } + if (sessionActive) { log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source }) return { status: "active" } } From fcd0011a6b8cad14e4e57b0278e746c6520d8b76 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 03:50:02 +0900 Subject: [PATCH 2/2] test(atlas): track active timers instead of scheduled delays in setTimeout mock - Replace the simple scheduledDelays array with an activeTimers Map so that clearTimeout removes timers from the tracked set. - This prevents false positives when internal withDispatchTimeout calls setTimeout for safety timeouts that are immediately cancelled. - Keeps the test intent unchanged: only genuinely scheduled retries are counted as delayed duplicate retries. --- src/hooks/atlas/index.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index fb9094d4c..c7c8d9b28 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1674,11 +1674,17 @@ session_id: ses_untrusted_999 writeBoulderState(TEST_DIR, state) const originalSetTimeout = globalThis.setTimeout - const scheduledDelays: number[] = [] + const originalClearTimeout = globalThis.clearTimeout + const activeTimers = new Map, number>() globalThis.setTimeout = ((_handler: Parameters[0], timeout?: number, ..._args: unknown[]) => { - scheduledDelays.push(timeout ?? 0) - return originalSetTimeout(() => undefined, 0) + const id = originalSetTimeout(() => undefined, 0) + activeTimers.set(id, timeout ?? 0) + return id }) as typeof setTimeout + globalThis.clearTimeout = ((id: ReturnType) => { + activeTimers.delete(id) + originalClearTimeout(id) + }) as typeof clearTimeout try { const mockInput = createMockPluginInput() @@ -1702,10 +1708,12 @@ session_id: ses_untrusted_999 }) // then - stale idle is consumed, not converted into another scheduled continuation + const scheduledDelays = Array.from(activeTimers.values()) expect(mockInput._promptMock).toHaveBeenCalledTimes(1) expect(scheduledDelays.filter((delay) => delay >= 5_000 && delay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS)).toHaveLength(0) } finally { globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout } }) @@ -2519,7 +2527,9 @@ session_id: ses_untrusted_999 capturedTimers.delete(id) return } - originalClearTimeout(id) + if (id !== undefined) { + originalClearTimeout(id) + } }) as typeof clearTimeout })