Skip to content

fix(runtime): budget explicit terminal close for the daemon's immediate-kill verdict - #22385

Merged
nwparker merged 3 commits into
stablyai:mainfrom
nwparker:worker-release-kill-timeout
Sep 23, 2026
Merged

nwparker merged 3 commits into
stablyai:mainfrom
nwparker:worker-release-kill-timeout

Conversation

@nwparker

Copy link
Copy Markdown
Contributor

ELI5

When Orca releases an orchestration worker, it closes the worker's terminal and checks that the agent really stopped. It only waited 2 seconds for that check. Some agents take longer to shut down cleanly (Muse Code takes about 3 seconds), so Orca reported "could not confirm it stopped" even though it had. Orca now waits as long as the terminal daemon's own shutdown is allowed to take.

What Changed

  • Before: orca orchestration worker-release on a Muse worker returned release_unknown. The error was "The agent terminal was closed but its process could not be confirmed stopped", and worker-show then reported liveness unverifiable. The dev log showed Request kill timed out after 2000ms, although the daemon log shows the session exited cleanly about 3 s after the release.

  • After: the same release returns released, and liveness reads exited.

  • Root cause: EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS was 2 s (src/main/runtime/orca-runtime-core.ts). That became the timeout on main's kill request to the terminal daemon. The daemon's immediate kill can legitimately take much longer by design:

    • up to 1 s to list child processes,
    • a 2.5 s window to confirm they stopped, with two 250 ms re-reads,
    • up to 8 s for the shell to exit.

    When main timed out first, the close path issued a second stop and marked the terminal unverifiable straight away. Worker release saved that as release_unknown, and the exit that arrived moments later couldn't change the saved receipt.

  • Fix:

    • The daemon's kill timings now live in one place (src/main/daemon/immediate-kill-reply-budget.ts), and the daemon reads its values from there. They add up to 12 s.
    • The explicit-close timeout is now that total plus 2 s for the check after the kill, so 14 s.
    • A test fails if the copied 1 s listing timeout drifts from the daemon's.

Why

The two sides disagreed about how long a kill may take. Main gave up before the daemon had finished its own bounded check, so a clean exit was reported as unknown. Deriving main's timeout from the daemon's timings fixes the cause instead of picking a new magic number.

The rules in docs/reference/ssh-execution-boundary.md still hold:

  • Only an exit reported by the host that owns the process counts as exited.
  • A process that is really stuck, or a host that doesn't answer, still ends in release_unknown / unverifiable.
  • Nothing new can claim a false exit.

Linked Issue

Found while validating #22383 (Muse orchestration workers, #19823).

Visual Proof

Before (main, Muse worker):

"state": "release_unknown", "processAction": "closed_agent_terminal",
"lastError": "The agent terminal was closed but its process could not be confirmed stopped: a follow-up stop was issued but its outcome could not be verified."
[pty] Failed to stop PTY …@@11141473: Request kill timed out after 2000ms

After (this PR, two Muse workers, about 2.4 s per release):

"state": "released", "processAction": "closed_agent_terminal", "lastError": null
  • Liveness is exited (source resource_release).
  • The dev log has no Request kill timed out.
  • In the daemon log, session-exited comes about 2.1 s after release, then session-killed immediate:true, with no session-kill-failed.

Both worker tabs are gone after release, and only the coordinator tab remains:

After release

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • Manual (macOS): a dev build with two real Muse Code workers; each ran its task, sent worker_done, and was released as above.

  • New test file: src/main/runtime/terminal-close-slow-agent-exit.test.ts runs the real daemon over a socket, the real runtime close and the real release logic. It covers:

    • a process that exits 2.3 s after the kill ends released (this test fails with the old 2 s),
    • a stop that runs out its budget ends release_unknown, with the second stop issued and the terminal marked unverifiable,
    • a SessionNotFoundError reply for a process that had already exited counts as exited,
    • main's close budget is larger than the daemon's.
  • Existing suites: daemon, PTY, worker-release, worktree-teardown, relay and orca-runtime all pass, as do tc:node and the changed-code quality gate.

AI Disclosure

Anthropic Claude assisted with implementation, validation, and this pull request description.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Tab closing: closing a tab in the UI is unchanged; the tab disappears at once. Only a caller that waits for the stop to be confirmed (worker-release, orca terminal close, mobile close) can now wait up to 14 s instead of 2 s, and only for a process that won't stop. That's still within the CLI's 60 s and federated release's 30 s timeouts. If an SSH provider is gone entirely, close still fails fast.
  • Not checked: the SSH relay's and Windows taskkill's own stop timings, against the new 14 s.
  • Unrelated, cosmetic: if a process exits after the budget, the second stop's -1 can overwrite the recorded exit reason with stop_unverified. The liveness verdict stays exited.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (tc:node and the affected suites pass locally; CI covers the rest)

…te-kill verdict

Explicit terminal close (worker-release, worker-stop, `orca terminal close`)
gave the daemon kill RPC a fixed 2s deadline. The daemon's immediate kill
captures descendants, SIGTERMs them with a 2.5s verification window, then
waits up to 8s for the root's physical exit. An agent that runs exit hooks
after SIGTERM (Muse: ~3s) outlived main's 2s timeout, so close reported the
PTY unverifiable and worker-release returned release_unknown even though the
daemon confirmed the exit ~200ms later.

Derive the close budget from the daemon's own immediate-kill reply budget
(now in an import-free module) plus 2s for the post-kill inventory check. A
process that exits within the daemon's budget is released; a wedged process
or unreachable host still times out as unverifiable.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Shared kill budget — new import-free src/main/daemon/immediate-kill-reply-budget.ts becomes the single source for the daemon's immediate-kill timings and derives IMMEDIATE_KILL_REPLY_BUDGET_MS (capture 1s + verify 2.5s + two 250ms table reads + 8s physical exit = 12s).
  • Daemon reads the shared timings — session-termination-controller.ts imports and re-exports IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS (existing importers keep working), and terminal-descendant-shutdown.ts now reads the verify/table windows from the module.
  • Main's explicit-close budget derived from it — EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS grows from a fixed 2s to IMMEDIATE_KILL_REPLY_BUDGET_MS + 2000 (14s), which is the deadlineMs that becomes the daemon kill request timeout.
  • Regression coverage — new terminal-close-slow-agent-exit.test.ts runs the real daemon over a socket plus the real runtime close/release logic: slow 2.3s exit ends released, a SessionNotFoundError reply counts as exited, and budget expiry ends release_unknown with the fallback stop.

I traced the daemon sequence (killWithDescendantSweep → terminateShutdownDescendants → forceKillAndWaitForExit) and the budget's worst case lands around 12.0–12.3s, so main's 14s leaves the follow-up inventory check its 2s. I also confirmed the new test is real coverage: reverting EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS to 2000 locally makes three of its four cases fail (the slow-exit case returns at ~2s and budgetMs reads 2000).

ℹ️ Capture timeout is mirrored, not shared

The budget module documents IMMEDIATE_KILL_CAPTURE_TIMEOUT_MS as mirroring DESCENDANT_SNAPSHOT_TIMEOUT_MS, but the daemon's initial capture still reads the latter directly, so the two values can drift apart. The new test's IMMEDIATE_KILL_CAPTURE_TIMEOUT_MS === DESCENDANT_SNAPSHOT_TIMEOUT_MS assertion does catch that drift, so this is intentional and safe — flagging only so the coupling is understood as deliberate.

Technical details
# Capture timeout mirror

## Affected sites
- `src/main/daemon/immediate-kill-reply-budget.ts:7-8` — declares `IMMEDIATE_KILL_CAPTURE_TIMEOUT_MS = 1000` as a copy of `DESCENDANT_SNAPSHOT_TIMEOUT_MS`.
- `src/main/pty-descendant-termination.ts:217` — `captureDescendantSnapshot` uses `deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS`; the kill-sweep call sites set neither, so this is the real capture bound.
- `src/main/runtime/terminal-close-slow-agent-exit.test.ts:106` — pins the two constants equal.

## Required outcome
- None. The module stays import-free (its stated purpose), and the test is what keeps the copy honest, so this is a note rather than a request.

## Suggested approach (optional)
- If a future change removes the drift test, prefer importing `DESCENDANT_SNAPSHOT_TIMEOUT_MS` (or asserting equality at module load) instead of relying on the comment.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 28760900-2d63-4670-a0d6-86f7c1486f83

📥 Commits

Reviewing files that changed from the base of the PR and between f52cf09 and b555916.

📒 Files selected for processing (1)
  • src/main/runtime/terminal-close-slow-agent-exit.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The change adds a force-kill retry wait to the immediate-kill reply budget and uses shared timeout constants in daemon shutdown code. Terminal-close handling now sets its stop timeout to the immediate-kill reply budget plus 2,000 ms. Integration tests cover slow exits, delayed exit frames, and stop deadlines that expire while a process is still running.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to b5559

The longer close window allows delayed daemon exits to be confirmed, while stuck processes still produce an unconfirmed outcome. No actionable merge risk introduced by this change remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: aligning explicit terminal-close timing with the daemon’s immediate-kill budget.
Description check ✅ Passed The description is mostly complete. It explains the user impact, root cause, implementation, rationale, testing, AI usage, and known limitations. It includes linked issue information and before/after …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: b0289ba1-3ed8-499f-bb6c-ace77de69b22

📥 Commits

Reviewing files that changed from the base of the PR and between bf3f952 and b5e0fc1.

📒 Files selected for processing (5)
  • src/main/daemon/immediate-kill-reply-budget.ts
  • src/main/daemon/session-termination-controller.ts
  • src/main/daemon/terminal-descendant-shutdown.ts
  • src/main/runtime/orca-runtime-core.ts
  • src/main/runtime/terminal-close-slow-agent-exit.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment thread src/main/daemon/immediate-kill-reply-budget.ts
Comment thread src/main/runtime/terminal-close-slow-agent-exit.test.ts Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

  • Derived the budget from the force-kill retry — SESSION_FORCE_KILL_RETRY_MS (250 ms) moved into immediate-kill-reply-budget.ts and is now part of IMMEDIATE_KILL_REPLY_BUDGET_MS (12,250 ms), raising main's EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS to 14,250 ms; session-termination-controller.ts re-exports it so existing importers are unchanged.
  • Exercised an expired close deadline — the budget-expiry case now drives the real stopAndWaitPtyFromRuntimeController against a wedged daemon session with a frozen Date jumped to the deadline, instead of a stub that returned false immediately.

I traced the daemon sequence (killWithDescendantSweep → terminateShutdownDescendants → forceKillAndWaitForExit, including requestForceKillWithRetry's single retry wait) and the new term closes the gap the prior review flagged. The test is real coverage: reverting EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS to 2000 fails 3 of its 4 cases.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread src/main/runtime/terminal-close-slow-agent-exit.test.ts Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Dropped the tautological expiry assertion — the budget-expiry test no longer computes expiredBy and asserts it is >= 0. With Date faked and never advanced, that value was always 0, so the check could not fail. The prior inline thread at terminal-close-slow-agent-exit.test.ts:165 is resolved.

The remaining coverage still holds: the test jumps the frozen clock to deadlineMs, asserts budgetMs > IMMEDIATE_KILL_REPLY_BUDGET_MS, and keeps the release_unknown receipt, single fallback-kill, and unverifiable liveness assertions. This delta is the only change since the prior pullfrog review, and it addresses that review's one open thread.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit 0b16a31 into stablyai:main Sep 23, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant