v0.0.16 - #4
Merged
Merged
Conversation
Both fixes shipped in 0.0.15 without the test scaffolding their issues specified, so the constructs that caused them had no executable guard. TEST.md Scenario S: split S3 into S3a and S3b. The old single step gated on `/bin/bash` being 3.2, so it SKIPped on every non-macOS host — silent exactly where the bug is easiest to reintroduce. S3a is a text scan for a here-doc opened inside `$( … )`: it tracks quote state, skips comments and here-doc bodies, ignores `$(( ))` and `<<<`, and never SKIPs. A self-test rebuilds the pre-fix construct and asserts the scan rejects it, so the check is proven to catch the bug rather than merely to pass beside it. S3b runs a real bash 3.x parse over claudezero.sh plus the three emitted scripts, reports the version, SKIPs rather than FAILs with no bash 3.x, and degrades to a note when S2 never emitted. TEST.md Scenario G2: automated with script(1), which supplies the pty. A helper picks the BSD or util-linux argument order so no one has to choose. The split is asserted in both directions — claude's TUI frames reach the terminal and never the capture file; ClaudeZero's own report reaches the capture file and, on the redirect form, never the terminal. The `tee` form cannot carry that second assertion, since echoing to the terminal is what tee is for. G3 keeps the real-claude kqueue check manual: no stub reproduces a Node runtime rejecting the descriptor. CONTRIBUTING.md: state that claudezero.sh must parse under bash 3.2, and name the here-doc-inside-command-substitution construct as forbidden beside the `read -r -d ''` form that replaces it. claudezero.sh is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e zero prompt Step c told the agent to implement task_id and to scope its edits, but never said what "done" means when the task line points at an issue file. Nothing instructed it to satisfy the acceptance criteria, and nothing instructed it to tick them. Issues consequently landed marked complete with criteria unmet, and in two cases fully ticked with no implementation behind them at all. Step c now reads the spec first and constrains edits second. The order matters: "scope every edit to task_id only" is a constraint on a boundary the criteria are what define, and criteria routinely reach files the task title never names — BUG 024's script fix landed while its TEST.md and CONTRIBUTING.md criteria did not. Reading the constraint first invites exactly that under-scoping. Ticking is bounded by "never ahead of verifying it": an instruction that only says "tick as you go" reproduces the failure it is meant to prevent. Applies only when the task line names an issue/spec file; a plain todo is unaffected. The merge gate is unaffected too — checkbox-merge diffs the todo file alone, so boxes ticked in an issue file are invisible to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ser (BUG-029) Bash defers every trap while waiting on a foreground child, so a SIGTERM arriving while claude hung could not be handled until the follow-up SIGKILL ended the run mid-wait: no closing report, no fleet TOTAL, and a leaked instance marker. Launch claude backgrounded and reap it with a re-entrant `wait` so a trapped signal interrupts the wait instead of being deferred. The TERM handler sets STOP (loop breaks to the existing closer), sets TERMED, and forwards TERM to claude so a hung child dies with us. The closer exits 143 when TERMED, as an `if` so a normal run does not leak status 1 through `set -e`. TEST.md Scenario K covers the hang (exit 143, report present, no orphan) and the two paths that must not change: a clean exit still ends 0, and claude's own 143 still restarts.
…e (ISSUE-030) A restart or MAX_LOOPS-stop line said only "claude exited after N runs", so a hang the outer timeout had to kill was indistinguishable from a clean exit without re-deriving it from timing. Capture the reaping wait's status (inside the loop body — `$?` after it is `break`'s own 0) and fold it into both existing lines as "claude exited with code %s after %s runs · …": one line per event, not a second diagnostic one. Build claude's argv as CLAUDE_ARGS so CLAUDEZERO_DEBUG can append --debug-file .git/debug-<base>-<instance>-<loop>.log; the loop number keeps a restart from truncating the previous run's trace. Off by default: unset, the argv is unchanged. TEST.md Scenario L covers both. Scenario K's launch-line grep follows the argv into CLAUDE_ARGS.
ISSUE-031: zero one task per claude session, move the wait for a claimable task into the shell, drop the /loop instruction. Keeps the context-full restart as the context-rot guard. ISSUE-032: watchdog timer for a claude hung mid-turn, CLAUDEZERO_WATCHDOG (default 900s, 0 disables), with its own console line on a kill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e task in the shell (ISSUE-031) The shell, not claude, now decides whether a session is worth starting: nothing unchecked left goes straight to the closer, and everything unchecked already held by a live peer waits in `wait_for_claimable`, launching no claude and spending no tokens. The zero prompt drops `/loop` and ends its turn after one task; the Stop hook ends a zero-mode session at every turn end, so each task starts on a context isolated from the task before it. The context-full restart is unchanged and still takes precedence. `CLAUDE_PID` is initialized before the loop: Claude Code exports it (its own pid) into every Bash-tool env, and the new wait is the first window where a TERM can arrive before claude has launched — without the init, `on_term` SIGTERMs the session that ran claudezero. Tests: new scenario M (one task per session, the shell waits, the Stop hook's two exit paths), including an assert that the TERM trap leaves an inherited CLAUDE_PID alone. Scenario G's report counts and scenario A's `todos counted` pattern are corrected for the closing report and fleet TOTAL added in ISSUE-017/BUG-022. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A claude that stops making progress never exits, so the run loop parked on it forever — no restart, no report, nothing to stop but the whole run. Each launch now arms a timer (CLAUDEZERO_WATCHDOG, default 15m; plain seconds or an s/m/h suffix, 0 disables). On expiry it names itself on its own console line, SIGTERMs claude onto the existing restart path, and escalates to SIGKILL 10s later. It counts down in short naps and is disarmed the moment claude is reaped, so no sleeper outlives its claude and fires at a recycled pid. A mistyped value warns and falls back to the default rather than to no watchdog. Scenario N in TEST.md covers the kill, the SIGKILL escalation, the disabled and healthy paths, and the bad-value fallback.
…ing line (ISSUE-033, ISSUE-031) A task worktree is a checkout of tracked files only, so a gitignored spec directory a todo line points at is simply absent there. A session claiming `ISSUE-031` opened no spec, fell back to the one-line title, and reported done — which is how ISSUE-032 shipped without the `--help` Environment: block its own acceptance criteria required, with none of those criteria ticked. Nothing in the zero prompt was at fault; step 2.c already says the criteria define done. New CLAUDEZERO_LINK, comma-separated top-level names, unset by default, symlinks each named entry from the repo root into every fresh task worktree. Linked, not copied, so a tick lands in the real file instead of in a copy the worktree removal deletes. Each name is added to info/exclude, which a .gitignore rule cannot cover: a `name/` pattern matches a DIRECTORY, and git records a symlink as a blob, so without the exclude the link shows as untracked and rides along in the session's `git add -A` — the exact publishing the variable exists to avoid. The whole list is validated once at startup and refuses the run, naming the offending entry, if one is empty, contains a `/`, or is missing at the root. Skipping a bad entry per claim would be silent: sessions would keep launching, each unable to read its spec, each reporting done. `link_ignored` therefore carries no validation, only the occupied-name guard — `[ ! -e ] && [ ! -L ]`, both halves needed because -e follows a link, so a dangling one reads as absent and the ln -s dies "File exists". The plain two-argument POSIX ln -s form is used throughout; -r/-f/-n diverge between BSD and GNU. smoke.sh grows a case for the form and the guard pair, including the dangling case. The waiting line is no longer paced by the claimable probe, which made it read as a hung terminal and made a piped run four times longer than it needs to be. On a terminal the spinner steps once a second (WAIT_FRAME) with the elapsed clock floored to 5-second steps (WAIT_STEP); on a log or a pipe one plain line every 20 seconds (LOG_TICK). All three are named constants beside WAIT_TICK, so the probe interval can move without silently moving the display with it. Tests: new TEST.md Scenario O covers the link, write-through, an empty git status and `git add -A`, the unset and unlisted cases, a dangling link, a repeat claim, the untouched merge gate, and the startup refusals. Scenario M grows M5, which measures the frame rate and clock step under a pty; M1 moves to a 45s window for the 20s log spacing. Two harness bugs fixed along the way: Scenario I's I4 fault injection seds the acquire_task line this change rewrites, and would have become a silent no-op; and M1's `no escapes` check used GNU-only `\|` alternation with an unbalanced bracket, so it has never run on the macOS leg. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDEZERO_LINK is the only way a session ever sees the spec files its todo lines point at, but it lived in the README alone — a user who runs `-h` had no way to learn it exists short of reading the source. `usage()` grows an `Environment:` block naming the variable, its comma-separated form, that it is unset by default, and why a worktree needs the link at all: a worktree checks out tracked files only, so a gitignored spec directory is simply absent there. The block is plain text under the `-t and -l` line, untouched by the heredoc's sed constant injection. Tests: TEST.md Scenario O grows O7, which greps the rendered `-h` output for the block, the variable's comma form, and the default line. Scenario O and O6 were re-run against the edited script and still pass. todo.md ticks ISSUE-033: the implementation landed in b9baecf, and this change closes the last open criterion apart from the Linux CI leg, which needs the branch pushed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…SSUE-031) Implementation landed in ae0aa3d/b9baecf; the box was never ticked. Verified: scenario M (M1-M5) all pass, shellcheck + bash 3.2 parse clean, absence greps for /loop and LOOP_INTERVAL return nothing, README bullet present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…--help (ISSUE-032) The watchdog shipped as a flat wall-clock cap, which kills the honest long runs the loop exists to leave unattended. Sample claude's own cumulative CPU time instead and restart the window on every advance: a claude that thinks, streams or runs tools burns CPU; one blocked on a dead socket burns none. The kill line now says "no progress from claude for N" rather than "ran N without exiting". Also document CLAUDEZERO_WATCHDOG in the --help Environment block, which listed only CLAUDEZERO_LINK, and add TEST.md N4: a stub burning CPU for 3x the window finishes on its own with no watchdog line.
If the earlier SIGTERM already reaped claude, the pid can be recycled by the OS during the WATCHDOG_GRACE sleep; a blind kill -KILL then lands on whatever unrelated process holds that number next. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
REPO now resolves via `git worktree list` instead of $(pwd), so a cwd drift into a task worktree of this project can no longer point $SCRIPT at the real shared .git. Added in_testroot() as the per-invocation check that a scenario's own cd actually landed under $TESTROOT. Cleanup no longer pkills by bare 'claudezero.sh'/'claude .*--settings' (matches any such process on the machine, including a real dogfood loop or the live agent itself) — scoped to $TESTROOT instead, which every test-spawned claude's --settings hook path embeds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d_owner Both walked up to 8 parent hops matching bare `ps -o comm=` == claude with no check that the match was this session's own — any live claude sitting in the ancestry for an unrelated reason (nested Task agents, a claude session dogfood-testing this tool) got SIGTERMed instead. Claude Code already exports CLAUDE_PID (its own pid) into every Bash-tool/hook child; try that first (verified alive + still named claude), fall back to the walk only when it's absent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e test runner term_owner()'s ancestor walk finds the nearest claude-comm'd process within 8 PPID hops. G's stub piped straight into the real compact-exit-hook.sh with no protection, so an agent running TEST.md autonomously (a live claude ancestor within that hop count) got its own session SIGTERMed instead of the stub. Adds a shared `guard` helper (Section 0): a real bash binary copied to a file named claude, invoked with CLAUDE_PID unset and the payload backgrounded (`& wait $!`) so bash's tail-command exec optimization can't replace the decoy's own comm before the kill lands. Applied to G's grun(). Cross-referenced from I, M4, and O, which already carry the same technique inline for unrelated reasons (ensure_owner needing a findable ancestor, not a kill to avoid).
grun()'s escaped \$PATH stayed literal through guard()'s nested decoy shell, breaking every G scenario run with "timeout: command not found"; also polls for all three per-loop reports since the decoy dies (SIGTERM'd by term_owner) after loop 1, before claudezero.sh's remaining loops finish. Intro scenario list now covers S and F..O (previously only A-E, G). The two same-lettered "Scenario G" sections are split into G1 (token accounting) and G2 (fd 4 descriptor), with their internal sub-tests renumbered G1.1/G1.2 and G2.1/G2.2 to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
IvanRublev
added a commit
that referenced
this pull request
Aug 3, 2026
* Move free task waiting loop to the shell script from claude session to save tokens on empty runs * Add watchdog feature to terminate hanged claude process that doesn't consume CPU in 15 min | Scenario | Verdict | Notes | |---|---|---| | S — static checks | PASS | shellcheck + bash 3.2 syntax (S1/S2/S3a/S3b) | | A — parallel zero + restart-resume + timing | PASS | 5/5 markers, 3 distinct agents, restarts 2/2/2 | | B — startup-guard refusals | PASS | B1–B5 | | C — merge-conflict path | PASS | base clean, 1 loser branch kept | | D — foreign check-off + self-heal | PASS | phantom check = none | | E — timing accounting | PASS | E1, E2 | | F — fenced-checkbox immunity | PASS | F1, F2 | | G1 — token accounting | **PASS** | fixed: `grun()` PATH escaping + early-return race (was FAIL) | | G2 — claude's output descriptor (fd 4) | PASS | G2.1, G2.2 automated; G2.3 manual/skipped (needs real claude + tty) | | H — claude session display name | PASS | H1–H5 | | I — `zero.sh claim` exit paths | PASS | I1–I7 | | J — fleet TOTAL on exit path | PASS | J1, J2 | | K — SIGTERM while claude hangs | PASS | K1, K2 | | L — exit code + `--debug-file` | PASS | L1, L2 | | M — one task per session | PASS | M1–M5 | | N — `CLAUDEZERO_WATCHDOG` | PASS | N1–N4 | | O — `CLAUDEZERO_LINK` | PASS | O1–O7 | **17/17 scenarios PASS**. Cleanup verified: 0 stray files, 0 stale worktrees/branches, `git status` clean. --------- Co-authored-by: Ivan Rublev <git@ivanrublev.me> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
What changed
Tests run
grun()PATH escaping + early-return race (was FAIL)zero.sh claimexit paths--debug-fileCLAUDEZERO_WATCHDOGCLAUDEZERO_LINK17/17 scenarios PASS. Cleanup verified: 0 stray files, 0 stale worktrees/branches,
git statusclean.