From 273f31f6bdbe9b840ed375cfc3adc12758a14bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 17:14:37 +0000 Subject: [PATCH] ledger_merge: --base never reads stdin (fixes the web-session hang) Same defect as the Mind's copy, fixed the same day: `classify --base origin/main` blocked forever in a Claude Code web/mobile container because the source order tried stdin before `--base`, and the harness stdin is a socket that never closes. Precedence is now explicit paths, then `--base`, then stdin. Stdin is read only when it is the sole source left. Two regression tests hold a stdin pipe open and never write to it, and assert that `--base` and explicit-path runs still return; a third pins the precedence. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Amh3Vn7CQGTT2XmopbZFbW --- REFERENCE.md | 5 ++++- scripts/ledger_merge.py | 20 ++++++++++++++++---- tests/test_ledger_merge.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 8e2911c..62ed857 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -149,7 +149,10 @@ deleted. Anything else waits for a human: `scripts/`, `tests/`, `.github/`, on the branch tip and again on the trial-merge tree, so two branches that each edited the same ledger are caught before either lands. `python3 scripts/ledger_merge.py classify --base origin/main` predicts the verdict -(exit 0 ledger · 1 code · 2 could not classify). +(exit 0 ledger · 1 code · 2 could not classify). `--base` reads the diff from git and +never stdin, so it is safe from a web/mobile session whose stdin is a harness +socket that never closes; piped paths are read only when neither explicit +paths nor `--base` is given. ## Check-in diff --git a/scripts/ledger_merge.py b/scripts/ledger_merge.py index d63a878..2605fed 100755 --- a/scripts/ledger_merge.py +++ b/scripts/ledger_merge.py @@ -33,6 +33,10 @@ python3 scripts/ledger_merge.py classify path/one path/two # explicit paths ... < paths-on-stdin +Sources take precedence in that order: explicit paths, then `--base`, then +stdin. Stdin is read only when neither of the others is given, so a `--base` +run never blocks on a stdin that stays open (a harness socket). + Exit codes: 0 = ledger-only (safe to auto-merge) · 1 = holds code (a human's call) · 2 = the script could not run. The caller must distinguish 1 from 2: "a human should look" and "the gate is broken" are not the same answer. @@ -187,19 +191,27 @@ def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) sub = parser.add_subparsers(dest="command", required=True) cls = sub.add_parser("classify", help="ledger-only, or does it hold code?") - cls.add_argument("paths", nargs="*", help="repo-relative paths (else stdin, else --base)") + cls.add_argument("paths", nargs="*", help="repo-relative paths (else --base, else stdin)") cls.add_argument("--base", help="diff HEAD against the merge base with this ref") cls.add_argument("--head", default="HEAD", help="the branch tip to judge (default HEAD)") args = parser.parse_args(argv) + # Source precedence: explicit paths, then --base, then stdin. `--base` must + # never read stdin: a Claude Code web/mobile session runs commands with a + # stdin that is not a TTY and never closes (a harness socket, not a pipe), + # so an `isatty()` test that falls through to `sys.stdin.read()` blocks + # forever, and `classify --base origin/main` hung there twice before this + # order was fixed (2026-09-17). Stdin is read only when it is the sole + # source left — the documented `... < paths` usage — where a caller that + # pipes nothing has asked for exactly the wait it gets, as with `cat`. if args.paths: paths = args.paths - elif not sys.stdin.isatty(): - paths = sys.stdin.read().splitlines() elif args.base: paths = [] + elif not sys.stdin.isatty(): + paths = sys.stdin.read().splitlines() else: - parser.error("give paths, pipe them in, or pass --base") + parser.error("give paths, pass --base, or pipe paths in") return 2 # Strip blanks BEFORE the emptiness check, not inside classify(): a stdin # of "\n" is one empty string, which is a truthy list, and an unfiltered diff --git a/tests/test_ledger_merge.py b/tests/test_ledger_merge.py index 3ab8ed1..1f056a1 100644 --- a/tests/test_ledger_merge.py +++ b/tests/test_ledger_merge.py @@ -214,6 +214,39 @@ def test_an_empty_diff_is_not_permission_to_merge(): assert r.returncode == 1 +def test_base_and_explicit_paths_never_wait_on_an_open_stdin(): + """A Claude Code web/mobile session runs commands with a stdin that is not + a TTY and never closes (a harness socket). `classify --base origin/main` + hung there twice on 2026-09-17, because the old source order tried stdin + before `--base`. With paths or `--base` given, stdin must not be touched: + hold the pipe open and never write to it, and the run must still return. + """ + for args in (["--base", "HEAD"], ["active.md" if "_cli" == "_run" else "projects/a.md"]): + proc = subprocess.Popen( + [sys.executable, str(SCRIPT), "classify", *args], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + try: + rc = proc.wait(timeout=30) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + raise AssertionError(f"classify {' '.join(args)} blocked on an open stdin") + finally: + proc.stdin.close() + out = proc.stdout.read() + # `--base HEAD` diffs HEAD against itself: an empty diff, which blocks + # (exit 1) — the point is that it answered, not what it answered. + assert rc == (1 if args[0] == "--base" else 0), out + + +def test_base_takes_precedence_over_piped_paths(): + """Piped paths are read only when nothing else names the source.""" + result = _cli("--base", "HEAD", stdin="README.md\n") + assert result.returncode == 1 + assert "nothing to merge" in result.stdout + + def test_every_fixture_path_is_classified(): for p in (REPO / "tests" / "fixtures").rglob("*"): if p.is_file():