Skip to content

[#975] De-block the event loop#985

Merged
realproject7 merged 1 commit into
mainfrom
task/975-deblock-event-loop
Jul 6, 2026
Merged

[#975] De-block the event loop#985
realproject7 merged 1 commit into
mainfrom
task/975-deblock-event-loop

Conversation

@realproject7

Copy link
Copy Markdown
Owner

Closes #975

De-block the event loop. QuadWork runs as a single Node process, so any synchronous block on the main thread freezes every agent's WebSocket, HTTP, and timers at once. This removes the 3 confirmed stalls — behavior-preservingly (only the blocking is removed; outcomes are unchanged).

Changes

  1. file-chat.js appendMessage busy-wait — the append retry loop slept via Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100), a synchronous 100ms block on the main thread (up to 300ms across 3 attempts) on every chat + system message. Dropped the sleep — retries now run immediately, attempt count unchanged. appendFileSync transient failures are rare and not time-dependent, so the busy-wait bought nothing but latency.

  2. index.js spawnButlerPty pre-trust — ran execFileSync("claude", ["-p", "echo ok"], { timeout: 15000 }) synchronously on the event loop; a hung claude blackholed the whole server for up to 15s. Dropped it: the interactive trust prompt is already auto-answered by the onData trust-listener installed a few lines below (the same mechanism the agent PTYs rely on), so no synchronous pre-trust is needed to reach a trusted session.

  3. routes.js /api/setup route — the setup shell-outs (gh repo clone, git fetch, git worktree add, claude -p) ran synchronously in the request handler. ensureGitHeadForSetup and createAgentWorktree are now async, awaiting an injectable execFn (default the new execAsync, backed by the already-imported _execFileAsync = promisify(execFile)); the /api/setup handler is async and awaits them. Express 5 forwards a rejected async handler to the default 500 handler — the same result the prior synchronous throws produced. The now-dead synchronous exec() wrapper was removed (execFileSync is still used directly by the small gh api helpers).

EPIC Alignment (#967)

EPIC #967 Phase 3, the last fix ticket. Isolated to three de-block sites; the one change on the chat write path (file-chat) is behavior-preserving with ordering verified below. No agent-spawn / steady-state runtime change beyond removing blocking. JSONL rotation is explicitly out of scope (a separate future ticket, per the issue).

Self-Verification

  • npm test (server suite, what CI runs) → 60 passed, 0 failed, 2 skipped. Both setup test files were migrated to inject an async fake execFn and await the now-async functions: routes.setupWorktrees.test.js 4/4 (stale/empty/clone HEAD paths), routes.setupHardening.test.js 6/6 (slug normalization, origin match/mismatch, worktree fresh/stale-reuse/detached).
  • node --check on all 5 files → clean.
  • Server boots + shuts down cleanly on this branch (temp HOME, free port) → prints listening, exits on SIGINT with no errors.
  • Chat ordering + ids are unaffected (the ticket's key safety check): the message id is assigned synchronously (state.nextId++) before the append, and appendFileSync writes in call order — removing the inter-retry sleep changes neither. Verified at runtime: 50 sequential appendMessage calls → ids 1..50 contiguous and strictly monotonic, and the on-disk JSONL reads back in ascending id order. The existing file-chat.test.js / file-chat.system-messages.test.js / file-chat.loopguard.test.js all still pass.

🤖 Generated with Claude Code

QuadWork is single-process, so any synchronous block on the main thread
freezes every agent's WS/HTTP/timers. Removes the 3 confirmed stalls,
behavior-preservingly (only the blocking removed).

1. file-chat.js appendMessage — the retry loop slept via
   Atomics.wait(...,100), a synchronous 100ms block (up to 300ms per
   append) on EVERY chat + system message. Dropped the sleep; retries now
   run immediately (attempt count unchanged). appendFileSync transient
   failures are rare and not time-dependent, so the busy-wait bought
   nothing. Message ordering + ids are unaffected — the id is assigned
   synchronously before the append and appendFileSync writes in order
   (verified: 50 appends → contiguous monotonic ids, readback ordered).

2. index.js spawnButlerPty — the pre-trust ran `claude -p "echo ok"`
   synchronously with a 15s timeout on the event loop; a hung claude
   blackholed the server for up to 15s. Dropped it: the interactive trust
   prompt is already auto-answered by the onData trust-listener installed
   right after (the same mechanism agent PTYs rely on).

3. routes.js setup route — gh clone / git fetch / git worktree add /
   claude -p ran synchronously in the request handler. ensureGitHeadForSetup
   and createAgentWorktree are now async (awaiting an injectable execFn,
   default the new execAsync = promisified execFile); the /api/setup handler
   is async and awaits them. Express 5 forwards a rejected async handler to
   the default 500 handler, same as the prior sync throws. Removed the now-
   dead synchronous exec() wrapper.

Tests: both setup test files migrated to inject an async fake exec and
await (setupWorktrees 4/4, setupHardening 6/6). Full suite 60 passed / 0
failed. Server boots + shuts down cleanly on this branch.

Note (not implemented here, per ticket): JSONL rotation is a separate
future ticket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: APPROVE

Epic Alignment: PASS

EPIC #967 Phase 3 calls for minimal local hardening without regressing the orchestrator; #975 specifically requires behavior-preserving removal of chat/butler/setup event-loop stalls, and the diff does exactly that.

Checked (evidence)

  • Structural gate: PR body contains Closes #975, filled ## EPIC Alignment (#967), and filled ## Self-Verification.
  • Context: gh issue view 975 and gh api repos/realproject7/quadwork/issues/967 read live; contract checked: behavior unchanged, only blocking removed, use async execFile path in routes.
  • Chat path: server/file-chat.js:146 still assigns ids synchronously before append; server/file-chat.js:166 keeps 3 attempts; server/file-chat.js:178 removes the Atomics.wait sleep.
  • Butler path: server/index.js:1009 removes the blocking pre-trust claude -p; server/index.js:1077 keeps the trust prompt listener.
  • Setup path: server/routes.js:3691 uses promisified async execFile; server/routes.js:3711 and server/routes.js:3777 make setup helpers async; server/routes.js:3884 makes /api/setup async and awaits shell-outs at server/routes.js:3892, server/routes.js:3906, server/routes.js:3920, server/routes.js:3937.
  • Tests updated for async setup helpers: server/routes.setupWorktrees.test.js and server/routes.setupHardening.test.js migrated to awaited fake execFns.
  • Riskiest part of this diff: chat write path ordering; acceptable because id/seq assignment remains before append, append remains synchronous, and only inter-retry sleep was removed.
  • Kill-list: scanned all items — clean.
  • CI: gh pr checks 985test pass, 52s.

Findings

None.

Decision

Approved. The changes are scoped to the three #975 stalls, preserve the relevant contracts, and CI passes on current head 8d1b61dfb40863b899d2080178faf61eed3bab56.

@realproject7

Copy link
Copy Markdown
Owner Author

APPROVE at 8d1b61dfb40863b899d2080178faf61eed3bab56

Full independent review — issue #975 De-block the event loop (EPIC #967 Phase 3, last fix ticket). Behavior-preserving removal of 3 synchronous main-thread stalls; comment-based approval (shared reviewer GH identity blocks a formal Approve).

Checked (evidence)

  • file-chat busy-wait removed (stall 1)server/file-chat.js:178: Atomics.wait(...,100) dropped from the append retry loop; retry is now immediate, attempt count unchanged (retries = 3). Ordering/ids safe because id is assigned synchronously at :146 (state.nextId++) before any I/O and the whole appendMessage is synchronous (appendFileSync), so no interleaving.
  • butler pre-trust removed (stall 2)server/index.js:1006-1012: the blocking execFileSync("claude",["-p","echo ok"], timeout 15000) is gone; the interactive trust prompt is auto-answered by the onData trust-listener at server/index.js:1077-1091 (writes "1\r"), the same mechanism agent PTYs use. No sync pre-trust needed.
  • setup shell-outs async (stall 3)server/routes.js:3691 new execAsync (promisified _execFileAsync, imported :7) keeps the old {ok, output} contract; ensureGitHeadForSetup (:3711) and createAgentWorktree (:3777) are now async with injectable execFn = execAsync, all call sites await; handler router.post("/api/setup", async …) at :3884. Express 5.2.1 (package.json ^5.2.1) forwards a rejected async handler to the default error handler → same 500 as the prior sync throw. Removed sync exec() wrapper has no remaining callers.

Verification

  • Chat ordering/ids (flagged invariant) — verified empirically: drove appendMessage 50× → returned ids contiguous+monotonic 1..50, on-disk JSONL ascending+contiguous 1..50, returned order === disk order.
  • node --check clean on all 5 files; both migrated setup tests pass (setupHardening 6/6, setupWorktrees 4/4); full suite 60 passed, 0 failed (shared deps); CI test SUCCESS.

No findings.

@realproject7 realproject7 merged commit f6533e9 into main Jul 6, 2026
1 check 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.

[#S9] De-block the event loop (file-chat busy-wait, butler/setup execFileSync)

2 participants