From 25622aa748987ff12adce10ebb38c1eadc59c7c9 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:58:40 +0800 Subject: [PATCH 01/18] make-pr: preflight runs the PR-body validator on --body-file description_check only read the description's prose for claims about the repo's past, so preflight printed "ok preflight passed" for a body the required PR Body check then rejected after publication (#780-#789, #793, #795). describe() now also runs engine/skills/draft-pr/scripts/validate-pr-body.mjs on the same file and prints its errors under the gate line. Exit 1 from the validator fails preflight. No node, a missing validator, a timeout, or any other exit code prints "description unchecked: ..." and fails too -- a check that could not run is not a pass. The #795 description lands as a fixture: it exits 1 with Test Plan and Revert Plan outside
and code names in the Summary, and the new tests show preflight failing on it and still passing a valid body. Co-Authored-By: Claude Opus 5 --- engine/skills/make-pr/scripts/preflight.py | 39 ++++- .../tests/fixtures/pr795-failing-body.md | 54 ++++++ .../make-pr/tests/test_description_check.py | 6 + engine/skills/make-pr/tests/test_preflight.py | 165 ++++++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 engine/skills/make-pr/tests/fixtures/pr795-failing-body.md diff --git a/engine/skills/make-pr/scripts/preflight.py b/engine/skills/make-pr/scripts/preflight.py index 36024a5e3..e3c237048 100644 --- a/engine/skills/make-pr/scripts/preflight.py +++ b/engine/skills/make-pr/scripts/preflight.py @@ -39,6 +39,9 @@ DEFAULT_CONFIG = os.path.join(REPO_ROOT, "drafter.config.json") UNCHECKED_EXIT = 3 +VALIDATOR = "engine/skills/draft-pr/scripts/validate-pr-body.mjs" +VALIDATOR_NAME = os.path.basename(VALIDATOR) +VALIDATOR_TIMEOUT = 120 class UnitRulesUnreadable(Exception): @@ -222,6 +225,34 @@ def changed_paths(base: str, repo: str = REPO_ROOT) -> list[str]: return sorted({p for p in (out + untracked).splitlines() if p.strip()}) +def validate_body(body_file: str, run=None, which=None) -> tuple[int, list[str]]: + """Run the PR-body schema validator that the required PR Body check runs. + + description_check only reads the prose for history claims, so a body whose + Test Plan is not inside a
block passed preflight and then failed + the required check after publication (PRs #780-#789, #793, #795). Exit 1 + from the validator is a real rejection; anything else -- no node, a + timeout, an unexpected exit code -- is unchecked, which also fails. + """ + run = run if run is not None else subprocess.run + which = which if which is not None else shutil.which + if not which("node"): + return 1, [f"description unchecked: node is not on PATH, so {VALIDATOR_NAME} did not run"] + if not os.path.isfile(os.path.join(REPO_ROOT, VALIDATOR)): + return 1, [f"description unchecked: no {VALIDATOR} under {REPO_ROOT}"] + cmd = ["node", VALIDATOR, "--body-file", os.path.abspath(body_file)] + try: + res = run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=VALIDATOR_TIMEOUT) + except subprocess.TimeoutExpired: + return 1, [f"description unchecked: {VALIDATOR_NAME} did not finish in {VALIDATOR_TIMEOUT}s"] + except OSError as exc: + return 1, [f"description unchecked: cannot run {VALIDATOR_NAME}: {exc}"] + lines = (res.stdout + res.stderr).strip().splitlines() + if res.returncode in (0, 1): + return res.returncode, lines + return 1, lines + [f"description unchecked: {VALIDATOR_NAME} exited {res.returncode}"] + + def describe(body_file: str | None) -> int: if not body_file: print("fail description unchecked: pass --body-file with the PR description") @@ -239,7 +270,13 @@ def describe(body_file: str | None) -> int: for line in lines: print(" " + line) print(f" description {outcome}") - return 0 if outcome == "clean" else 1 + status = 0 if outcome == "clean" else 1 + + print(f"gate node {VALIDATOR} --body-file " + body_file) + schema_status, schema_lines = validate_body(body_file) + for line in schema_lines: + print(" " + line) + return status or schema_status def main(argv: list[str] | None = None) -> int: diff --git a/engine/skills/make-pr/tests/fixtures/pr795-failing-body.md b/engine/skills/make-pr/tests/fixtures/pr795-failing-body.md new file mode 100644 index 000000000..f97a5d9ec --- /dev/null +++ b/engine/skills/make-pr/tests/fixtures/pr795-failing-body.md @@ -0,0 +1,54 @@ +## Summary + +We run the same tools on seven machines. Keeping them all on the same version used to mean updating each one by hand. + +Now `scripts/update_fleet.sh` does it. It reads `remoteTargets` from `~/.invoker/config.json`, updates every machine, then prints one line per machine saying whether it worked. + +A machine it could not reach shows as failed, never as fine. A `--dry-run` changes nothing. + +## Review Claim + +The update script reports every machine it was asked about, and a machine it could not check shows as failed, not as fine. + +## Review Lane + +behavior + +## Review Unit + +corpus-lesson + +## Safety Invariant + +The script changes nothing on a `--dry-run`, and replacing the live app on that machine happens only behind an explicit flag. Release assets are checksum-verified before anything is installed. + +## Slice Rationale + +One claim, one review unit: the cat-mode skill package plus its colocated tests. The two mined rules are prose in the same skill; they ship no code and cannot be reviewed apart from the file they live in. + +## Non-goals + +- No Invoker-side change: the script consumes published release assets as they are. +- No scheduler or worker. Running it is still a person's decision. + +## Test Plan + +``` +$ python3 -m unittest tests.test_cat_mode +Ran 92 tests in 0.113s +OK +``` + +Gates: + +``` +$ python3 scripts/ci/check_skill_file_refs.py -> exit=0 ok skill file refs +$ python3 scripts/ci/check_codify_has_code.py -> exit=0 ok codify-has-code +``` + +## Revert Plan + +- Safe to revert? Yes +- Revert command: `git revert ` +- Post-revert steps: None. No installed hook, skill, or machine reads the script. +- Data migration? No diff --git a/engine/skills/make-pr/tests/test_description_check.py b/engine/skills/make-pr/tests/test_description_check.py index c0bbc8aec..60286071e 100644 --- a/engine/skills/make-pr/tests/test_description_check.py +++ b/engine/skills/make-pr/tests/test_description_check.py @@ -95,8 +95,13 @@ def test_unreadable_body_file_fails_as_unchecked(self): self.assertIn("cannot read", out.getvalue()) def test_clean_body_file_passes(self): + """BODY is a stub, not a schema-valid description, so describe()'s other + step -- the PR-body schema validator -- stands in as passing here. Its + own cases live in test_preflight.TestDescriptionSchemaValidator.""" original = description_check.check + original_validate = preflight.validate_body description_check.check = lambda body: ("clean", []) + preflight.validate_body = lambda body_file: (0, ["PR body validation passed."]) try: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as handle: handle.write(BODY) @@ -105,6 +110,7 @@ def test_clean_body_file_passes(self): status = preflight.describe(handle.name) finally: description_check.check = original + preflight.validate_body = original_validate os.unlink(handle.name) self.assertEqual(status, 0) self.assertIn("description clean", out.getvalue()) diff --git a/engine/skills/make-pr/tests/test_preflight.py b/engine/skills/make-pr/tests/test_preflight.py index 22fa2d144..7744a9c0f 100644 --- a/engine/skills/make-pr/tests/test_preflight.py +++ b/engine/skills/make-pr/tests/test_preflight.py @@ -3,12 +3,15 @@ sets of PRs in this repo (e.g. #89 visual-proof, the 2026-09-01 hook slices).""" from __future__ import annotations +import contextlib +import io import json import os import shutil import subprocess import sys import tempfile +import types import unittest HERE = os.path.dirname(os.path.abspath(__file__)) @@ -16,6 +19,7 @@ import preflight as pf # noqa: E402 SCRIPT = os.path.join(os.path.dirname(HERE), "scripts", "preflight.py") +PR795_BODY = os.path.join(HERE, "fixtures", "pr795-failing-body.md") # real: PR #89 "Require actual captures for visual proof" PR89 = ["product/skills/visual-proof/SKILL.md", "product/skills/visual-proof/tests/fires_example.md"] @@ -295,5 +299,166 @@ def test_no_paths_exits_2(self): self.assertEqual(res.returncode, 2) +VALID_BODY = """## Summary + +We run the same tools on seven machines. Keeping them all on the same version used to mean updating each one by hand. + +Now one script does it. It updates every machine, then prints one line per machine saying whether it worked. + +A machine it could not reach shows as failed, never as fine. A practice run changes nothing. + +## Review Claim + +The update script reports every machine it was asked about, and a machine it could not check shows as failed, not as fine. + +## Review Lane + +behavior + +## Review Unit + +corpus-lesson + +## Safety Invariant + +The script changes nothing on a practice run, and replacing the live app on a machine happens only behind an explicit flag. + +## Slice Rationale + +One claim, one review unit: the cat-mode skill package plus its colocated tests. + +## Non-goals + +- No Invoker-side change: the script consumes published release assets as they are. + +## Test Plan + +
+Test Plan + +``` +$ python3 -m unittest tests.test_cat_mode +Ran 92 tests in 0.113s +OK +``` + +
+ +## Revert Plan + +
+Revert Plan + +- Safe to revert? Yes +- Revert command: `git revert ` +- Post-revert steps: None. +- Data migration? No + +
+""" + + +@contextlib.contextmanager +def clean_history_judge(): + """Stand in for description_check so these tests exercise the schema + validator alone. The real checker asks a background LLM judge.""" + stub = types.ModuleType("description_check") + stub.check = lambda body: ("clean", []) + real = sys.modules.get("description_check") + sys.modules["description_check"] = stub + try: + yield + finally: + if real is None: + sys.modules.pop("description_check", None) + else: + sys.modules["description_check"] = real + + +def run_preflight(body_file): + """main() on a neutral path, so the exit status is the description's.""" + real = pf.changed_paths + pf.changed_paths = lambda base, repo=pf.REPO_ROOT: ["docs/ecosystem.md"] + buf = io.StringIO() + try: + with clean_history_judge(), contextlib.redirect_stdout(buf): + status = pf.main(["--body-file", body_file]) + finally: + pf.changed_paths = real + return status, buf.getvalue() + + +class TestDescriptionSchemaValidator(unittest.TestCase): + """preflight's description step must run the same validator as the + required PR Body check. description_check only reads the prose for claims + about the repo's past, so a body whose Test Plan sits outside a
+ block got `ok preflight passed` and was then rejected after publication.""" + + def test_the_pr795_body_is_rejected_with_the_validators_own_errors(self): + status, lines = pf.validate_body(PR795_BODY) + report = "\n".join(lines) + self.assertEqual(status, 1, report) + self.assertIn("## Test Plan must wrap its content in a collapsed
block", report) + self.assertIn("## Revert Plan must wrap its content in a collapsed
block", report) + self.assertIn("must not use code names", report) + + def test_preflight_fails_on_the_pr795_body_and_prints_the_errors(self): + status, out = run_preflight(PR795_BODY) + self.assertEqual(status, 1, out) + self.assertIn("## Test Plan must wrap its content in a collapsed
block", out) + self.assertNotIn("ok preflight passed", out) + self.assertIn("fail preflight", out) + + def test_a_body_the_validator_accepts_still_passes(self): + with tempfile.TemporaryDirectory() as tmp: + body_file = os.path.join(tmp, "body.md") + with open(body_file, "w", encoding="utf-8") as handle: + handle.write(VALID_BODY) + status, out = run_preflight(body_file) + self.assertEqual(status, 0, out) + self.assertIn("PR body validation passed.", out) + self.assertIn("ok preflight passed", out) + + def test_a_missing_node_is_unchecked_not_a_pass(self): + status, lines = pf.validate_body(PR795_BODY, which=lambda name: None) + self.assertEqual(status, 1, lines) + self.assertIn("description unchecked", "\n".join(lines)) + self.assertIn("node is not on PATH", "\n".join(lines)) + + def test_a_validator_that_is_not_in_the_checkout_is_unchecked_not_a_pass(self): + real = pf.VALIDATOR + pf.VALIDATOR = "engine/skills/draft-pr/scripts/no-such-validator.mjs" + try: + status, lines = pf.validate_body(PR795_BODY) + finally: + pf.VALIDATOR = real + self.assertEqual(status, 1, lines) + self.assertIn("description unchecked", "\n".join(lines)) + + def test_a_timeout_is_unchecked_not_a_pass(self): + def times_out(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd="node", timeout=pf.VALIDATOR_TIMEOUT) + + status, lines = pf.validate_body(PR795_BODY, run=times_out, which=lambda name: "/usr/bin/node") + self.assertEqual(status, 1, lines) + self.assertIn("description unchecked", "\n".join(lines)) + + def test_an_unexpected_exit_code_is_unchecked_not_a_pass(self): + def crashes(*args, **kwargs): + return subprocess.CompletedProcess(args, 7, stdout="", stderr="node: bad option") + + status, lines = pf.validate_body(PR795_BODY, run=crashes, which=lambda name: "/usr/bin/node") + self.assertEqual(status, 1, lines) + self.assertIn("exited 7", "\n".join(lines)) + + def test_a_node_that_cannot_be_spawned_is_unchecked_not_a_pass(self): + def refuses(*args, **kwargs): + raise OSError("Exec format error") + + status, lines = pf.validate_body(PR795_BODY, run=refuses, which=lambda name: "/usr/bin/node") + self.assertEqual(status, 1, lines) + self.assertIn("description unchecked", "\n".join(lines)) + + if __name__ == "__main__": unittest.main() From aeb2d1c931330fb5ec1b2bf1579e69e6a6d964af Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:58:53 +0800 Subject: [PATCH 02/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/implement-p?= =?UTF-8?q?reflight-runs-validator=20=E2=80=94=20Review=20claim:=20make-pr?= =?UTF-8?q?=20preflight=20runs=20the=20PR-description=20validator=20on=20-?= =?UTF-8?q?-body-file=20and=20fails=20when=20it=20fails,=20so=20it=20no=20?= =?UTF-8?q?longer=20prints=20'preflight=20passed'=20for=20a=20description?= =?UTF-8?q?=20the=20required=20PR=20Body=20check=20rejects.=20Review=20lan?= =?UTF-8?q?e:=20policy=20Safety=20invariant:=20The=20change=20can=20only?= =?UTF-8?q?=20add=20a=20failure=20or=20an=20UNCHECKED=20notice;=20a=20PR?= =?UTF-8?q?=20description=20that=20passes=20engine/skills/draft-pr/scripts?= =?UTF-8?q?/validate-pr-body.mjs=20today=20is=20never=20newly=20blocked.?= =?UTF-8?q?=20Effectiveness=20measurement:=20A=20preflight=20test=20feeds?= =?UTF-8?q?=20the=20original=20#795=20description=20(Test=20Plan=20and=20R?= =?UTF-8?q?evert=20Plan=20not=20inside=20
,=20code=20names=20in=20?= =?UTF-8?q?Summary),=20which=20the=20validator=20rejects=20with=20exit=201?= =?UTF-8?q?,=20and=20asserts=20preflight=20exits=20non-zero=20and=20prints?= =?UTF-8?q?=20the=20validator's=20errors;=20a=20passing=20description=20st?= =?UTF-8?q?ill=20gives=20'ok=20preflight=20passed';=20a=20validator=20that?= =?UTF-8?q?=20cannot=20run=20gives=20an=20unchecked=20failure,=20not=20a?= =?UTF-8?q?=20pass.=20Slice=20rationale:=20One=20script,=20one=20claim:=20?= =?UTF-8?q?preflight's=20description=20step=20also=20runs=20the=20schema?= =?UTF-8?q?=20validator.=20Architectural=20effect:=20preflight's=20describ?= =?UTF-8?q?e()=20step=20covers=20the=20same=20rules=20as=20the=20required?= =?UTF-8?q?=20PR=20Body=20check.=20Goal:=20Stop=20preflight=20from=20appro?= =?UTF-8?q?ving=20a=20PR=20description=20the=20required=20check=20will=20r?= =?UTF-8?q?eject.=20Motivation:=20A=20reflect=20pass=20found=20PR=20descri?= =?UTF-8?q?ptions=20on=20catstack=20PRs=20#780-#789,=20#793=20and=20#795?= =?UTF-8?q?=20failing=20the=20required=20PR=20Body=20check=20after=20publi?= =?UTF-8?q?cation,=20and=20the=20user=20had=20to=20ask=20for=20a=20manual?= =?UTF-8?q?=20fix=20of=20every=20PR.=20In=20catstack=20the=20guard=20staye?= =?UTF-8?q?d=20silent:=20fed=20`mergify=20stack=20push`=20and=20`gh=20pr?= =?UTF-8?q?=20create=20--body-file=20`=20it=20exited=200?= =?UTF-8?q?=20with=20no=20output,=20while=20the=20same=20stack=20push=20in?= =?UTF-8?q?=20the=20Invoker=20checkout=20fired.=20Read=20at=20origin/main:?= =?UTF-8?q?=20engine/skills/make-pr/scripts/preflight.py=20describe()=20on?= =?UTF-8?q?ly=20runs=20description=5Fcheck,=20then=20main()=20prints=20'ok?= =?UTF-8?q?=20preflight=20passed';=20the=20session=20that=20opened=20#795?= =?UTF-8?q?=20got=20that=20line=20and=20then=20ran=20gh=20pr=20create.=20A?= =?UTF-8?q?lternative=20considerations:=20Leaving=20the=20validator=20to?= =?UTF-8?q?=20CI=20was=20set=20aside=20because=20the=20failure=20then=20sh?= =?UTF-8?q?ows=20up=20only=20after=20publication.=20Reimplementing=20the?= =?UTF-8?q?=20rules=20in=20Python=20was=20set=20aside=20because=20it=20wou?= =?UTF-8?q?ld=20drift=20from=20the=20validator.=20Implementation=20details?= =?UTF-8?q?:=20In=20engine/skills/make-pr/scripts/preflight.py=20describe(?= =?UTF-8?q?),=20after=20description=5Fcheck,=20run=20`node=20engine/skills?= =?UTF-8?q?/draft-pr/scripts/validate-pr-body.mjs=20--body-file=20`?= =?UTF-8?q?=20from=20the=20repo=20root=20with=20a=20timeout.=20Print=20its?= =?UTF-8?q?=20output=20lines=20indented=20like=20the=20other=20gates.=20Ex?= =?UTF-8?q?it=201=20from=20the=20validator=20fails=20preflight;=20a=20miss?= =?UTF-8?q?ing=20node=20binary,=20a=20timeout,=20or=20any=20other=20exit?= =?UTF-8?q?=20code=20prints=20'description=20unchecked:=20'=20and?= =?UTF-8?q?=20fails=20preflight.=20Add=20the=20tests=20to=20engine/skills/?= =?UTF-8?q?make-pr/tests/test=5Fpreflight.py=20with=20the=20#795=20body=20?= =?UTF-8?q?as=20a=20fixture=20file=20under=20engine/skills/make-pr/tests/f?= =?UTF-8?q?ixtures/.=20Non-goals:=20No=20change=20to=20the=20validator,=20?= =?UTF-8?q?the=20hook,=20or=20the=20make-pr=20SKILL.md=20rules.=20Do=20not?= =?UTF-8?q?=20edit=20engine/skills/draft-pr/scripts/validate-pr-body.mjs,?= =?UTF-8?q?=20scripts/pr/validate-pr-body-local.mjs,=20or=20any=20other=20?= =?UTF-8?q?file=20open=20PR=20#742=20changes;=20call=20the=20validator=20a?= =?UTF-8?q?s=20it=20is.=20If=20a=20change=20would=20overlap=20an=20open=20?= =?UTF-8?q?PR,=20stop=20and=20report=20instead.=20Layer:=20domain=20Featur?= =?UTF-8?q?e=20state:=20active=20Files:=20-=20engine/skills/make-pr/script?= =?UTF-8?q?s/preflight.py=20-=20engine/skills/make-pr/tests/test=5Fpreflig?= =?UTF-8?q?ht.py=20-=20engine/skills/make-pr/tests/fixtures/pr795-failing-?= =?UTF-8?q?body.md=20Change=20types:=20-=20engine/skills/make-pr/scripts/p?= =?UTF-8?q?reflight.py:=20modify=20-=20engine/skills/make-pr/tests/test=5F?= =?UTF-8?q?preflight.py:=20modify=20-=20engine/skills/make-pr/tests/fixtur?= =?UTF-8?q?es/pr795-failing-body.md:=20create=20Acceptance=20criteria:=20-?= =?UTF-8?q?=20`python3=20-m=20unittest=20discover=20-s=20engine/skills/mak?= =?UTF-8?q?e-pr/tests=20-v`=20exits=200.=20-=20`python3=20scripts/check=5F?= =?UTF-8?q?skill=5Ftest=5Fcoverage.py=20--base=20origin/main=20--head=20HE?= =?UTF-8?q?AD`=20exits=200.=20-=20`python3=20scripts/check=5Fskill=5Ffile?= =?UTF-8?q?=5Frefs.py`=20exits=200.=20-=20`python3=20scripts/check=5Fno=5F?= =?UTF-8?q?new=5Fcomments.py`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From e89618223402b2c1497635bfe6cd884299d7a7cc Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:59:12 +0800 Subject: [PATCH 03/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-1=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20-m=20unittest=20discover=20-s=20engine/skills/make-pr/tests?= =?UTF-8?q?=20-v`=20passes=20on=20the=20finished=20branch.=20Review=20lane?= =?UTF-8?q?:=20proof=20Safety=20invariant:=20Verification=20is=20read-only?= =?UTF-8?q?=20and=20alters=20no=20repository=20file.=20Effectiveness=20mea?= =?UTF-8?q?surement:=20The=20command's=20exit=20code=20is=20the=20direct?= =?UTF-8?q?=20measurement.=20Slice=20rationale:=20One=20check=20per=20proo?= =?UTF-8?q?f=20task.=20Architectural=20effect:=20None;=20verification=20on?= =?UTF-8?q?ly.=20Goal:=20Prove=20the=20slice.=20Motivation:=20Running=20th?= =?UTF-8?q?e=20check=20is=20the=20proof.=20Alternative=20considerations:?= =?UTF-8?q?=20The=20full=20suite=20was=20not=20required=20because=20the=20?= =?UTF-8?q?slice=20touches=20one=20component=20with=20its=20own=20tests.?= =?UTF-8?q?=20Implementation=20details:=20Run=20`python3=20-m=20unittest?= =?UTF-8?q?=20discover=20-s=20engine/skills/make-pr/tests=20-v`.=20Non-goa?= =?UTF-8?q?ls:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature=20s?= =?UTF-8?q?tate:=20active=20Layer=20exception:=20allowed.=20Verification?= =?UTF-8?q?=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20co?= =?UTF-8?q?mmit=20so=20they=20check=20the=20final=20branch=20the=20PR=20wi?= =?UTF-8?q?ll=20carry.=20Acceptance=20criteria:=20-=20The=20command=20exit?= =?UTF-8?q?s=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From fe1887b3effe7d00d47acbd00ae378a62893680f Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:59:13 +0800 Subject: [PATCH 04/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-4=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fno=5Fnew=5Fcomments.py`=20passes=20on=20the?= =?UTF-8?q?=20finished=20branch.=20Review=20lane:=20proof=20Safety=20invar?= =?UTF-8?q?iant:=20Verification=20is=20read-only=20and=20alters=20no=20rep?= =?UTF-8?q?ository=20file.=20Effectiveness=20measurement:=20The=20command'?= =?UTF-8?q?s=20exit=20code=20is=20the=20direct=20measurement.=20Slice=20ra?= =?UTF-8?q?tionale:=20One=20check=20per=20proof=20task.=20Architectural=20?= =?UTF-8?q?effect:=20None;=20verification=20only.=20Goal:=20Prove=20the=20?= =?UTF-8?q?slice.=20Motivation:=20Running=20the=20check=20is=20the=20proof?= =?UTF-8?q?.=20Alternative=20considerations:=20The=20full=20suite=20was=20?= =?UTF-8?q?not=20required=20because=20the=20slice=20touches=20one=20compon?= =?UTF-8?q?ent=20with=20its=20own=20tests.=20Implementation=20details:=20R?= =?UTF-8?q?un=20`python3=20scripts/check=5Fno=5Fnew=5Fcomments.py`.=20Non-?= =?UTF-8?q?goals:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature?= =?UTF-8?q?=20state:=20active=20Layer=20exception:=20allowed.=20Verificati?= =?UTF-8?q?on=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20?= =?UTF-8?q?commit=20so=20they=20check=20the=20final=20branch=20the=20PR=20?= =?UTF-8?q?will=20carry.=20Acceptance=20criteria:=20-=20The=20command=20ex?= =?UTF-8?q?its=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 1 From 394d32186d7f4efea35f3c7aae3abc98b7a4aef6 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:59:28 +0800 Subject: [PATCH 05/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-2=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fskill=5Ftest=5Fcoverage.py=20--base=20origin?= =?UTF-8?q?/main=20--head=20HEAD`=20passes=20on=20the=20finished=20branch.?= =?UTF-8?q?=20Review=20lane:=20proof=20Safety=20invariant:=20Verification?= =?UTF-8?q?=20is=20read-only=20and=20alters=20no=20repository=20file.=20Ef?= =?UTF-8?q?fectiveness=20measurement:=20The=20command's=20exit=20code=20is?= =?UTF-8?q?=20the=20direct=20measurement.=20Slice=20rationale:=20One=20che?= =?UTF-8?q?ck=20per=20proof=20task.=20Architectural=20effect:=20None;=20ve?= =?UTF-8?q?rification=20only.=20Goal:=20Prove=20the=20slice.=20Motivation:?= =?UTF-8?q?=20Running=20the=20check=20is=20the=20proof.=20Alternative=20co?= =?UTF-8?q?nsiderations:=20The=20full=20suite=20was=20not=20required=20bec?= =?UTF-8?q?ause=20the=20slice=20touches=20one=20component=20with=20its=20o?= =?UTF-8?q?wn=20tests.=20Implementation=20details:=20Run=20`python3=20scri?= =?UTF-8?q?pts/check=5Fskill=5Ftest=5Fcoverage.py=20--base=20origin/main?= =?UTF-8?q?=20--head=20HEAD`.=20Non-goals:=20No=20mutations.=20Layer:=20ap?= =?UTF-8?q?p=5Fregression=20Feature=20state:=20active=20Layer=20exception:?= =?UTF-8?q?=20allowed.=20Verification=20and=20the=20terminal=20scrub=20run?= =?UTF-8?q?=20after=20the=20docs=20commit=20so=20they=20check=20the=20fina?= =?UTF-8?q?l=20branch=20the=20PR=20will=20carry.=20Acceptance=20criteria:?= =?UTF-8?q?=20-=20The=20command=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From 5950111b5bcc17cdad2ddff472ed8ec0f8f5f494 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:59:31 +0800 Subject: [PATCH 06/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-3=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fskill=5Ffile=5Frefs.py`=20passes=20on=20the?= =?UTF-8?q?=20finished=20branch.=20Review=20lane:=20proof=20Safety=20invar?= =?UTF-8?q?iant:=20Verification=20is=20read-only=20and=20alters=20no=20rep?= =?UTF-8?q?ository=20file.=20Effectiveness=20measurement:=20The=20command'?= =?UTF-8?q?s=20exit=20code=20is=20the=20direct=20measurement.=20Slice=20ra?= =?UTF-8?q?tionale:=20One=20check=20per=20proof=20task.=20Architectural=20?= =?UTF-8?q?effect:=20None;=20verification=20only.=20Goal:=20Prove=20the=20?= =?UTF-8?q?slice.=20Motivation:=20Running=20the=20check=20is=20the=20proof?= =?UTF-8?q?.=20Alternative=20considerations:=20The=20full=20suite=20was=20?= =?UTF-8?q?not=20required=20because=20the=20slice=20touches=20one=20compon?= =?UTF-8?q?ent=20with=20its=20own=20tests.=20Implementation=20details:=20R?= =?UTF-8?q?un=20`python3=20scripts/check=5Fskill=5Ffile=5Frefs.py`.=20Non-?= =?UTF-8?q?goals:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature?= =?UTF-8?q?=20state:=20active=20Layer=20exception:=20allowed.=20Verificati?= =?UTF-8?q?on=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20?= =?UTF-8?q?commit=20so=20they=20check=20the=20final=20branch=20the=20PR=20?= =?UTF-8?q?will=20carry.=20Acceptance=20criteria:=20-=20The=20command=20ex?= =?UTF-8?q?its=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From e59f975f1c96f06b16439853b22beee3a5c974f0 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 00:59:40 +0800 Subject: [PATCH 07/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-4=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fno=5Fnew=5Fcomments.py`=20passes=20on=20the?= =?UTF-8?q?=20finished=20branch.=20Review=20lane:=20proof=20Safety=20invar?= =?UTF-8?q?iant:=20Verification=20is=20read-only=20and=20alters=20no=20rep?= =?UTF-8?q?ository=20file.=20Effectiveness=20measurement:=20The=20command'?= =?UTF-8?q?s=20exit=20code=20is=20the=20direct=20measurement.=20Slice=20ra?= =?UTF-8?q?tionale:=20One=20check=20per=20proof=20task.=20Architectural=20?= =?UTF-8?q?effect:=20None;=20verification=20only.=20Goal:=20Prove=20the=20?= =?UTF-8?q?slice.=20Motivation:=20Running=20the=20check=20is=20the=20proof?= =?UTF-8?q?.=20Alternative=20considerations:=20The=20full=20suite=20was=20?= =?UTF-8?q?not=20required=20because=20the=20slice=20touches=20one=20compon?= =?UTF-8?q?ent=20with=20its=20own=20tests.=20Implementation=20details:=20R?= =?UTF-8?q?un=20`python3=20scripts/check=5Fno=5Fnew=5Fcomments.py`.=20Non-?= =?UTF-8?q?goals:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature?= =?UTF-8?q?=20state:=20active=20Layer=20exception:=20allowed.=20Verificati?= =?UTF-8?q?on=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20?= =?UTF-8?q?commit=20so=20they=20check=20the=20final=20branch=20the=20PR=20?= =?UTF-8?q?will=20carry.=20Acceptance=20criteria:=20-=20The=20command=20ex?= =?UTF-8?q?its=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 1 From 0c52061d7f2ecd8a37fbac3e3baf0e393472e6a5 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 01:01:07 +0800 Subject: [PATCH 08/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-4=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fno=5Fnew=5Fcomments.py`=20passes=20on=20the?= =?UTF-8?q?=20finished=20branch.=20Review=20lane:=20proof=20Safety=20invar?= =?UTF-8?q?iant:=20Verification=20is=20read-only=20and=20alters=20no=20rep?= =?UTF-8?q?ository=20file.=20Effectiveness=20measurement:=20The=20command'?= =?UTF-8?q?s=20exit=20code=20is=20the=20direct=20measurement.=20Slice=20ra?= =?UTF-8?q?tionale:=20One=20check=20per=20proof=20task.=20Architectural=20?= =?UTF-8?q?effect:=20None;=20verification=20only.=20Goal:=20Prove=20the=20?= =?UTF-8?q?slice.=20Motivation:=20Running=20the=20check=20is=20the=20proof?= =?UTF-8?q?.=20Alternative=20considerations:=20The=20full=20suite=20was=20?= =?UTF-8?q?not=20required=20because=20the=20slice=20touches=20one=20compon?= =?UTF-8?q?ent=20with=20its=20own=20tests.=20Implementation=20details:=20R?= =?UTF-8?q?un=20`python3=20scripts/check=5Fno=5Fnew=5Fcomments.py`.=20Non-?= =?UTF-8?q?goals:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature?= =?UTF-8?q?=20state:=20active=20Layer=20exception:=20allowed.=20Verificati?= =?UTF-8?q?on=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20?= =?UTF-8?q?commit=20so=20they=20check=20the=20final=20branch=20the=20PR=20?= =?UTF-8?q?will=20carry.=20Acceptance=20criteria:=20-=20The=20command=20ex?= =?UTF-8?q?its=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Review claim: `python3 scripts/check_no_new_comments.py` passes on the finished branch. Review lane: proof Safety invariant: Verification is read-only and alters no repository file. Effectiveness measurement: The command's exit code is the direct measurement. Slice rationale: One check per proof task. Architectural effect: None; verification only. Goal: Prove the slice. Motivation: Running the check is the proof. Alternative considerations: The full suite was not required because the slice touches one component with its own tests. Implementation details: Run `python3 scripts/check_no_new_comments.py`. Non-goals: No mutations. Layer: app_regression Feature state: active Layer exception: allowed. Verification and the terminal scrub run after the docs commit so they check the final branch the PR will carry. Acceptance criteria: - The command exits 0. --- scripts/ci/check_no_new_comments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/check_no_new_comments.py b/scripts/ci/check_no_new_comments.py index 97210b61c..010231f9d 100644 --- a/scripts/ci/check_no_new_comments.py +++ b/scripts/ci/check_no_new_comments.py @@ -16,7 +16,7 @@ import subprocess import sys -SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__)) REPO_ROOT = os.path.dirname(os.path.dirname(SCRIPTS_DIR)) sys.path.insert(0, os.path.join(REPO_ROOT, "engine", "hooks", "no-comments")) sys.path.insert(0, SCRIPTS_DIR) From 54e76b3fe572e414b2a511e31d2e5c2daec5222f Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 01:01:13 +0800 Subject: [PATCH 09/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/verify-pref?= =?UTF-8?q?light-runs-validator-4=20=E2=80=94=20Review=20claim:=20`python3?= =?UTF-8?q?=20scripts/check=5Fno=5Fnew=5Fcomments.py`=20passes=20on=20the?= =?UTF-8?q?=20finished=20branch.=20Review=20lane:=20proof=20Safety=20invar?= =?UTF-8?q?iant:=20Verification=20is=20read-only=20and=20alters=20no=20rep?= =?UTF-8?q?ository=20file.=20Effectiveness=20measurement:=20The=20command'?= =?UTF-8?q?s=20exit=20code=20is=20the=20direct=20measurement.=20Slice=20ra?= =?UTF-8?q?tionale:=20One=20check=20per=20proof=20task.=20Architectural=20?= =?UTF-8?q?effect:=20None;=20verification=20only.=20Goal:=20Prove=20the=20?= =?UTF-8?q?slice.=20Motivation:=20Running=20the=20check=20is=20the=20proof?= =?UTF-8?q?.=20Alternative=20considerations:=20The=20full=20suite=20was=20?= =?UTF-8?q?not=20required=20because=20the=20slice=20touches=20one=20compon?= =?UTF-8?q?ent=20with=20its=20own=20tests.=20Implementation=20details:=20R?= =?UTF-8?q?un=20`python3=20scripts/check=5Fno=5Fnew=5Fcomments.py`.=20Non-?= =?UTF-8?q?goals:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature?= =?UTF-8?q?=20state:=20active=20Layer=20exception:=20allowed.=20Verificati?= =?UTF-8?q?on=20and=20the=20terminal=20scrub=20run=20after=20the=20docs=20?= =?UTF-8?q?commit=20so=20they=20check=20the=20final=20branch=20the=20PR=20?= =?UTF-8?q?will=20carry.=20Acceptance=20criteria:=20-=20The=20command=20ex?= =?UTF-8?q?its=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From cc4dd390202022a891ca379285c8ffff8cda7f5e Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 01:02:02 +0800 Subject: [PATCH 10/18] =?UTF-8?q?invoker:=20wf-1790182452439-6/scrub-hando?= =?UTF-8?q?ff-artifacts=20=E2=80=94=20Review=20claim:=20No=20ephemeral=20i?= =?UTF-8?q?nter-task=20handoff=20files=20remain=20in=20the=20worktree=20be?= =?UTF-8?q?fore=20the=20merge=20gate.=20Review=20lane:=20cleanup=20Safety?= =?UTF-8?q?=20invariant:=20The=20scrub=20script=20only=20checks=20for=20kn?= =?UTF-8?q?own=20handoff=20artifact=20names=20and=20never=20touches=20sour?= =?UTF-8?q?ce,=20tests,=20or=20other=20repository=20files.=20Effectiveness?= =?UTF-8?q?=20measurement:=20The=20script=20exits=20non-zero=20if=20any=20?= =?UTF-8?q?handoff=20artifact=20remains.=20Slice=20rationale:=20Required?= =?UTF-8?q?=20terminal=20scrub=20for=20every=20implementation=20workflow.?= =?UTF-8?q?=20Architectural=20effect:=20None;=20hygiene=20only.=20Goal:=20?= =?UTF-8?q?Leave=20the=20branch=20free=20of=20handoff=20artifacts.=20Motiv?= =?UTF-8?q?ation:=20Handoff=20files=20must=20not=20reach=20the=20PR.=20Alt?= =?UTF-8?q?ernative=20considerations:=20Manual=20cleanup=20was=20set=20asi?= =?UTF-8?q?de=20as=20non-deterministic.=20Implementation=20details:=20Run?= =?UTF-8?q?=20scripts/scrub-handoff-artifacts.sh.=20Non-goals:=20No=20prod?= =?UTF-8?q?uct=20edits.=20Layer:=20app=5Fregression=20Feature=20state:=20a?= =?UTF-8?q?ctive=20Layer=20exception:=20allowed.=20Verification=20and=20th?= =?UTF-8?q?e=20terminal=20scrub=20run=20after=20the=20docs=20commit=20so?= =?UTF-8?q?=20they=20check=20the=20final=20branch=20the=20PR=20will=20carr?= =?UTF-8?q?y.=20Acceptance=20criteria:=20-=20The=20command=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From 9bb7258ce3d220c8526ff009beeca94d059dfd2d Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 10:52:21 +0800 Subject: [PATCH 11/18] cat-mode: many PR stacks run as one parallel unit per stack, never serially route_execution(units=N) never returns local for N>1 publishing units: Invoker first, else one worktree subagent per unit (also when the user directs subagents). Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I9d5797fbed454079561fd2f4b36379ab7b7df02d --- corpus/skills/cat-mode/SKILL.md | 1 + .../cat-mode/references/execution-routing.md | 1 + .../skills/cat-mode/references/subagents.md | 23 ++++++++ .../cat-mode/scripts/route_execution.py | 55 ++++++++++++++++--- tests/test_execution_routing.py | 41 ++++++++++++++ 5 files changed, 112 insertions(+), 9 deletions(-) diff --git a/corpus/skills/cat-mode/SKILL.md b/corpus/skills/cat-mode/SKILL.md index 97a3caba2..c826356b2 100644 --- a/corpus/skills/cat-mode/SKILL.md +++ b/corpus/skills/cat-mode/SKILL.md @@ -138,6 +138,7 @@ isolated subagents and report back async rather than blocking on each one. durable artifact.** Separable and parallel is not authorization to fan out; a fan-out default cannot hand a subagent publishing authority the routing table never granted. Route that work through Execution routing. +- **Many PR stacks: one parallel unit per stack, never serial** (Invoker, else a worktree subagent each). - **A fork/subagent told to touch files must run in its own worktree, not the live checkout** — even when told "read-only." Scope wording is not filesystem isolation. diff --git a/corpus/skills/cat-mode/references/execution-routing.md b/corpus/skills/cat-mode/references/execution-routing.md index b59942609..dd105c7c6 100644 --- a/corpus/skills/cat-mode/references/execution-routing.md +++ b/corpus/skills/cat-mode/references/execution-routing.md @@ -6,6 +6,7 @@ Catstack owns judgment and local fallback. Invoker owns durable plan submission, ## Decision +0. **More than one independent publishing unit** (several PR stacks to land or repair, several workflows): never serial in the parent chat. Invoker first, one workflow per unit; `subagent_worktree_per_unit` when Invoker is unavailable or the user directs subagents. `route_execution(units=N)` returns it; see [subagents.md](subagents.md). 1. **Invoker unavailable** (no `invoker_prepare_plan_review` / `invoker_submit_plan` tools): stay local — subagents, `loop-generator`, `land-stack`, current chat execution. 2. **Small local work** (one-file fix, short edit, read-only question): stay local even if Invoker is installed. Post-land wait until `MERGED`, merge-queue babysit, and already-named execution Backlog are **not** this bucket — they are `durable_parallel`. 3. **Approved plan or durable/parallel work** and Invoker MCP is available: delegate. If Invoker is missing, use a separate git worktree + PR stack. Do not park that work in the parent chat. diff --git a/corpus/skills/cat-mode/references/subagents.md b/corpus/skills/cat-mode/references/subagents.md index 8180b19c7..5ed7b83d3 100644 --- a/corpus/skills/cat-mode/references/subagents.md +++ b/corpus/skills/cat-mode/references/subagents.md @@ -36,6 +36,29 @@ definition whatever `produces` claims; and an empty or unrecognized `produces` raises rather than falling through to fan-out, because an output nobody declared is unchecked, not clean. +## Many stacks: parallel per unit, never serial + +Routing picks *who* runs publishing work; it never licenses running several +independent units one after another in the parent thread. When the work is N +independent PR stacks (landing, conflict repair, review fixes), each stack is +its own unit with its own worktree, and the units run in parallel: an Invoker +workflow per stack first, and one worktree-isolated subagent per stack as the +fallback when Invoker is unavailable or the user directs it +(`subagent_worktree_per_unit`). The per-unit subagent still inherits only the +scope the parent names, and its transcript is still grepped for writes. + +The failure shape: asked to land dozens of admin-bypass PRs across two repos, +the parent recommended working the ~20 rebases and review fixes "one at a +time" and started serially in one worktree, until the user asked for a +worktree subagent per stack. The routing table allowed it: `route_execution` +returned `local` for publishing work without Invoker at any unit count. + +Prior art: Amdahl's law — Gene M. Amdahl, "Validity of the single processor +approach to achieving large scale computing capabilities", AFIPS 1967, +https://doi.org/10.1145/1465482.1465560 — the serial fraction bounds the +whole job, so independent units forced through one thread set the finish +time. + ## Defer to the harness's routing skill The precedence above is catstack's fallback, not the owner. When a harness diff --git a/corpus/skills/cat-mode/scripts/route_execution.py b/corpus/skills/cat-mode/scripts/route_execution.py index 4fbf70287..2ad1bc086 100644 --- a/corpus/skills/cat-mode/scripts/route_execution.py +++ b/corpus/skills/cat-mode/scripts/route_execution.py @@ -11,8 +11,8 @@ from typing import Literal WorkKind = Literal["readonly", "small_local", "approved_plan", "durable_parallel"] -Route = Literal["local", "delegate_invoker"] -Delegation = Literal["local", "delegate_invoker", "subagent_fanout"] +Route = Literal["local", "delegate_invoker", "subagent_worktree_per_unit"] +Delegation = Literal["local", "delegate_invoker", "subagent_fanout", "subagent_worktree_per_unit"] DURABLE_ALIASES = frozenset({"post_land_babysit", "named_execution_backlog"}) @@ -36,6 +36,13 @@ "invoker_wait_for_workflow_or_status", ) +SUBAGENT_PER_UNIT_STEPS = ( + "one_worktree_per_unit", + "spawn_one_subagent_per_unit_in_parallel", + "collect_reports_async", + "grep_transcripts_for_writes", +) + SUBAGENT_FANOUT_STEPS = ( "spawn_worktree_isolated_subagents", "collect_reports_async", @@ -72,14 +79,32 @@ def normalize_work_kind(work_kind: str) -> WorkKind: raise ValueError(f"unknown work_kind: {work_kind!r}") -def route_execution(*, tools: set[str] | frozenset[str] | list[str], work_kind: str) -> Route: +def route_execution( + *, + tools: set[str] | frozenset[str] | list[str], + work_kind: str, + units: int = 1, + user_directed_subagents: bool = False, +) -> Route: """Return where execution should run for this request. - 1. Invoker MCP missing → local - 2. Small / read-only work → local even if Invoker exists - 3. Approved plan or durable/parallel → delegate_invoker + `units` counts independent publishing units (PR stacks, workflows). + More than one never runs serially in the parent thread: + Invoker first, else one worktree-isolated subagent per unit. + + 1. units > 1 → delegate_invoker, or subagent_worktree_per_unit when + Invoker is missing or the user directed subagents + 2. Invoker MCP missing → local + 3. Small / read-only work → local even if Invoker exists + 4. Approved plan or durable/parallel → delegate_invoker """ kind = normalize_work_kind(work_kind) + if units < 1: + raise ValueError(f"units must be >= 1, got {units!r}") + if units > 1 and kind != "readonly": + if invoker_mcp_available(tools) and not user_directed_subagents: + return "delegate_invoker" + return "subagent_worktree_per_unit" if not invoker_mcp_available(tools): return "local" if kind in ("readonly", "small_local"): @@ -112,6 +137,8 @@ def route_delegation( tools: set[str] | frozenset[str] | list[str], work_kind: str, produces: set[str] | frozenset[str] | list[str] | tuple[str, ...], + units: int = 1, + user_directed_subagents: bool = False, ) -> Delegation: """Resolve the Subagents default against the execution-routing table. @@ -122,7 +149,9 @@ def route_delegation( """ normalize_work_kind(work_kind) if publishes(work_kind=work_kind, produces=produces): - return route_execution(tools=tools, work_kind=work_kind) + return route_execution( + tools=tools, work_kind=work_kind, units=units, user_directed_subagents=user_directed_subagents, + ) return "subagent_fanout" @@ -131,6 +160,8 @@ def handoff_steps_for(route: Route | Delegation) -> tuple[str, ...]: return ("stay_local",) if route == "subagent_fanout": return SUBAGENT_FANOUT_STEPS + if route == "subagent_worktree_per_unit": + return SUBAGENT_PER_UNIT_STEPS return DELEGATE_HANDOFF_STEPS @@ -142,9 +173,15 @@ def handoff_steps_for(route: Route | Delegation) -> tuple[str, ...]: tools = payload.get("tools", []) work_kind = payload.get("work_kind", "small_local") produces = payload.get("produces") + units = int(payload.get("units", 1)) + directed = bool(payload.get("user_directed_subagents", False)) if produces is None: - route: Route | Delegation = route_execution(tools=tools, work_kind=work_kind) + route: Route | Delegation = route_execution( + tools=tools, work_kind=work_kind, units=units, user_directed_subagents=directed, + ) else: - route = route_delegation(tools=tools, work_kind=work_kind, produces=produces) + route = route_delegation( + tools=tools, work_kind=work_kind, produces=produces, units=units, user_directed_subagents=directed, + ) defer_to = installed_harness_routing_skill(payload.get("home")) print(json.dumps({"route": route, "steps": list(handoff_steps_for(route)), "defer_to": defer_to})) diff --git a/tests/test_execution_routing.py b/tests/test_execution_routing.py index 49b3a80fd..8fb7bddba 100644 --- a/tests/test_execution_routing.py +++ b/tests/test_execution_routing.py @@ -40,6 +40,47 @@ def test_partial_tools_still_local(self): ) self.assertEqual(route, "local") + def test_many_publishing_units_never_run_serially(self): + tools = list(self.router.INVOKER_REQUIRED_TOOLS) + for kind in ("durable_parallel", "approved_plan", "post_land_babysit", "small_local"): + for available in (tools, []): + with self.subTest(kind=kind, invoker=bool(available)): + route = self.router.route_execution(tools=available, work_kind=kind, units=13) + self.assertNotEqual(route, "local") + + def test_many_units_prefer_invoker_then_worktree_subagent_per_unit(self): + tools = list(self.router.INVOKER_REQUIRED_TOOLS) + self.assertEqual( + self.router.route_execution(tools=tools, work_kind="post_land_babysit", units=13), + "delegate_invoker", + ) + self.assertEqual( + self.router.route_execution(tools=[], work_kind="post_land_babysit", units=13), + "subagent_worktree_per_unit", + ) + self.assertEqual( + self.router.route_execution( + tools=tools, work_kind="post_land_babysit", units=13, user_directed_subagents=True, + ), + "subagent_worktree_per_unit", + ) + steps = self.router.handoff_steps_for("subagent_worktree_per_unit") + self.assertEqual(steps[0], "one_worktree_per_unit") + self.assertIn("grep_transcripts_for_writes", steps) + + def test_many_units_route_delegation_reaches_per_unit_route(self): + route = self.router.route_delegation( + tools=[], work_kind="durable_parallel", produces=["pull_request"], units=4, + ) + self.assertEqual(route, "subagent_worktree_per_unit") + + def test_units_must_be_positive(self): + with self.assertRaises(ValueError): + self.router.route_execution(tools=[], work_kind="durable_parallel", units=0) + + def test_single_unit_keeps_existing_routes(self): + self.assertEqual(self.router.route_execution(tools=[], work_kind="approved_plan", units=1), "local") + def test_small_local_stays_local_even_with_invoker(self): tools = list(self.router.INVOKER_REQUIRED_TOOLS) for kind in ("small_local", "readonly"): From 13ab621032e05a184793ddf0f3012ebac5084eea Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Tue, 22 Sep 2026 00:59:32 -0700 Subject: [PATCH 12/18] reflect: catch an evidence-order correction from the ledger transition, not from wording Both retraction detectors need a wrongness word. Two real corrections carry none, because the claim was true and only its order was wrong: "Correcting one claim and arming the check I implied:" "I was right - but I said it a turn before I checked it" Primary fix, no regex and no model: a ledger row going outstanding -> discharged IS the event "a claim was asserted before its check ran, and the check has now run". The Stop that discharges a row now reports it as a reflect trigger. It reads state, so the reply's wording is not consulted at all. Secondary fix: the judge dictionary's meaning now covers "right, but said before it was checked", and both real strings are eval cases. Run against the real judge, all six cases land where the dictionary says they should. The regex scan keeps its hole on purpose, pinned by three tests so the next author widens it knowingly rather than by accident. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I99190104b68d88f78aa9c8a706c9a6d23055aecc --- .../phrases/wrong-check-reflect.json | 6 ++-- engine/hooks/unverified-tag-ledger/README.md | 6 ++++ engine/hooks/unverified-tag-ledger/detect.py | 35 ++++++++++++++----- .../unverified-tag-ledger/tests/test_hooks.py | 30 ++++++++++++++++ .../wrong-check-reflect/eval_dictionary.py | 3 ++ .../tests/test_self_retraction_scan.py | 24 +++++++++++++ 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/engine/hooks/llm-judge/phrases/wrong-check-reflect.json b/engine/hooks/llm-judge/phrases/wrong-check-reflect.json index 173dbcbef..cf121c913 100644 --- a/engine/hooks/llm-judge/phrases/wrong-check-reflect.json +++ b/engine/hooks/llm-judge/phrases/wrong-check-reflect.json @@ -1,7 +1,7 @@ { "checker": "wrong-check-reflect", "reads": "reply", - "meaning": "The latest assistant reply admits that something it told the user earlier was wrong, misread, or answered the wrong question.", + "meaning": "The latest assistant reply admits that something it told the user earlier was wrong, misread, or answered the wrong question, OR that an earlier claim of its own went out before the check backing it had run. A reply saying the claim turned out correct but was stated before it was checked IS a match: the failure is the order, not the answer, so no word in the reply has to say anything was wrong.", "match": [ "my earlier check was wrong", "You're right, I misread that", @@ -10,7 +10,9 @@ "I was wrong about the path", "Correction: the file I pointed you to earlier is not the one in use; the real one is src/b.py.", "Good catch. The earlier number was off; the real count is 12.", - "My earlier read of the config was wrong \u2014 the default is 4, not 8." + "My earlier read of the config was wrong \u2014 the default is 4, not 8.", + "Correcting one claim and arming the check I implied:", + "I was right - but I said it a turn before I checked it" ], "not_match": [ "You're right. Let's go with option B.", diff --git a/engine/hooks/unverified-tag-ledger/README.md b/engine/hooks/unverified-tag-ledger/README.md index ccfa1d8cf..121cd94ad 100644 --- a/engine/hooks/unverified-tag-ledger/README.md +++ b/engine/hooks/unverified-tag-ledger/README.md @@ -53,6 +53,12 @@ fixtures in `tests/test_hooks.py`. reason is written to stderr. - **Escalation** — a claim outstanding `ESCALATE_AFTER_TURNS` (3) turns or more is reported as a reflect trigger rather than accumulating quietly. +- **Discharge is itself a reflect trigger** — a row going outstanding -> + discharged is the record of a claim that went out first and was checked + after. That is an evidence-order miss, and it carries no wrongness word, so + the phrase scanners (`engine/skills/reflect/scripts/self_retraction_scan.py`, + and the `wrong-check-reflect` dictionary) cannot see it from the text. This + hook sees it from state instead, and says so on the Stop that discharges. Malformed tags are deliberately ignored here; `diu-stop` already rejects those. diff --git a/engine/hooks/unverified-tag-ledger/detect.py b/engine/hooks/unverified-tag-ledger/detect.py index 8a47c2eff..1f54902da 100644 --- a/engine/hooks/unverified-tag-ledger/detect.py +++ b/engine/hooks/unverified-tag-ledger/detect.py @@ -34,6 +34,15 @@ ESCALATE_AFTER_TURNS = 3 MAX_LISTED = 5 +DISCHARGE_REFLECT = ( + "unverified-tag-ledger: {count} claim(s) went from unverified to checked this turn: " + "{claims}. That transition is the whole event: the claim went out first and the check " + "ran after. No wording has to admit anything for this to be true, which is why the " + "phrase scanners miss it -- an evidence-order miss carries no wrongness word. " + "Treat it as a reflect trigger, not a milestone: run reflect on this transcript, or " + "say plainly why this one does not need it." +) + CLAIM_RE = re.compile( r"\{\{\s*CAT-UNVERIFIED\s*:?\s*(?P.*?)(?:--|—)\s*cannot\s+verify\s*:\s*(?P[^}]*)\}\}", re.IGNORECASE | re.DOTALL, @@ -177,18 +186,26 @@ def evaluate(payload: dict) -> dict: session_id = str(payload.get("session_id") or "") message = _last_assistant_text(payload) tools = tools_used_this_turn(payload) + was_open = {row["claim"] for row in outstanding(read_ledger(session_id))} rows = record_turn(session_id, message, tools) + notes = [] + discharged = sorted( + row["claim"] for row in rows if row.get("resolved") and row["claim"] in was_open) + if discharged: + notes.append(DISCHARGE_REFLECT.format( + count=len(discharged), claims="; ".join(discharged[:MAX_LISTED]))) new_claims = {tag["claim"] for tag in parse_tags(message)} if not new_claims: - return {"note": "", "block": ""} + return {"note": "\n".join(notes), "block": ""} if tools is None: - return {"note": ( + notes.append( f"unverified-tag-ledger: logged {len(new_claims)} CAT-UNVERIFIED claim(s), but this " "turn's tool calls could not be read from transcript_path (see the line above), so " "whether a check was attempted is UNCHECKED, not clean. Nothing was discharged and " - "the turn was not refused."), "block": ""} + "the turn was not refused.") + return {"note": "\n".join(notes), "block": ""} if not tools & VERIFY_TOOLS and not payload.get("stop_hook_active"): claims = "; ".join(sorted(new_claims)[:MAX_LISTED]) @@ -201,12 +218,12 @@ def evaluate(payload: dict) -> dict: fresh = [row for row in rows if row["claim"] in new_claims and not row.get("resolved") and row.get("turns", 0) == 0] - if not fresh: - return {"note": "", "block": ""} - return {"note": ( - f"unverified-tag-ledger: logged {len(fresh)} CAT-UNVERIFIED claim(s) against this session. " - "They are deferred, not discharged, and will be raised again next turn " - "(cat-mode/SKILL.md:269)."), "block": ""} + if fresh: + notes.append( + f"unverified-tag-ledger: logged {len(fresh)} CAT-UNVERIFIED claim(s) against this " + "session. They are deferred, not discharged, and will be raised again next turn " + "(cat-mode/SKILL.md:269).") + return {"note": "\n".join(notes), "block": ""} def decide_stop(payload: dict) -> str: diff --git a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py index 12ab99310..251cede62 100644 --- a/engine/hooks/unverified-tag-ledger/tests/test_hooks.py +++ b/engine/hooks/unverified-tag-ledger/tests/test_hooks.py @@ -38,6 +38,9 @@ "-- cannot verify: my own reasoning isn't observable by any command}}") MALFORMED = "{{CAT-UNVERIFIED: something I did not check}}" +EVIDENCE_ORDER_1 = "Correcting one claim and arming the check I implied:" +EVIDENCE_ORDER_2 = "I was right - but I said it a turn before I checked it" + def _real_transcript_lines() -> list[str]: with open(REAL_TRANSCRIPT, encoding="utf-8") as handle: @@ -209,6 +212,33 @@ def test_verified_and_dropped_tag_is_discharged_through_the_real_payload(self) - self.assertEqual(self.detect.outstanding(self.detect.read_ledger("s1")), []) self.assertEqual(self.detect.reminder("s1"), "") + def test_a_discharged_claim_fires_the_reflect_trigger(self) -> None: + self.detect.evaluate(self.payload(REAL_TAG_1, tools=True)) + verdict = self.detect.evaluate( + self.payload("Here is the pasted output proving it.", tools=True)) + self.assertIn("reflect trigger", verdict["note"]) + self.assertIn("widened scope", verdict["note"]) + + def test_an_evidence_order_correction_triggers_with_no_wrongness_word(self) -> None: + """The transition fires; the reply's wording is not consulted at all.""" + for index, reply in enumerate((EVIDENCE_ORDER_1, EVIDENCE_ORDER_2)): + session = f"evidence-order-{index}" + self.detect.evaluate(self.payload(REAL_TAG_1, tools=True, session_id=session)) + verdict = self.detect.evaluate( + self.payload(reply, tools=True, session_id=session)) + self.assertIn("reflect trigger", verdict["note"]) + self.assertIn("carries no wrongness word", verdict["note"]) + + def test_a_turn_that_discharges_nothing_stays_silent_about_reflect(self) -> None: + verdict = self.detect.evaluate( + self.payload("Ran the tests, all green.", tools=True)) + self.assertEqual(verdict["note"], "") + + def test_a_reemitted_tag_is_not_reported_as_discharged(self) -> None: + self.detect.evaluate(self.payload(REAL_TAG_1, tools=True)) + verdict = self.detect.evaluate(self.payload(REAL_TAG_1, tools=True)) + self.assertNotIn("reflect trigger", verdict["note"]) + def test_unchecked_turn_does_not_discharge_a_row(self) -> None: self.detect.evaluate(self.payload(REAL_TAG_1, tools=True)) self.detect.evaluate({ diff --git a/engine/hooks/wrong-check-reflect/eval_dictionary.py b/engine/hooks/wrong-check-reflect/eval_dictionary.py index 258dabb2f..9a9e89273 100644 --- a/engine/hooks/wrong-check-reflect/eval_dictionary.py +++ b/engine/hooks/wrong-check-reflect/eval_dictionary.py @@ -17,6 +17,9 @@ (HIT_TEXT, True), ("You're right. Let's go with option B.", False), ("I double-checked my earlier count and it holds; nothing in it was wrong.", False), + ("Correcting one claim and arming the check I implied:", True), + ("I was right - but I said it a turn before I checked it", True), + ("I ran the check first and then said it, so the order was right.", False), ) diff --git a/engine/skills/reflect/scripts/tests/test_self_retraction_scan.py b/engine/skills/reflect/scripts/tests/test_self_retraction_scan.py index 72266f872..852582772 100644 --- a/engine/skills/reflect/scripts/tests/test_self_retraction_scan.py +++ b/engine/skills/reflect/scripts/tests/test_self_retraction_scan.py @@ -74,6 +74,30 @@ def test_third_party_blame_inside_a_reported_clause_stays_clean(self): self.assertIsNone(self_retraction_scan.find_admission(text)) +class TestEvidenceOrderIsOutOfReach(unittest.TestCase): + """Two real corrections this scan cannot see, and the reason it cannot. + + Both are corrections about evidence ORDER: the claim was true, and it was + asserted before the check ran. Nothing in either sentence says anything was + wrong, so every pattern here misses them by construction. Pinned so the + next author widens the regex knowingly rather than by accident: the catch + for this class is the unverified-tag-ledger discharge transition, which + reads state rather than wording. + """ + + def test_arming_the_implied_check_is_not_reachable_by_wording(self): + text = "Correcting one claim and arming the check I implied:" + self.assertIsNone(self_retraction_scan.find_admission(text)) + + def test_right_but_asserted_early_is_not_reachable_by_wording(self): + text = "I was right - but I said it a turn before I checked it" + self.assertIsNone(self_retraction_scan.find_admission(text)) + + def test_the_same_sentence_with_a_wrongness_word_does_fire(self): + text = "I was wrong about the path; I said it a turn before I checked it." + self.assertIsNotNone(self_retraction_scan.find_admission(text)) + + class TestScanAssistantTexts(unittest.TestCase): def test_collects_one_hit_per_admission(self): hits = self_retraction_scan.scan_assistant_texts( From 8ae48c3876473d024ba0bdff3699696febb9dc2b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 24 Sep 2026 03:22:20 +0000 Subject: [PATCH 13/18] =?UTF-8?q?invoker:=20wf-1790219068137-115/repair=20?= =?UTF-8?q?=E2=80=94=20Repair=20PR=20#890:=20failed=5Fchecks:=20validate;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From 1c598ed94e91a09e71c6d260d3d25b56d5be232d Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 12:07:55 +0800 Subject: [PATCH 14/18] make-pr: preflight hands the validator the changed files The required PR Body check passes --changed-files-file, which is how the validator rejects changed file and folder names in the Summary and a Review Unit that does not match the diff. Preflight passed only --body-file, so those descriptions passed locally and failed after publish. describe() now takes the changed paths preflight already computed and writes them to a temp file for the validator. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: Id36a64869b7cfb2b76b912fd3cf81a88a39dd344 --- engine/skills/make-pr/scripts/preflight.py | 29 +++++++++++-------- .../make-pr/tests/test_description_check.py | 8 ++--- engine/skills/make-pr/tests/test_preflight.py | 26 +++++++++++++++++ 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/engine/skills/make-pr/scripts/preflight.py b/engine/skills/make-pr/scripts/preflight.py index e3c237048..91b0fd0f6 100644 --- a/engine/skills/make-pr/scripts/preflight.py +++ b/engine/skills/make-pr/scripts/preflight.py @@ -28,6 +28,7 @@ import shutil import subprocess import sys +import tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -225,7 +226,7 @@ def changed_paths(base: str, repo: str = REPO_ROOT) -> list[str]: return sorted({p for p in (out + untracked).splitlines() if p.strip()}) -def validate_body(body_file: str, run=None, which=None) -> tuple[int, list[str]]: +def validate_body(body_file: str, run=None, which=None, changed_paths: list[str] | None = None) -> tuple[int, list[str]]: """Run the PR-body schema validator that the required PR Body check runs. description_check only reads the prose for history claims, so a body whose @@ -240,20 +241,24 @@ def validate_body(body_file: str, run=None, which=None) -> tuple[int, list[str]] return 1, [f"description unchecked: node is not on PATH, so {VALIDATOR_NAME} did not run"] if not os.path.isfile(os.path.join(REPO_ROOT, VALIDATOR)): return 1, [f"description unchecked: no {VALIDATOR} under {REPO_ROOT}"] - cmd = ["node", VALIDATOR, "--body-file", os.path.abspath(body_file)] - try: - res = run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=VALIDATOR_TIMEOUT) - except subprocess.TimeoutExpired: - return 1, [f"description unchecked: {VALIDATOR_NAME} did not finish in {VALIDATOR_TIMEOUT}s"] - except OSError as exc: - return 1, [f"description unchecked: cannot run {VALIDATOR_NAME}: {exc}"] + with tempfile.TemporaryDirectory(prefix="preflight-") as tmp: + files_file = os.path.join(tmp, "changed-files.txt") + with open(files_file, "w", encoding="utf-8") as handle: + handle.writelines(p + "\n" for p in changed_paths or []) + cmd = ["node", VALIDATOR, "--body-file", os.path.abspath(body_file), "--changed-files-file", files_file] + try: + res = run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=VALIDATOR_TIMEOUT) + except subprocess.TimeoutExpired: + return 1, [f"description unchecked: {VALIDATOR_NAME} did not finish in {VALIDATOR_TIMEOUT}s"] + except OSError as exc: + return 1, [f"description unchecked: cannot run {VALIDATOR_NAME}: {exc}"] lines = (res.stdout + res.stderr).strip().splitlines() if res.returncode in (0, 1): return res.returncode, lines return 1, lines + [f"description unchecked: {VALIDATOR_NAME} exited {res.returncode}"] -def describe(body_file: str | None) -> int: +def describe(body_file: str | None, changed_paths: list[str]) -> int: if not body_file: print("fail description unchecked: pass --body-file with the PR description") return 1 @@ -272,8 +277,8 @@ def describe(body_file: str | None) -> int: print(f" description {outcome}") status = 0 if outcome == "clean" else 1 - print(f"gate node {VALIDATOR} --body-file " + body_file) - schema_status, schema_lines = validate_body(body_file) + print(f"gate node {VALIDATOR} --body-file {body_file} --changed-files-file <{len(changed_paths)} changed path(s)>") + schema_status, schema_lines = validate_body(body_file, changed_paths=changed_paths) for line in schema_lines: print(" " + line) return status or schema_status @@ -338,7 +343,7 @@ def main(argv: list[str] | None = None) -> int: if res.returncode != 0: status = 1 if args.paths is None and not args.dry_run: - if describe(args.body_file) != 0: + if describe(args.body_file, paths) != 0: status = 1 print("ok preflight passed" if status == 0 else "fail preflight: fix the above before gh pr create") return status diff --git a/engine/skills/make-pr/tests/test_description_check.py b/engine/skills/make-pr/tests/test_description_check.py index 60286071e..1058f206b 100644 --- a/engine/skills/make-pr/tests/test_description_check.py +++ b/engine/skills/make-pr/tests/test_description_check.py @@ -83,14 +83,14 @@ class TestPreflightDescription(unittest.TestCase): def test_missing_body_file_fails_as_unchecked(self): out = io.StringIO() with redirect_stdout(out): - status = preflight.describe(None) + status = preflight.describe(None, []) self.assertEqual(status, 1) self.assertIn("description unchecked", out.getvalue()) def test_unreadable_body_file_fails_as_unchecked(self): out = io.StringIO() with redirect_stdout(out): - status = preflight.describe("/nonexistent/body.md") + status = preflight.describe("/nonexistent/body.md", []) self.assertEqual(status, 1) self.assertIn("cannot read", out.getvalue()) @@ -101,13 +101,13 @@ def test_clean_body_file_passes(self): original = description_check.check original_validate = preflight.validate_body description_check.check = lambda body: ("clean", []) - preflight.validate_body = lambda body_file: (0, ["PR body validation passed."]) + preflight.validate_body = lambda body_file, changed_paths: (0, ["PR body validation passed."]) try: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as handle: handle.write(BODY) out = io.StringIO() with redirect_stdout(out): - status = preflight.describe(handle.name) + status = preflight.describe(handle.name, []) finally: description_check.check = original preflight.validate_body = original_validate diff --git a/engine/skills/make-pr/tests/test_preflight.py b/engine/skills/make-pr/tests/test_preflight.py index 7744a9c0f..928595e45 100644 --- a/engine/skills/make-pr/tests/test_preflight.py +++ b/engine/skills/make-pr/tests/test_preflight.py @@ -419,6 +419,32 @@ def test_a_body_the_validator_accepts_still_passes(self): self.assertIn("PR body validation passed.", out) self.assertIn("ok preflight passed", out) + def test_a_changed_file_name_in_the_summary_fails_like_the_required_check(self): + with tempfile.TemporaryDirectory() as tmp: + body_file = os.path.join(tmp, "body.md") + with open(body_file, "w", encoding="utf-8") as handle: + handle.write(VALID_BODY.replace("Now one script does it.", "Now the ecosystem page does it.")) + status, out = run_preflight(body_file) + self.assertEqual(status, 1, out) + self.assertIn('"ecosystem" (changed file name)', out) + self.assertNotIn("ok preflight passed", out) + + def test_the_validator_is_handed_the_changed_files(self): + seen = {} + + def records(cmd, **kwargs): + flag = cmd.index("--changed-files-file") + with open(cmd[flag + 1], encoding="utf-8") as handle: + seen["files"] = handle.read().split() + return subprocess.CompletedProcess(cmd, 0, stdout="PR body validation passed.", stderr="") + + status, lines = pf.validate_body( + PR795_BODY, run=records, which=lambda name: "/usr/bin/node", + changed_paths=["a/b.py", "docs/ecosystem.md"], + ) + self.assertEqual(status, 0, lines) + self.assertEqual(seen["files"], ["a/b.py", "docs/ecosystem.md"]) + def test_a_missing_node_is_unchecked_not_a_pass(self): status, lines = pf.validate_body(PR795_BODY, which=lambda name: None) self.assertEqual(status, 1, lines) From ccddeb197c58a27e30fcad052a9fec3b1c5ec26f Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 24 Sep 2026 12:08:27 +0800 Subject: [PATCH 15/18] event-wait: read a final unterminated line, kill a stuck source, honor the deadline on write Three review findings left open after #790 and #821: - A lines source whose last complete record has no trailing newline is matched at EOF; a partial last line still ends as truncated_frame. - A json_stream command that ignores SIGTERM is killed after 5s instead of being left running. - Socket connect and the snapshot write use the wait's remaining time, so a peer that never reads ends the wait at its deadline. Also carries #791's harness EOF test for drop_peers. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I42c99ca13c82dc0fd5c22bc3b56a991ddd38c77b --- .../skills/event-wait/scripts/wait_event.py | 44 ++++++++-- .../event-wait/tests/test_wait_event.py | 86 ++++++++++++++++++- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/product/skills/event-wait/scripts/wait_event.py b/product/skills/event-wait/scripts/wait_event.py index f7dae8b2c..3d1550f60 100644 --- a/product/skills/event-wait/scripts/wait_event.py +++ b/product/skills/event-wait/scripts/wait_event.py @@ -455,6 +455,19 @@ def feed(self, chunk: bytes) -> Iterator[dict]: return self._feed_lines() return self._feed_length_prefix() + def finish(self) -> Iterator[dict]: + if self.framing["kind"] != "lines" or not self.buffer.strip(): + return + tail = self.buffer + try: + record = self._decode_payload(tail) + except SourceError as exc: + raise SourceError( + "truncated_frame", f"source closed with {len(tail)} unread bytes" + ) from exc + self.buffer = b"" + yield record + def _decode_payload(self, payload: bytes) -> dict: try: record = json.loads(payload.decode("utf-8")) @@ -507,10 +520,14 @@ def _feed_length_prefix(self) -> Iterator[dict]: class SocketChannel: """A connected Unix socket. Writable only when a snapshot is configured.""" - def __init__(self, path: str) -> None: + def __init__(self, path: str, timeout: float) -> None: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(timeout) try: self.sock.connect(path) + except socket.timeout: + self.sock.close() + raise Timeout() from None except OSError as exc: self.sock.close() raise SourceError( @@ -528,9 +545,12 @@ def recv(self, timeout: float) -> bytes: "read_failed", f"socket read failed ({errno.errorcode.get(exc.errno, 'error')})" ) from exc - def send(self, payload: bytes) -> None: + def send(self, payload: bytes, timeout: float) -> None: + self.sock.settimeout(timeout) try: self.sock.sendall(payload) + except socket.timeout: + raise Timeout() from None except OSError as exc: raise SourceError( "write_failed", f"snapshot request failed ({errno.errorcode.get(exc.errno, 'error')})" @@ -579,7 +599,7 @@ def recv(self, timeout: float) -> bytes: "read_failed", f"stream read failed ({errno.errorcode.get(exc.errno, 'error')})" ) from exc - def send(self, payload: bytes) -> None: + def send(self, payload: bytes, timeout: float) -> None: raise SourceError("read_only_source", "this source accepts no requests") def close(self) -> None: @@ -587,7 +607,11 @@ def close(self) -> None: try: self.process.terminate() self.process.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired) as exc: + except subprocess.TimeoutExpired: + print("event-wait: source command ignored terminate; killing it", file=sys.stderr) + self.process.kill() + self.process.wait() + except OSError as exc: print(f"event-wait: stopping source command failed: {exc}", file=sys.stderr) finally: if self.process.stdout is not None: @@ -758,7 +782,7 @@ def wake_env(self, status: str | None = None) -> dict: def attach(self) -> None: source = self.spec["source"] if source["kind"] == "unix_socket": - self.channel = SocketChannel(source["path"]) + self.channel = SocketChannel(source["path"], self.remaining()) else: self.channel = StreamChannel(source["command"], source["path"]) @@ -779,7 +803,7 @@ def send_snapshot(self) -> None: frame = len(payload).to_bytes(framing["prefix_bytes"], framing["byte_order"]) + payload else: frame = payload + b"\n" - self.channel.send(frame) + self.channel.send(frame, self.remaining()) self.requests_sent += 1 self.snapshot_open = True @@ -858,6 +882,10 @@ def consume(self) -> dict: if chunk is WOULD_BLOCK: continue if not chunk: + for record in self.decoder.finish(): + outcome = self.handle(record) + if outcome is not None: + return outcome if self.decoder.pending_bytes: raise SourceError( "truncated_frame", @@ -1001,7 +1029,7 @@ def run(spec: dict, out=sys.stdout) -> int: callback_ready, callback_status = wait.callback_readiness() try: wait.attach() - except SourceError as exc: + except (SourceError, Timeout) as exc: wait.emit( { "record": "armed", @@ -1010,7 +1038,7 @@ def run(spec: dict, out=sys.stdout) -> int: "source_ready": False, "callback_ready": callback_ready, "callback_status": callback_status, - "error_code": exc.code, + "error_code": getattr(exc, "code", "deadline_exceeded"), } ) raise diff --git a/product/skills/event-wait/tests/test_wait_event.py b/product/skills/event-wait/tests/test_wait_event.py index 9457a72cd..47adba5c5 100644 --- a/product/skills/event-wait/tests/test_wait_event.py +++ b/product/skills/event-wait/tests/test_wait_event.py @@ -13,6 +13,7 @@ """ from __future__ import annotations +import errno import json import os import shutil @@ -159,7 +160,8 @@ def drop_peers(self, expect: int = 0) -> None: try: peer.shutdown(socket.SHUT_RDWR) except OSError as exc: - print(f"producer: shutting down a peer failed: {exc}", file=sys.stderr) + if exc.errno != errno.ENOTCONN: + print(f"producer: shutting down a peer failed: {exc}", file=sys.stderr) try: peer.close() except OSError as exc: @@ -582,6 +584,19 @@ def test_a_source_without_a_snapshot_is_never_written_to(self): self.assertEqual(producer.request_count(), 0) +class ProducerHarnessTests(EventWaitTestCase): + def test_drop_peers_hands_a_connected_client_an_eof(self): + producer = self.producer("harness.sock") + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(client.close) + client.connect(producer.path) + + producer.drop_peers(expect=1) + + client.settimeout(10.0) + self.assertEqual(client.recv(4096), b"") + + class StreamFailureTests(EventWaitTestCase): def test_a_malformed_frame_fails_closed_without_echoing_the_payload(self): producer = self.producer() @@ -642,6 +657,34 @@ def test_a_match_sharing_a_read_with_a_bad_frame_is_still_delivered(self): self.assertEqual(receipt["status"], "completed") self.assertEqual(receipt["exit_code"], wait_event.EXIT_MATCHED) + def test_a_snapshot_write_to_a_peer_that_never_reads_ends_at_the_deadline(self): + path = os.path.join(self.dir, "deaf.sock") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(server.close) + server.bind(path) + server.listen(4) + held = [] + acceptor = threading.Thread(target=lambda: held.append(server.accept()[0]), daemon=True) + acceptor.start() + self.addCleanup(lambda: [peer.close() for peer in held]) + spec = self.socket_spec( + "deaf-peer", + "wf-1", + path, + deadline_seconds=2.0, + snapshot={ + "request": {"kind": "req", "pad": "x" * 8_000_000}, + "response_match_fields": {"kind": "res"}, + }, + ) + wait = self.start(spec) + wait.read_armed() + + receipt = wait.finish(timeout=12.0) + self.assertEqual(receipt["outcome"], "timeout", receipt) + self.assertEqual(receipt["error_code"], "deadline_exceeded") + self.assertEqual(receipt["exit_code"], wait_event.EXIT_TIMEOUT) + def test_a_truncated_frame_at_disconnect_is_reported_as_truncated(self): producer = self.producer() spec = self.socket_spec("truncated", "wf-1", producer.path, deadline_seconds=8.0) @@ -774,6 +817,47 @@ def test_a_stream_that_ends_before_the_match_is_a_source_error(self): self.assertEqual(receipt["error_code"], "source_closed") self.assertFalse(receipt["event_received"]) + def test_a_final_record_without_a_newline_is_still_matched(self): + script = ( + "import json,sys\n" + "sys.stdout.write(json.dumps({'workflowId':'wf-1','status':'running'}) + '\\n')\n" + "sys.stdout.write(json.dumps({'workflowId':'wf-1','status':'completed'}))\n" + ) + spec = self.stream_spec("stream-no-newline", "wf-1", script) + wait = self.start(spec) + wait.read_armed() + + receipt = wait.finish() + self.assertEqual(receipt["outcome"], "matched", receipt) + self.assertEqual(receipt["status"], "completed") + + def test_a_partial_final_line_is_still_reported_as_truncated(self): + script = "import sys\nsys.stdout.write('{\"workflowId\": \"wf-1\", \"sta')\n" + spec = self.stream_spec("stream-partial", "wf-1", script) + wait = self.start(spec) + wait.read_armed() + + receipt = wait.finish() + self.assertEqual(receipt["error_code"], "truncated_frame", receipt) + + def test_a_source_command_that_ignores_terminate_is_killed_on_close(self): + command = [ + sys.executable, + "-u", + "-c", + "import signal,time\n" + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + "print('ready')\n" + "time.sleep(60)\n", + ] + channel = wait_event.StreamChannel(command, None) + self.addCleanup(lambda: channel.process.poll() is None and channel.process.kill()) + self.assertIn(b"ready", channel.recv(10.0)) + + channel.close() + + self.assertIsNotNone(channel.process.poll()) + def test_a_stream_source_may_not_declare_a_snapshot(self): spec = self.stream_spec("stream-snap", "wf-1", "pass") spec["source"]["snapshot"] = {"request": {}} From aa9412e3473da3005fb48f125af8af0ca623d14b Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 23 Sep 2026 22:13:45 +0800 Subject: [PATCH 16/18] llm-judge: find Codex's transcript by thread-id, and deliver subagent verdicts to the parent Codex's notify payload carries no transcript path. A real capture from `codex exec` has exactly: client, cwd, input-messages, last-assistant-message, thread-id, turn-id, type. Both resolvers looked only for transcript_path / conversation_id, so every Codex job was queued with an empty transcript and its verdict was written where no drain looks. The Codex notify tests passed only because their fixture supplied a transcript_path the real harness never sends. thread-id names the rollout file under ~/.codex/sessions, so both resolvers now fall back to that file (shared in _sdk/transcripts.py). A Claude subagent's verdict lands after the subagent has stopped, so no later PostToolUse inside it ever drains it. The parent's drain now also drains /subagents/*.jsonl. Real path: a real `codex exec` turn with this branch's notify queued against its rollout file, the judge returned hit, and llm-judge's codex_notify delivered the on_hit text; report.py --judge --check exited 0. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I5a619714e44f318c4abf315ebfb7783657f5baec --- engine/hooks/_sdk/transcripts.py | 28 +++++++++ engine/hooks/llm-judge/inbox.py | 8 ++- engine/hooks/llm-judge/tests/test_inbox.py | 58 +++++++++++++++++++ engine/hooks/wrong-check-reflect/detect.py | 3 +- .../wrong-check-reflect/tests/test_hooks.py | 19 ++++++ 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 engine/hooks/_sdk/transcripts.py diff --git a/engine/hooks/_sdk/transcripts.py b/engine/hooks/_sdk/transcripts.py new file mode 100644 index 000000000..5237a5924 --- /dev/null +++ b/engine/hooks/_sdk/transcripts.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import glob +import os +import re + +CODEX_SESSIONS_ENV = "CATSTACK_CODEX_SESSIONS_DIR" +THREAD_ID = re.compile(r"^[0-9A-Za-z-]{8,64}$") + + +def codex_sessions_root() -> str: + return os.environ.get(CODEX_SESSIONS_ENV) or os.path.join(os.path.expanduser("~"), ".codex", "sessions") + + +def codex_rollout(payload: dict) -> str: + thread = payload.get("thread-id") or payload.get("thread_id") + if not isinstance(thread, str) or not THREAD_ID.match(thread): + return "" + pattern = os.path.join(codex_sessions_root(), "*", "*", "*", f"rollout-*-{thread}.jsonl") + matches = sorted(glob.glob(pattern)) + return matches[-1] if matches else "" + + +def subagent_transcripts(transcript: str) -> list[str]: + if not transcript.endswith(".jsonl"): + return [] + folder = os.path.join(transcript[: -len(".jsonl")], "subagents") + return sorted(glob.glob(os.path.join(folder, "*.jsonl"))) diff --git a/engine/hooks/llm-judge/inbox.py b/engine/hooks/llm-judge/inbox.py index 669ed910d..262739830 100644 --- a/engine/hooks/llm-judge/inbox.py +++ b/engine/hooks/llm-judge/inbox.py @@ -5,6 +5,7 @@ import os import judge +from transcripts import codex_rollout, subagent_transcripts NO_TRANSCRIPT = "llm-judge: {harness} payload has no transcript path, so finished verdicts were not checked" REPORT_LIMIT = 600 @@ -31,7 +32,7 @@ def resolve_transcript(payload: dict) -> str: candidate = os.path.join(root, project, "agent-transcripts", conv, f"{conv}.jsonl") if os.path.isfile(candidate): return candidate - return "" + return codex_rollout(payload) def _turn_text(data: dict) -> str: @@ -167,7 +168,10 @@ def unchecked_message(item: dict) -> str: def messages(transcript: str) -> list[str]: out = [] - for item in judge.drain(transcript): + drained = [] + for path in [transcript, *subagent_transcripts(transcript)]: + drained.extend(judge.drain(path)) + for item in drained: outcome = item.get("outcome") if outcome == "clean": continue diff --git a/engine/hooks/llm-judge/tests/test_inbox.py b/engine/hooks/llm-judge/tests/test_inbox.py index a6ad26a91..057719bac 100644 --- a/engine/hooks/llm-judge/tests/test_inbox.py +++ b/engine/hooks/llm-judge/tests/test_inbox.py @@ -355,5 +355,63 @@ def test_no_transcript_says_unchecked_on_stderr(self): self.assertIn("no transcript path", self.run_codex([json.dumps({"type": "agent-turn-complete", "thread-id": "t1"})])) + +REAL_CODEX_NOTIFY_KEYS = ["client", "cwd", "input-messages", "last-assistant-message", "thread-id", "turn-id", "type"] +THREAD = "01a0ce9a-301b-7d42-bb5c-b9a9a4cfe8c6" + + +class TestTranscriptResolution(InboxTestCase): + def setUp(self): + super().setUp() + sessions = os.path.join(self.work.name, "codex-sessions") + day = os.path.join(sessions, "2026", "09", "23") + os.makedirs(day) + self.rollout = os.path.join(day, f"rollout-2026-09-23T22-10-06-{THREAD}.jsonl") + with open(self.rollout, "w", encoding="utf-8") as handle: + handle.write("{}\n") + self.sessions_env = patch.dict(os.environ, {"CATSTACK_CODEX_SESSIONS_DIR": sessions}) + self.sessions_env.start() + + def tearDown(self): + self.sessions_env.stop() + super().tearDown() + + def real_codex_payload(self): + payload = { + "type": "agent-turn-complete", + "thread-id": THREAD, + "turn-id": "01a0ce9a-30dc-73f1-bfe6-ccce644446e1", + "cwd": self.work.name, + "client": "codex_exec", + "input-messages": ["Reply with the single word ok."], + "last-assistant-message": "ok", + } + self.assertEqual(sorted(payload), REAL_CODEX_NOTIFY_KEYS) + return payload + + def test_real_codex_notify_payload_resolves_to_its_rollout_file(self): + self.assertEqual(inbox.resolve_transcript(self.real_codex_payload()), self.rollout) + + def test_hit_for_a_codex_rollout_reaches_the_next_codex_notify(self): + self.transcript = self.rollout + self.seed(ANSWERS_TRUE) + self.assertEqual(self.run_codex([json.dumps(self.real_codex_payload())]), ON_HIT + "\n") + + def test_unknown_or_unsafe_thread_id_resolves_to_nothing(self): + for thread in ("0000000-0000-not-there", "../../etc", "*"): + with self.subTest(thread=thread): + self.assertEqual(inbox.resolve_transcript({"thread-id": thread}), "") + + def test_a_subagent_verdict_is_delivered_to_the_parent_session(self): + subagent = os.path.join(self.transcript[: -len(".jsonl")], "subagents", "agent-a1.jsonl") + os.makedirs(os.path.dirname(subagent)) + with open(subagent, "w", encoding="utf-8") as handle: + handle.write("{}\n") + parent = self.transcript + self.transcript = subagent + self.seed(ANSWERS_TRUE) + self.assertEqual(inbox.messages(parent), [ON_HIT]) + self.assertEqual(inbox.messages(parent), []) + if __name__ == "__main__": unittest.main() diff --git a/engine/hooks/wrong-check-reflect/detect.py b/engine/hooks/wrong-check-reflect/detect.py index 0bb9339b3..e1af76c89 100644 --- a/engine/hooks/wrong-check-reflect/detect.py +++ b/engine/hooks/wrong-check-reflect/detect.py @@ -15,6 +15,7 @@ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) from events import write_stage_event # noqa: E402 +from transcripts import codex_rollout # noqa: E402 from flags import enforcement_gate # noqa: E402 HOOKS_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -258,7 +259,7 @@ def resolve_transcript(payload: dict) -> str: return candidate except OSError: pass - return "" + return codex_rollout(payload) def _is_assistant_line(data: dict) -> bool: diff --git a/engine/hooks/wrong-check-reflect/tests/test_hooks.py b/engine/hooks/wrong-check-reflect/tests/test_hooks.py index 27a62d0c6..c647eebd9 100644 --- a/engine/hooks/wrong-check-reflect/tests/test_hooks.py +++ b/engine/hooks/wrong-check-reflect/tests/test_hooks.py @@ -545,6 +545,25 @@ def test_claude_stop_records_the_queued_job_under_the_claude_harness(self): [("wrong-check-reflect", "claude", "transcript")]) self.assertEqual(queued[0]["finding_id"], self.wait_for_jobs(1)[0][: -len(".json")]) + def test_real_codex_notify_queues_against_its_rollout_file(self): + self.use_runners(SLOW_CLEAN) + thread = "01a0ce9a-301b-7d42-bb5c-b9a9a4cfe8c6" + day = os.path.join(self.reflect_state.name, "codex-sessions", "2026", "09", "23") + os.makedirs(day) + rollout = os.path.join(day, f"rollout-2026-09-23T22-10-06-{thread}.jsonl") + with open(rollout, "w", encoding="utf-8") as handle: + handle.write("{}\n") + payload = {"type": "agent-turn-complete", "thread-id": thread, "turn-id": "t", "cwd": "/", + "client": "codex_exec", "input-messages": ["hi"], "last-assistant-message": HIT_TEXT} + with patch.dict(os.environ, {"CATSTACK_CODEX_SESSIONS_DIR": os.path.dirname(os.path.dirname(os.path.dirname(day)))}): + err = run_codex_notify([json.dumps(payload)]) + self.assertEqual(err, "") + [job] = self.wait_for_jobs(1) + with open(os.path.join(self.state.name, "jobs", job), encoding="utf-8") as handle: + self.assertEqual(json.load(handle)["transcript"], rollout) + queued = [r for r in self.stage_rows() if r["action"] == "judge_queued"] + self.assertEqual([(r["harness"], r["reason"]) for r in queued], [("codex", "transcript")]) + def test_claude_malformed_stdin_fail_open(self): err = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not-json")): From 9df0641396c8000dd5c525ee803cf3df10ebb2f3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 23 Sep 2026 14:39:56 +0000 Subject: [PATCH 17/18] llm-judge: look for Codex rollouts under CODEX_HOME when it is set Codex keeps its rollout files under $CODEX_HOME/sessions whenever that variable is set, and the notify hook inherits the variable from the Codex process that spawns it. codex_sessions_root() only looked at CATSTACK_CODEX_SESSIONS_DIR and the hard-coded ~/.codex/sessions, so a session run with a non-default Codex home resolved to no file at all: the job was queued under an empty path and its verdict could never be delivered. The explicit CATSTACK_CODEX_SESSIONS_DIR override still wins, so the tests keep pinning the sessions tree directly. Otherwise the root is derived from CODEX_HOME, falling back to ~/.codex as before. The new test fails against the previous helper with '' != the rollout path, and passes with it. Co-Authored-By: Claude Opus 5 (1M context) --- engine/hooks/_sdk/transcripts.py | 7 ++++++- engine/hooks/llm-judge/tests/test_inbox.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/engine/hooks/_sdk/transcripts.py b/engine/hooks/_sdk/transcripts.py index 5237a5924..2629a2ba3 100644 --- a/engine/hooks/_sdk/transcripts.py +++ b/engine/hooks/_sdk/transcripts.py @@ -5,11 +5,16 @@ import re CODEX_SESSIONS_ENV = "CATSTACK_CODEX_SESSIONS_DIR" +CODEX_HOME_ENV = "CODEX_HOME" THREAD_ID = re.compile(r"^[0-9A-Za-z-]{8,64}$") def codex_sessions_root() -> str: - return os.environ.get(CODEX_SESSIONS_ENV) or os.path.join(os.path.expanduser("~"), ".codex", "sessions") + override = os.environ.get(CODEX_SESSIONS_ENV) + if override: + return override + home = os.environ.get(CODEX_HOME_ENV) or os.path.join(os.path.expanduser("~"), ".codex") + return os.path.join(home, "sessions") def codex_rollout(payload: dict) -> str: diff --git a/engine/hooks/llm-judge/tests/test_inbox.py b/engine/hooks/llm-judge/tests/test_inbox.py index 057719bac..8787882f7 100644 --- a/engine/hooks/llm-judge/tests/test_inbox.py +++ b/engine/hooks/llm-judge/tests/test_inbox.py @@ -397,6 +397,21 @@ def test_hit_for_a_codex_rollout_reaches_the_next_codex_notify(self): self.seed(ANSWERS_TRUE) self.assertEqual(self.run_codex([json.dumps(self.real_codex_payload())]), ON_HIT + "\n") + def test_a_codex_home_override_is_where_the_rollout_is_looked_up(self): + home = os.path.join(self.work.name, "codex-home") + day = os.path.join(home, "sessions", "2026", "09", "23") + os.makedirs(day) + rollout = os.path.join(day, f"rollout-2026-09-23T22-40-00-{THREAD}.jsonl") + with open(rollout, "w", encoding="utf-8") as handle: + handle.write("{}\n") + self.sessions_env.stop() + try: + with patch.dict(os.environ, {"CODEX_HOME": home}, clear=False): + os.environ.pop("CATSTACK_CODEX_SESSIONS_DIR", None) + self.assertEqual(inbox.resolve_transcript(self.real_codex_payload()), rollout) + finally: + self.sessions_env.start() + def test_unknown_or_unsafe_thread_id_resolves_to_nothing(self): for thread in ("0000000-0000-not-there", "../../etc", "*"): with self.subTest(thread=thread): From 619f890a4a086996f47b3e871cda840fb83dbf02 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 23 Sep 2026 14:41:12 +0000 Subject: [PATCH 18/18] =?UTF-8?q?invoker:=20wf-1790173197097-223/repair=20?= =?UTF-8?q?=E2=80=94=20Repair=20PR=20#819:=20failed=5Fchecks:=20validate;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0