spawn: kill process group + heartbeat subagent activity (#631)

* spawn: kill process group + heartbeat subagent activity

two compounding bugs produced zombie agent runs that stalled until the
GitHub-Actions job-level timeout (observed on PR #622, run 25577068620).

1. SIGKILL hit the wrong process. node_modules/opencode-ai/bin/opencode
   is a Node shim that spawnSyncs the native opencode-<plat>-<arch>
   binary with stdio:"inherit". our spawn() ran without detached, so
   child.kill("SIGKILL") killed only the shim. the native binary was
   reparented to PID 1, kept holding our stdout pipe via inherited fds,
   and child.on("close") never fired — leaving the agent promise
   pending past the 5min outer safety-net timer ("agent still pending
   5min after inner activity kill — forcing exit") and the grandchild
   running until the runner timed out.

   fix: SpawnOptions gains killGroup; when set, we spawn detached and
   route all kill paths (timeout, activity timeout, ctrl-c) through
   process.kill(-pid, signal). opencode + claude opt in.

2. inner activity timer false-fired during long task subagents.
   opencode's `task` tool encapsulates subagent execution in-process —
   subagent-internal events don't reach the parent NDJSON stream — so
   the parent looked idle for the full subagent duration even when
   real work was happening, and the 5min DEFAULT_ACTIVITY_TIMEOUT_MS
   would fire mid-subagent.

   fix: SpawnOptions gains externalActivitySource; the timer fires on
   min(local stdout idle, external idle). opencode passes getIdleMs()
   from the global activity tracker and runs a 30s heartbeat
   (markActivity()) while at least one task dispatch is in flight.

action/utils/subprocess.test.ts covers both: a bash+sleep grandchild
that proves close fires <10s with killGroup, and externalActivitySource
keeping the timer armed during 8s of stdout silence.

* opencode: suspend activity timer instead of heartbeat during subagent runs

addresses review on prior commit: replace the 30s markActivity()
heartbeat with a boolean isPausedExternally predicate keyed off
opencode's existing taskDispatchByCallID + pendingTaskDispatches.
no fake activity, no race window between a 30s tick and a subagent
that finishes between ticks.

while the predicate returns true, spawn's activity check skips the
kill decision *and* advances lastActivityTime so a clean unpause
can't fire on a stale baseline. tests cover both the suspended case
(8s of stdout silence + activityTimeout=1s but paused → process
exits cleanly) and the resume case (paused for 500ms then unpaused
→ 30s sleep gets killed by activity timeout as normal).
This commit is contained in:
Colin McDonnell
2026-05-08 21:29:22 +00:00
committed by pullfrog[bot]
parent 20d4b12522
commit ca913c76ea
4 changed files with 158 additions and 5 deletions
+24
View File
@@ -368,6 +368,16 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
// mismatched callID" signal).
const knownNonTaskCallIDs = new Set<string>();
// a subagent is "in flight" while at least one `task` tool_use is awaiting
// its tool_result. used to suspend spawn's activity timer (which only sees
// opencode's stdout) so a long-running subagent — whose internal events
// don't surface on the parent NDJSON stream — can't trip the 5min timeout.
// boolean state instead of a heartbeat so there's no race window between a
// periodic tick and a subagent that finishes between ticks.
function isSubagentInFlight(): boolean {
return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0;
}
function emitSubagentFinished(
dispatch: TaskDispatch,
status: string,
@@ -709,6 +719,20 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
activityTimeout: 300_000,
onActivityTimeout: params.onActivityTimeout,
stdio: ["ignore", "pipe", "pipe"],
// node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs
// the native opencode-<plat>-<arch> binary with stdio:"inherit". without
// a process-group kill, SIGKILL hits only the shim, the native binary
// is reparented to PID 1, holds our stdout pipe open, and `child.close`
// never fires — producing zombie runs. detached + killGroup nukes the
// whole tree.
killGroup: true,
// suspend the inner activity timer while a `task` subagent is in flight.
// opencode's task tool encapsulates subagent execution in-process — the
// subagent's internal events don't surface on the parent NDJSON stream,
// so without this the 5min timeout would falsely fire mid-subagent.
// suspend/resume is preferable to a heartbeat because there's no race
// between a periodic tick and a subagent finishing between ticks.
isPausedExternally: isSubagentInFlight,
onStdout: async (chunk) => {
const text = chunk.toString();
output += text;