Skip to content

v0.0.16 - #4

Merged
IvanRublev merged 28 commits into
masterfrom
v0.0.16
Aug 3, 2026
Merged

v0.0.16#4
IvanRublev merged 28 commits into
masterfrom
v0.0.16

Conversation

@IvanRublev

Copy link
Copy Markdown
Owner

What changed

  • 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

Tests run

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.

Ivan Rublev and others added 28 commits August 2, 2026 16:20
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>
…D fixes

Both claudezero.sh fixes (0f7a54b, daf1d55) landed in this 0.0.16 cycle
without a changelog entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@IvanRublev
IvanRublev merged commit 66ed878 into master Aug 3, 2026
3 checks passed
@IvanRublev
IvanRublev deleted the v0.0.16 branch August 3, 2026 12:47
@IvanRublev IvanRublev changed the title V0.0.16 v0.0.16 Aug 3, 2026
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>
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