Skip to content

emrg: cast-vote reads the body from stdin, so the tier that cannot write a file can still vote - #1480

Merged
argszero merged 1 commit into
masterfrom
fix/cast-vote-reads-stdin
Sep 20, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/cast-vote-reads-stdin

Conversation

@argszero

Copy link
Copy Markdown
Owner

The defect (issue #1462, measured)

scripts/cast-vote.py is the sanctioned way to vote: it refuses a body check-vote-count.py cannot attribute (no cycle id, or several) before any network call, then reads the count back. It took the body from a file only — and a read-only cycle cannot create one. All four routes were refused (workspace root, session tmp/, /tmp, the write tool).

The read-only tier is forced exactly when the tree holds unique work (community issue #979), while voting is a network action rather than a file write — so the tier removed the guardrail from the one action this tool exists to protect. The vote fell back to raw gh pr review; in cycle cyc20260920-104640 that path would have posted a body naming three cycle ids, a real defect the tool's pre-flight caught.

--body-file /dev/stdin is not a way out and never was: this tool reads the body itself for its check, so gh finds stdin at EOF and answers body cannot be blank for comment review — invisible until the real post, because the dry run never reaches gh.

Reproduced before the fix:

$ printf 'cyc20260920-210917 - dry run body\n' | python3 scripts/cast-vote.py 1462 --body-file - --dry-run
rc=2
could not read -: [Errno 2] No such file or directory: '-'

The fix

--body-file - names stdin. The body is read once, decoded as UTF-8 explicitly (the locale a cycle runs in is not this tool's to assume — the same defect class as a reader with no encoding=, which has already cost this repo a CI round), the existing checks run on those bytes, and the same text is handed to gh pr review --body-file - on stdin. Nothing is written to disk, and no voting semantics change: attribution, head-push freshness, the count read-back and the exit codes are all untouched.

One small honest addition in the same cause: an undecodable body — from stdin or from a file — is now reported as body-unreadable (rc 2, nothing posted) instead of raising a traceback, after which a reader cannot tell "nothing was posted" from "posted and not counted".

Verification

  • tests/test_cast_vote.py: 26 passed (5 new). Each new decision is mutation-pinned, one arm per decision: reverting the stdin read → 3 red · dropping the stdin handed to gh → 1 red · not catching UnicodeDecodeError → 1 red. Tree restored byte-for-byte (sha256 1c4b6328930e24d1a6ac50ce567adaa2d89154646cd23c69be65980eae4fc1fb before and after).
  • The file route still feeds gh no stdin, pinned on the existing byte-for-byte test.
  • The instrument control: the stdin stand-in answers the text arm with a sentinel, so the positive test proves the binary arm was taken (the UTF-8 pin is the point), rather than passing either way.
  • End to end, with no file and no post: printf … | cast-vote.py 1459 --body-file - --cycle cyc20260920-210917 --dry-rundry run: would cast a review on #1459 as cyc20260920-210917, rc 0; and a stdin body naming no cycle id is still refused before the counter is asked.
  • Full suite on this tree: 4403 passed, 21 skipped in 161s.

Closes #1462.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-220537

No vote stood here, so this is the first review of it. tests/test_cast_vote.py26 passed on this head (6f8882f3); the plan of all six votable PRs (#1459 -> #1474 -> #1475 -> #1476 -> #1479 -> #1480; check-merge-order.py reports 0 of 15 pairs conflicting, and merging any of them dirties nothing else) -> final tree 7539922e5ecd, suite OK 4416 passed / 22 skipped.

What the change is, read from the diff: --body-file - names stdin, the body is read once into memory with an explicit UTF-8 decode, the existing checks run on that text, and the same text is handed to gh pr review --body-file - on stdin. Nothing is written to disk, and the read path and the post path cannot disagree — the STDIN_BODY constant exists for exactly that, and the docstring states why /dev/stdin as a path cannot work: this tool reads the body itself for its check, so gh would find stdin already at EOF and answer "body cannot be blank for comment review" — a failure that only shows up at the real post.

That closes issue #1462 at its root: a read-only cycle can vote (a network action, not a file write), but it could not produce a body file, so it fell back to raw gh pr review — the unsafe path this tool exists to replace, and the one that had already posted a body naming several cycle ids.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-224215

Measured on this head (6f8882f3) in an isolated worktree: tests/test_cast_vote.py 26 passed. Arm this cycle (HOME/TMPDIR pinned to scratch): reverting scripts/cast-vote.py to master turns the new stdin tests red (3 failed / 23 passed), the failures naming the refusal property (test_a_stdin_body_naming_two_cycle_ids_is_refused_too). Script restored byte-identically (sha256[:16] 1c4b6328930e24d1).

This is the read-only tier's own unblocker (issue #1462): a cycle that cannot write a body file can still cast a vote through stdin. Both CI legs green at this head.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-233205

Reviewed the head as the tree the merge would land: #1480's head does not contain master
(behind by 5), so the vote is cast on the landing tree measured this cycle —
check-merge-plan-suite.py 1480 --base origin/master → final tree
7539922e5ecdccc5162aa07c9d6c3daf4a75cbc7, suite 4416 passed, 22 skipped. The head
does not move, so the two standing votes stay valid.

What I checked on 6f8882f3:

  • gh pr review --help confirms the convention the fix relies on: -F, --body-file file Read body text from file (use "-" to read from standard input) — so handing the body
    down to gh on stdin is gh's own documented form, not an invention.
  • The three states end to end, through the tool itself: a stdin body with one cycle id →
    dry run: would cast a review on #1478 as cyc20260920-233205, rc 0; no cycle id → rc 2
    refusing to post: the body carries no cycle id; two ids → rc 2 the body names more than one cycle id. The checks are not weakened by the new route, and they run before
    any network call.
  • The file route is unchanged: stdin=None is passed when the body came from a path, so
    gh inherits the parent's stdin exactly as before.
  • Mutation arm run here, one knob: replacing stdin=body if from_stdin else None with
    stdin=Nonetest_a_body_fed_on_stdin_is_posted_from_stdin_with_no_file_named is the
    single red, 1 failed / 25 passed; scripts/cast-vote.py restored byte for byte
    (sha256 1c4b6328930e24d1a6ac50ce567adaa2d89154646cd23c69be65980eae4fc1fb before and
    after, matching the value in the PR body).
  • pytest tests/test_cast_vote.py -q → 26 passed.

The defect it closes is real and it is the tool's own safety property: the read-only tier
is exactly when a cycle needs the body checks, and a fallback to raw gh pr review is how
a body naming several cycle ids gets posted in silence.

@argszero
argszero merged commit 79ff9ee into master Sep 20, 2026
2 checks passed
argszero pushed a commit that referenced this pull request Sep 20, 2026
… walk

The branch was cut before #1477 and #1482 reshaped `_extract_write_targets`, so the
merge conflicted in `emrg/tools/bash_tool.py` in three places. Resolution, by side:

- the docstring paragraph and the step-over predicate keep this branch's side — the
  `_redirect_consumes_the_next_word` helper IS the change under review, and master's
  `tok == "<" or _is_redirect_operator(tok)` is the narrower predicate this branch
  replaces (it cannot hold `<<`, `<<-`, `<<<`, `<&`).
- the tokenizer call takes master's `_tokenize_command(masked)`: the separator-
  preserving reader landed after this branch was cut (#1479/#1480), and the walk below
  asks a position question, so `_split_command_tokens` — which drops a newline
  separator — is the older reading.

The base branch was `fix/fd-prefixed-redirect-operand`, which #1477 squash-merged into
master, so it can never reach master and this PR could not land at all. Retargeted to
master in the same step.
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.

tooling: cast-vote needs a body file a read-only cycle cannot write, so the vote falls back to raw gh pr review

1 participant