Skip to content

fix(drift): process-group kill, fail-closed version bump, early-infra alerting#312

Merged
jpr5 merged 2 commits into
mainfrom
harden/drift-subprocess-bump-alerting
Jul 18, 2026
Merged

fix(drift): process-group kill, fail-closed version bump, early-infra alerting#312
jpr5 merged 2 commits into
mainfrom
harden/drift-subprocess-bump-alerting

Conversation

@jpr5

@jpr5 jpr5 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Hardens three drift-remediation tail workstreams (WS-4/6/8) as one coherent PR, each with a red-green regression lock (WS-10). All against origin/main @ a21e786.

Fixes

WS-4 — real process-group subprocess control (scripts/fix-drift.ts)

invokeClaudeCode spawned npx with no process group and gated the SIGKILL escalation on if (!child.killed). Node sets child.killed = true the instant SIGTERM is delivered (not when the process exits), so !child.killed is ~always false and SIGKILL never fires; and signalling the npx wrapper never reached the @anthropic-ai/claude-code grandchild — a wedged fixer survived SIGTERM and burned the full 30-min job budget.

Fix: spawn with detached: true (own process group), and on timeout SIGTERM then — after a grace — SIGKILL the whole group via process.kill(-pid, …), gated on a real has-exited flag flipped by the close event. Extracted killProcessGroup (ESRCH/EPERM tolerant) + scheduleEscalatingKill so the escalation is unit-testable against a real wedged child. Normal completion still reaps cleanly (the close handler cancels the grace timer and sets exited first).

WS-8 — version-bump failure must fail closed (scripts/fix-drift.ts)

createPr caught a version-bump/CHANGELOG failure, console.warn("…skipping"), and shipped an unversioned PR that never publishes a release (silent value loss). Fix: a bump failure is now a hard error — exit with a new VERSION_BUMP_FAILED reason (exit 18) routed to the workflow's human-review alert; no push, no PR. patchBumpVersion's own throw is unchanged; the caller now propagates it.

WS-6 — early-infra catch-all alert (.github/workflows/fix-drift.yml)

The four specific Slack alerts are all gated on step outputs that only exist after the collector ran, so an early failure (checkout / mint-app-token / pnpm install / ag-ui clone / git config) died red with zero Slack signal. Fix: added an end-of-job if: failure() catch-all that fires only when none of the specific alerts did (the early-infra window), labelling it an infra/setup failure distinct from a drift-fix failure. Tightened the autofix-step alert to require check to have run (skip == 'false') so it no longer misfires on that window (no double-alert). The Create-PR step now captures the script's exit code + reason= so a fail-closed exit (e.g. version-bump-failed) is named in the failure alert.

Red-green (WS-10 locks)

WS-4 — real wedged subprocess. A node child that traps SIGTERM and sleeps:

  • Empirical old-vs-new demo: OLD: child.killed=true -> SIGKILL fired? false ; process still alive? true vs NEW: exited=true ; process still alive? false.
  • RED: kill tests against pre-fix fix-drift.tskillProcessGroup/scheduleEscalatingKill are not a function (4 fail).
  • GREEN: 6/6 pass — group SIGKILL kills a SIGTERM-trapping child within grace; no stray SIGKILL when already exited; the !child.killed-guard contrast test proves the old dead-code path.

WS-8 — createPr fail-closed. Git mocked so the version-bump commit throws:

  • RED against pre-fix code → warns-and-continues, no exit 18 (test fails).
  • GREEN → exits __exit__18, emits reason=version-bump-failed, no push / no gh pr, no console.warn.

WS-6 — workflow static assertions.

  • RED against pre-fix yml → all 6 new assertions fail (no catch-all step, no version-bump-failed case).
  • GREEN → catch-all present + failure()-unconditional on step outcomes + anti-double-alert guard; autofix alert requires skip == 'false'; version-bump-failed reason wired.

Gates (all green locally)

pnpm test (4614 passed / 44 skipped) · format:check · lint · tsc --noEmit · build · actionlint (+ shellcheck) on fix-drift.yml · commitlint --from origin/main --to HEAD → 0 problems.

Deferred

  • Standalone actionlint-as-CI (nice-to-have, separate chore).
  • LOW WS-3 parsePorcelainLine C-unescape — not a trivial 1-3 line change (mirrored across two files + would need a shared helper), so skipped per the re-audit.

🤖 Generated with Claude Code

… alerting

Harden three drift-remediation tail workstreams as one PR (WS-4/6/8), each
with a red-green regression lock (WS-10).

WS-4 — real process-group subprocess control. invokeClaudeCode spawned npx
with no process group and gated SIGKILL escalation on `if (!child.killed)`,
which is ~always false right after SIGTERM delivery, so SIGKILL never fired
and killing the npx wrapper never reached the claude-code grandchild — a
wedged fixer burned the full 30-min budget. Now spawn with `detached: true`
and, on timeout, SIGTERM then (after a grace) SIGKILL the whole GROUP via
`process.kill(-pid, …)`, gated on a REAL has-exited flag from the `close`
event. Extracted killProcessGroup + scheduleEscalatingKill (ESRCH/EPERM
tolerant) so the escalation is unit-testable against a real wedged child.

WS-8 — version-bump failure must fail closed. createPr caught a version
bump/CHANGELOG failure, warned "skipping", and shipped an unversioned PR that
never publishes a release (silent value loss). Now a bump failure is a hard
error: exit with a new VERSION_BUMP_FAILED reason (exit 18) routed to the
workflow's human-review alert; no push, no PR. patchBumpVersion's own throw is
unchanged — the caller now propagates it.

WS-6 — early-infra catch-all alert. The four specific Slack alerts are all
gated on step outputs that only exist after the collector ran, so an early
failure (checkout / mint-app-token / pnpm install / ag-ui clone / git config)
died red with zero Slack signal. Added an end-of-job `if: failure()` catch-all
that fires only when none of the specific alerts did (early-infra window),
distinguishing infra/setup from drift-fix failure. Tightened the autofix-step
alert to require `check` to have run so it no longer misfires on that window.
The Create-PR step now captures the script's exit code + `reason=` so a
fail-closed exit (e.g. version-bump-failed) is named in the failure alert.

WS-10 — regression locks: a real SIGTERM-trapping subprocess killed within
grace (old logic leaves it alive, fixed logic kills it and the group); a
createPr fail-closed test (git mocked so the bump commit throws → exit 18, no
PR); workflow static assertions for the catch-all's presence + unconditional
`if: failure()` gating + the version-bump-failed reason wiring.

Deferred: standalone actionlint-as-CI; the LOW WS-3 parsePorcelainLine
C-unescape (not a trivial 1-3 line change across the two mirrored copies).
@pkg-pr-new

pkg-pr-new Bot commented Jul 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@312

commit: 3079939

Address CR-confirmed residuals from the tail batch on top of the WS-4/6/8
core fixes. Edges, observability, and test-quality only.

- invokeClaudeCode: a synchronous throw while wiring the stdout/stderr
  handlers (e.g. null streams from a spawn edge case) armed the 30-min
  killTimer at line 473 but never cleared it, stranding the timer and
  risking a late group-kill on a reused PID. Guard the handler setup in
  a try/catch that clears killTimer before rejecting (slot2-F1).
- killProcessGroup: stop treating EPERM as a benign "nothing to kill".
  EPERM can mean the group is alive but unkillable by us (re-credentialed
  or re-parented child) and still burning the budget. Log a distinct
  warning and attempt a single-PID fallback; ESRCH stays benign
  (slot2-F5).
- fix-drift.yml: quarantine (collector exit 5) now fails the job with
  exit 1 after the Slack ping, so a human watching CI status (not just
  Slack) sees the needs-human condition. The fix-failure alert and the
  catch-all are both disjoint from quarantine (skip == 'true'), so no
  double-alert (slot2-F3).
- Fail-closed parse/git paths now emit a NAMED reason instead of a blank
  one: a new POST_FIX_PARSE_ERROR (19) wraps the parsePostFixExit /
  readPostFixReport throw in PR mode, and GIT_PUSH_FAILED (20) wraps the
  createPr git checkout/add/commit/push sequence. Both are wired into the
  workflow's reason -> message case block (slot2-F7/F12).
- Tighten the WS-4 kill locks: the "does NOT SIGKILL when already exited"
  test now uses a SIGTERM-trapping child forced hasExited()==true so it
  genuinely proves the escalation is SKIPPED (the old obedient-child
  version passed for the wrong reason); add a no-late-timer lock and an
  executor-throw lock (fix-drift-invoke.test.ts); replace the illustrative
  old-guard contrast with real revert-guards (slot3-F1/F2).

Red-green verified per fix by stashing the source fixes and running the
new tests against the pre-fix code. Full suite (4666), format, lint,
tsc, actionlint, and build all pass.
@jpr5
jpr5 marked this pull request as ready for review July 18, 2026 00:47
@jpr5
jpr5 merged commit 306ece5 into main Jul 18, 2026
29 checks passed
@jpr5
jpr5 deleted the harden/drift-subprocess-bump-alerting branch July 18, 2026 00:47
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