From beba94a5257419341eead235e892abb1cfcf010f Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 25 Jun 2026 21:23:34 +0800 Subject: [PATCH] fix(run): grace period before exit for multi-background-agent race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit done resolved the instant mainIdle && busy.size === 0. With multiple background agents, those spawned near the main session's completion emit their busy event asynchronously (omo-stable fires prompt without await), so busy was momentarily empty when main went idle. done resolved prematurely and the fallback process.exit killed agents that hadn't been tracked yet — the first agent finishing would take the whole process down. Wait a 3s grace period once everything appears idle before resolving done. Any session going busy during the grace period cancels it, so late-spawned background agents are picked up. Verified with 3 parallel background agents: all complete (finish=stop), clean EXIT=0. --- packages/opencode/src/cli/cmd/run.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0d49aecc5..a914c5b59 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -454,6 +454,7 @@ export const RunCommand = effectCmd({ // background (child) sessions are still running. const busy = new Set() let mainIdle = false + let graceTimer: ReturnType | undefined for await (const event of events.stream) { if ( @@ -549,9 +550,23 @@ export const RunCommand = effectCmd({ if (status.type === "idle") { busy.delete(sid) if (sid === sessionID) mainIdle = true - if (mainIdle && busy.size === 0) resolveDone() } else { busy.add(sid) + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = undefined + } + } + // Don't resolve immediately when everything looks idle: background + // sessions spawned near the main session's completion emit their + // busy event asynchronously, so busy may be momentarily empty. + // Wait a grace period; any session going busy during it cancels + // the timer above so we keep waiting. + if (mainIdle && busy.size === 0 && !graceTimer) { + graceTimer = setTimeout(() => { + if (mainIdle && busy.size === 0) resolveDone() + }, 3000) + graceTimer.unref?.() } } @@ -579,6 +594,7 @@ export const RunCommand = effectCmd({ } // Stream closed (e.g. server shutdown) before the exit condition was // met — unblock execute() so it doesn't hang forever. + if (graceTimer) clearTimeout(graceTimer) resolveDone() }