Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,65 @@ jobs:
chmod +x tools/linux/*.sh install.sh
bats --print-output-on-failure tests/linux/

# ----- macOS: bats suite for tools/macos/ + install.sh (MEDIUM tier) ----
# arm64 only (macos-14). Hosted Intel macOS (macos-13) was dropped in
# v1.2.0: GitHub is retiring hosted Intel macOS, so the macos-13 leg
# never got a runner -- every run sat in "awaiting a runner" until
# GitHub's hard 24h account ceiling and was auto-cancelled. timeout-minutes
# bounds execution time AFTER a runner is assigned, never queue time, so
# no workflow knob can rescue a starved label. The runtime scripts are
# architecture-neutral (process groups / signals / ps, no arch-specific
# paths) and bash-3.2-safe by construction; the "Static bash-3.2 safety
# check" step below is the standing proof of the 3.2 constraint, so only
# x86_64 *execution* is uncovered, not syntax. Restoring an Intel
# execution leg is tracked as an explicit open gap: issue #3. Matrix kept
# single-entry (not collapsed) so re-adding Intel is a one-line change;
# fail-fast stays off for when it returns.
macos:
name: bats (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-14]
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Install bats-core
# bats-core is the canonical Homebrew formula (it installs the
# `bats` binary); the bare `bats` formula is the dead pre-1.0 one.
run: brew install bats-core

- name: Probe environment (record which bash actually runs the suite)
# macOS ships Apple's frozen bash 3.2.57 at /bin/bash, but GitHub
# runners also put a modern Homebrew bash earlier on PATH. The
# wrapper is written 3.2-safe by construction; this records in the
# log which bash the functional suite exercises so the gap is
# documented, not hidden.
run: |
sw_vers
echo "arch: $(uname -m)"
echo "bats: $(bats --version)"
echo "PATH bash: $(bash --version | head -1)"
echo "/bin/bash: $(/bin/bash --version | head -1) <- Apple stock 3.2.57"

- name: Static bash-3.2 safety check (parse under Apple stock /bin/bash)
# bats may run the suite under Homebrew bash, so separately prove
# the runtime scripts at least PARSE under the frozen 3.2.57 every
# real macOS user has. Catches syntax-level 3.2 breakage directly
# instead of trusting the runner's newer bash to stand in for it.
run: |
/bin/bash -n tools/macos/claude-jobbed.sh
/bin/bash -n tools/macos/find-claude.sh
/bin/bash -n install.sh
echo "tools/macos/*.sh + install.sh parse clean under bash 3.2.57"

- name: Run bats suite
run: |
chmod +x tools/macos/*.sh install.sh
bats --print-output-on-failure tests/macos/

# ----- Windows: existing PowerShell suite (Wave A retroactive coverage) --
windows:
name: pwsh (windows-latest)
Expand Down
49 changes: 40 additions & 9 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# claude-code-structured-concurrency — Design Specification

> Version: 1.0.0
> Status: shipped — 22+1 tests passing, 9ms reap latency verified on Windows 11 build 26200
> Version: 1.2.0
> Status: shipped — Windows STRONG (v1.0.0), Linux STRONG+MEDIUM (v1.1.0), macOS MEDIUM + cmd.exe shim (v1.2.0). 36+1 pwsh + 40 bats tests passing; 9ms reap latency verified on Windows 11 build 26200.
> Author: Ronil Basu ([@ron2k1](https://github.com/ron2k1))
> Created: 2026-05-07
> Revised: 2026-05-16 — v1.2.0 macOS MEDIUM tier + cmd.exe shim

## Naming note

The skill is named after the OS-level concept (**structured concurrency**) -- that's what senior engineers will recognize from Trio, Kotlin coroutines, and Swift Concurrency. Inside the codebase, **"reap"** stays as the operational verb (function names, `~/.reap/` config dir, `reap.log`). The name signals *what it is*; the verb describes *what it does*.

## Problem statement

Claude Code (CC) on Windows spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain: `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`). When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots.
Claude Code (CC) spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain — on Windows `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`); on Linux/macOS the analogous `sh → npx → node` / `sh → uvx → python`. When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots.

Cumulative effect, observed 2026-05-07:
- 14 user-global MCPs + ~30 plugin MCPs ≈ 40-60 node.exe per active session
Expand All @@ -23,7 +24,32 @@ Cumulative effect, observed 2026-05-07:

- Killing all `node.exe` by name alone — would terminate active CC itself. The decision flow always checks `spare_classifications` first, so `claude.exe` (classified as `claude`) cannot be killed even if `node.exe` is in `kill_names`. This invariant is exercised explicitly in `tests/test-config-loader.ps1`.
- Replacing CC's own subprocess discipline. Anthropic's harness can and should ship Job Objects natively; this skill is the user-side workaround until then.
- Running on macOS or Linux. Those platforms already have OS-level reapers (`prctl(PR_SET_PDEATHSIG)` + cgroups on Linux, equivalent semantics on macOS).
- A STRONG (kernel-enforced, SIGKILL-proof) guarantee on macOS. macOS has no Job Object, no `cgroup.kill`, and no `prctl(PR_SET_PDEATHSIG)` — there is no kernel primitive that atomically reaps a process subtree on ancestor death. macOS is MEDIUM by construction (process group + `trap` + a disowned out-of-process watchdog); the honest ceiling — a simultaneous `kill -9` of both wrapper and watchdog — is stated in the guarantee matrix below and pinned by `tests/macos/test-honesty.bats`. Closing that gap would need a Swift `kqueue`/`launchd` helper and is conditional on telemetry.

> Note: an earlier draft listed "Running on macOS or Linux" as a non-goal, asserting those platforms "already have OS-level reapers ... equivalent semantics on macOS." That was wrong on both counts: Linux shipped STRONG+MEDIUM in v1.1.0 and macOS shipped MEDIUM in v1.2.0, and macOS specifically has *no* such reaper — that absence is the entire reason it tops out at MEDIUM. The only genuine remaining non-goal is the macOS STRONG gap above.

## Cross-platform guarantee matrix

The design goal is identical on every platform — *the OS, not application code, enforces parent-death cleanup* — but the available kernel primitive differs, so the strength of the guarantee differs. That difference is stated honestly rather than papered over; the install banner and the test suite both encode it.

| Platform | Mechanism | Survives SIGKILL of wrapper? | Tier |
|---|---|---|---|
| Windows 10+ | Win32 Job Object + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` | Yes — kernel reaps on job-handle close, including Task Manager End-Task | **STRONG** (v1.0.0) |
| Linux ≥5.14 | `systemd-run --user --scope` + `cgroup.kill` | Yes — cgroup-level kill, kernel-enforced; an out-of-process watchdog supervises the scope so even SIGKILL of the wrapper still triggers `cgroup.kill` | **STRONG** (v1.1.0) |
| Linux <5.14 / no systemd / WSL1 | `set -m` + `trap` on EXIT/INT/TERM/HUP + `killpg` | No — a bash trap cannot fire on `kill -9` | **MEDIUM** (v1.1.0) |
| macOS | `set -m` + `trap` + disowned out-of-process watchdog | Partial — survives Force-Quit/SIGKILL of the wrapper *alone* (the watchdog outlives it and reaps the tree); does **not** survive a simultaneous SIGKILL of wrapper *and* watchdog | **MEDIUM** (v1.2.0) |

### Why macOS is MEDIUM, not STRONG

Windows has the Job Object; Linux ≥5.14 has `cgroup.kill`. macOS has neither, and `prctl(PR_SET_PDEATHSIG)` is Linux-only. There is no macOS syscall that says "kill this whole subtree when the ancestor dies." The MEDIUM design extracts the maximum the OS allows:

1. `set -m` puts the spawned `claude` in its own process group, so it can be `killpg`'d without signaling the wrapper.
2. `trap 'cleanup' EXIT INT TERM HUP` handles every *catchable* exit of the wrapper.
3. A disowned watchdog subshell — in its *own* process group, so step 2's `killpg` cannot take it down — records the wrapper's PID and start time (`ps -p $pid -o lstart=`; macOS has no `/proc`) and polls. When the wrapper vanishes for *any* reason, including the un-catchable `kill -9` that defeats step 2, the watchdog runs the same `cleanup()` and reaps the tree. On graceful exit the wrapper `kill -KILL`s the watchdog (the watchdog traps catchable signals by design, so SIGTERM would not stop it).

The watchdog is what lifts macOS from WEAK (`setpgid`+`trap` only, which dies with the wrapper on `kill -9`) to MEDIUM. The residual gap — a *simultaneous* `kill -9` of wrapper and watchdog — is unrecoverable because nothing is left alive and macOS has no kernel fallback. That exact scenario is asserted, and proven still-failing-by-design, in `tests/macos/test-honesty.bats`, so the ceiling is documented as an executable test, not just prose.

This reuses the Linux v1.1.0 watchdog architecture: the out-of-process supervisor pattern was first built so the Linux STRONG path could survive a wrapper SIGKILL (the watchdog re-triggers `cgroup.kill`). macOS borrows the same supervisor idea but, lacking `cgroup.kill`, tops out at MEDIUM instead of STRONG.

## Architecture

Expand Down Expand Up @@ -126,21 +152,26 @@ Tests in `tests/`:
1. `test-job-object.ps1` -- functional proof that `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` works on this Windows build. Spawns a sleeping `node`-like child, closes the job handle, asserts the child died within 2 seconds. **Verified 9ms reap latency on Windows 11 build 26200.**
2. `test-orphan-detect.ps1` -- 9 unit tests on synthetic process snapshots: orphan detection (with PID-reuse guard via `StartTime` comparison), classification, descendant tree walk.
3. `test-config-loader.ps1` -- 9 unit tests on the config schema: defaults, malformed-JSON fallback, partial-config merge, and the spare-wins-over-kill safety invariant.
4. `test-spawn-plan.ps1` -- 14 assertions on wrapper host-routing for `.cmd`/`.bat`/`.ps1` Claude shims (npm-installed Claude ships a shim, not an `.exe`) and extension-priority resolution.
5. `tests/linux/*.bats` (18) + `tests/macos/*.bats` (22) -- find-claude probe priority, pgid/watchdog cleanup, installer idempotency, and the two load-bearing *negative* tests: `tests/linux/test-cgroup-kill.bats` (Linux STRONG must survive wrapper SIGKILL via `cgroup.kill`) and `tests/macos/test-honesty.bats` (the macOS MEDIUM ceiling — simultaneous wrapper+watchdog SIGKILL leaks by design, and is asserted to still leak so it cannot silently regress).

All three suites must pass before any release tag. **22+1 tests passing as of v1.0.0.** CI on Windows runners is a v1.1 follow-up.
All suites must pass before any release tag. **36+1 PowerShell + 40 bats (18 Linux + 22 macOS) passing as of v1.2.0.** CI runs `ubuntu-latest`, `macos-14`, and `windows-latest` on every push; see `.github/workflows/test.yml`. Hosted Intel `macos-13` was dropped in v1.2.0 — GitHub is retiring hosted Intel macOS, so that leg never received a runner and ran to GitHub's hard 24h "awaiting a runner" ceiling (`timeout-minutes` bounds execution after assignment, never queue time); the scripts are architecture-neutral and bash-3.2-safe by construction, so only x86_64 *execution* is uncovered, tracked as an explicit open gap (#3). (CI on Windows runners was the v1.1 follow-up promised in the original spec; it shipped in v1.1.0 alongside the Linux port and was extended to macOS in v1.2.0.)

## Open questions (deferred)

- WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent? (Likely no -- WSL already reaps via cgroups, but verify.)
- ~~WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent?~~ Resolved in v1.1.0. WSL2 (real Linux kernel ≥5.14) takes the Linux STRONG `cgroup.kill` path; WSL1 (no real cgroup v2 / systemd) falls to the MEDIUM `set -m`+`trap` path. `CLAUDE_JOBBED_FORCE_FALLBACK=1` exercises that fallback in CI on a systemd-equipped runner.
- Intel (x86_64) macOS execution coverage: dropped from CI in v1.2.0 — GitHub is retiring hosted Intel macOS, so the `macos-13` leg never received a runner (it ran to GitHub's hard 24h "awaiting a runner" ceiling on every push, since `timeout-minutes` bounds execution after assignment, never queue time). The runtime scripts are architecture-neutral and bash-3.2-safe by construction, and the `/bin/bash -n` static-parse step still proves the 3.2 syntax constraint, so only x86_64 *execution* is uncovered. Restoring an Intel execution leg (self-hosted or paid runner) is tracked in #3.
- Multi-session telemetry: should we track per-session spawn counts and report at end? (v1.1 feature.)
- Plugin authors writing their own predicates: pluggable filter chain at the Layer-2 level? (v1.2 if asked.)
- Windows Server / older Windows 10 builds: Job Object behavior was unreliable pre-build 17134 (January 2018). Currently documented as a hard floor; could be loosened with a runtime probe.

## Versioning

- v1.0.0 -- this release. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script.
- v1.1.0 -- CI on Windows runners; multi-session telemetry; configurable log retention.
- v1.2.0 -- pluggable filter chain; companion macOS/Linux verifier (so cross-platform users can lint-check their config).
- v1.0.0 -- Windows. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script.
- v1.0.2 / v1.0.3 -- installer `-ShadowClaude` (plain `claude` routes through the wrapper as a function, not a `Set-Alias`); wrapper host-routes npm-installed `.cmd`/`.ps1` Claude shims through `cmd.exe /c` / `powershell.exe -File`.
- v1.1.0 -- Linux. `tools/linux/` find-claude (9-probe) + two-tier wrapper: STRONG via `systemd-run --user --scope` + `cgroup.kill` (kernel ≥5.14) with an out-of-process watchdog supervising the scope, MEDIUM `set -m`+`trap` fallback for older kernels / no-systemd / WSL1. GitHub Actions matrix added (`ubuntu-latest` + `windows-latest`). bats suite.
- v1.2.0 -- macOS + cmd.exe (this release). `tools/macos/` find-claude (bash-3.2-safe; fnm probe also checks `~/Library/Application Support/fnm`) + MEDIUM wrapper (`set -m` + `trap` + disowned out-of-process watchdog; honest simultaneous-SIGKILL ceiling pinned by `tests/macos/test-honesty.bats`). `tools/claude-jobbed.cmd` shim so cmd.exe inherits the Windows STRONG Job Object. CI extended with the `macos-14` (Apple Silicon) leg plus a `/bin/bash -n` static-parse step against Apple's stock 3.2.57; hosted Intel `macos-13` execution was dropped (GitHub is retiring hosted Intel macOS; the leg never got a runner) and is tracked as an explicit open gap (#3).
- Deferred -- pluggable Layer-2 filter chain; multi-session spawn telemetry; configurable log retention; a Swift `kqueue`/`launchd` helper to lift macOS toward STRONG (conditional on telemetry showing real demand).

## Related work

Expand Down
Loading
Loading