Fix startup crash when a stuck workdir lock survives an interrupt - #596
Merged
TheGreatAxios merged 2 commits intoAug 24, 2026
Conversation
TheGreatAxios
enabled auto-merge (squash)
August 24, 2026 05:06
…rejection close() on the agent package releases its workdir lock only after reactor.abort()/sendQueue.drain() and the shutdown-complete race finish. A throw partway through (most likely right when an operator interrupts mid-inference, exactly when those paths are stressed) leaves the lock held forever in-process: the agent is already marked closed, so retrying close() is a silent no-op that can never release it. The next buildAgent() for that workdir then throws AgentContextLockError, and reloadIfIdle's rebuild had no try/catch around it, so the throw escaped as an unhandled rejection and crashed the process. Route every rebuild site (interrupt, reload, session rotation) through a shared close-then-check helper: a failed close now short-circuits the rebuild instead of attempting a second, doomed acquisition, and the failure surfaces as a plain-language caught error.
Session rotation was never routed through closeAgentForRebuild: it mints a
fresh sessionId/workdir before rebuilding, so a leaked lock on the old
workdir can never be re-acquired there. Write that reasoning down at the
call site and next to closeAgentForRebuild's doc comment, since the
asymmetry across the three rebuild sites needs an explanation the next
reader can find.
Replace the helper-only regression test with one that drives the real
session-operation-queue the same way reloadIfIdle actually calls it (void
enqueue(...), no awaited return value) and asserts, via a real
process.on("unhandledRejection") listener, that the rejection is contained
and surfaces through fatalBuildError instead of escaping. reloadIfIdle
itself can't be reached in isolation without standing up the full TUI
runner; that's noted at the test.
TheGreatAxios
force-pushed
the
cl-5753-startup-after-an-interrupt-crashes-with-an-agent-already-open
branch
from
August 24, 2026 05:09
f6709c4 to
5750fd1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CL-5753. Upstream ordering bug in @intx/agent filed as CL-6984, referencing this PR.
Reproduction status
Could not reproduce the operator's exact reported crash on current
main(43+ merges landed since the ticket was filed; no test or code currently touchesAgentContextLockErrorat all, so nothing recently could have fixed it either). But reading the actual acquisition path confirmed a real, still-present bug matching the ticket's diagnosis exactly, which this PR fixes with a regression test proving the failure mode and the fix.Root cause
@intx/agent'sclose()(node_modules/@intx/agent/dist/agent.js, lines 548-592) setsclosed = trueat line 551 — before teardown (reactor.abort(),sendQueue.drain(), the shutdown-complete race, lines 552-591) — and only callslock.release()last, at line 592. If any step in between throws — most likely exactly when an operator interrupts mid-inference, which is when those paths are under the most stress — the lock is never released. Becauseclosedis alreadytrue, a retriedclose()short-circuits at line 549 and also never reacheslock.release(). The lock is now stuck held for the rest of the process.src/tui/runner.tshas three rebuild sites (interrupt,reloadIfIdle, session rotation) that callcurrentAgent.close().catch(...)— deliberately swallowing close errors — and then callbuildAgent(). Whenclose()fails to reachlock.release(), a secondcreateAgent()for the same workdir throwsAgentContextLockError("an agent is already open for workdir: ...") for a lock nothing will ever free.The unhandled-rejection path is concrete, not inferred:
session-operation-queue.ts'senqueuedoestail = tail.then(op, op); return tail;, andreloadIfIdlecallsvoid enqueueOp(async () => { ... })with no internal try/catch — so a rejecting op has nothing to catch it, andvoiddiscards the only promise reference that could. That is the unhandled rejection from the ticket.interrupt()and session rotation already had a try/catch around this sequence, so they failed contained;reloadIfIdledid not.This is not a stale on-disk lock file (there isn't one — the lock is deliberately in-process only, per its own doc comment) and no lock-file/expiry/force-unlock mechanism has been introduced.
Fix
closeAgentForRebuild(agent, context)helper: closes the current agent, swallows and logs the close error same as before, but now returns whether it succeeded.agentRebuildFailure(err)helper: turnsAgentContextLockErrorinto a clear, plain-language message ("Could not start a new agent: the previous one did not shut down cleanly. Restart Corbits to continue.") and passes any other error through unchanged.interruptandreloadIfIdlenow: on a failed close, skip the secondcreateAgent()call entirely (eliminating the doomed second acquisition) and surfaceagentRebuildFailurethrough the existingfatalBuildError/recordRunErrorpath instead of throwing raw.reloadIfIdlenow has the try/catch it was missing, so this can no longer escape as an unhandled rejection.closeAgentForRebuild's doc comment: rotation always mints a freshsessionId/workdir before callingbuildAgent(), so even aclose()that leaks the old workdir's lock can never be re-acquired there —buildAgent()targets a directory nothing has ever locked. The old lock still leaks for the rest of the process in that case, but nothing tries to re-acquire it, so there's no crash to guard against on that path. Left as-is rather than adding a guard that would do nothing, per review.What this does and doesn't fix
This removes the crash, not the lock leak. On a failed close, the session becomes a graceful dead end:
currentAgentstays the closed agent, the nextsend()throws the friendly error, and that workdir can never build a new agent for the rest of the process. Restart is the only recovery — same as before, just without the crash and stack trace. The actual ordering bug in@intx/agent(markingclosedbeforelock.release(), making retries no-ops) is the real fix and is out of scope here; it's filed upstream as CL-6984.Test plan
tests/unit/tui/runner.test.ts:closeAgentForRebuild/agentRebuildFailureunit tests, plus an end-to-end test that drives the realsession-operation-queue.tsthe same wayreloadIfIdleactually calls it (void enqueue(...), return value never awaited) with a realprocess.on("unhandledRejection")listener, proving the rejection is contained and surfaces throughfatalBuildErrorrather than escaping.reloadIfIdleitself can't be exercised in isolation — it's a closure over ~15 ofrunTUI's local variables (currentAgent,buildAgent,streamPromise,workflowController,pendingReload/inFlight,fatalBuildError, etc.) with no seam short of standing up the full TUI runner (provider config, plugin discovery, MCP wiring, a real OpenTUI host), which is out of scope for this fix.bun run checkgreen in the foreground (lint: 0 errors/1364 pre-existing warnings, typecheck clean, build clean, 5371 tests passing / 0 failed).