Skip to content

Commit 2d11e39

Browse files
authored
Merge pull request #30 from usjoh/feat/worktree-ref-and-sighup
feat: worktree_ref pinning + SIGHUP graceful shutdown
2 parents 08654bf + a8159bb commit 2d11e39

6 files changed

Lines changed: 277 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Each task gets its own directory, its own worker, its own log, and its own verdi
108108
| `verified` | One plain-English sentence saying what the check proves — shown on the results page next to "finished & checked" |
109109
| `full_access` | Worker runs unsandboxed — required for workers that spawn their own sub-workers; must also be enabled in config |
110110
| `worktrees` (run-level) | Give each task an isolated git worktree of `repo` so parallel workers can't collide |
111+
| `worktree_ref` (run-level) | Commit the task worktrees are created at (default `HEAD`). Pin it when the work is anchored to an older immutable baseline — worktree creation runs orchestrator-side and unsandboxed, so this is free, whereas a sandboxed worker told to `git checkout` inside its worktree fails outright (the shared `.git/worktrees` metadata is outside its writable surface) |
111112

112113
> **Worktree footgun:** on PASS the task's worktree is removed — including anything written inside it. In worktrees mode, worker logs live outside task worktrees in `workdir/logs/`; have workers write deliverables outside the worktree too, or have your `check` copy artifacts out before it exits 0.
113114

docs/MODEL-NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ checks and raw logs support — no vibes, no worker self-reports.
406406
- Both fixture-based checks passed while hiding 2 real-substrate bugs (EXIT-trap lock cleanup deleting a live lock; tilde-expansion in ${var#~/} — fixture used absolute paths). Codex not at fault; checks were fixture-only. Lesson: for ops scripts, add one real-repo scenario to the check or budget an acceptance pass after integration.
407407

408408
## gpt-5.6-sol (codex)
409+
- 2026-07-31 (observer-triad, phase-1 closeout) SCOREBOARD CORRECTION: three FAIL rows stamped `failure_class=model` that day are orchestration/environment-class, not model. (1+2) Both lanes of `bounded-primordial-v4-independent-review` (13:34Z): the spec ordered `git checkout` INSIDE the Ringer worktree, which needs a write to the shared `.git/worktrees/<id>/index.lock` — outside workspace-write's writable roots — so the first command failed EPERM. Both workers did exactly the right thing: halted immediately, changed nothing, reported the blocked command and the fix verbatim (~14k tokens each). The check then failed on the absent baseline/report and the rows recorded as model failures. Same defect class as the cttc r1 correction above (sandbox write-boundary vs spec contract); fixed structurally by the manifest-level `worktree_ref` field (worktree creation is orchestrator-side and unsandboxed) and, spec-side, by the clone-into-taskdir pattern. (3) `contract-authority` attempt 1 in the first `adversarial-review` dispatch (22:23Z, FAIL at 9m): the check rejected the draft report, and the retry was still in flight when the whole run died with its parent session (network drop → session death → unhandled SIGHUP; the ghost-run signature that put SIGHUP into the shutdown handler). At most a first-try format slip with the retry mechanism never allowed to finish — the identical three-lane review re-run later that evening went 3/3 first-try, READY×3. Discount all three rows when reading Sol's code-review cell.
409410
- 2026-07-28 (meridian, cttc-idea-join-notif — live Apps Script family board, 6 rounds): medium effort 3/3 first-try on code-feature/code-fix (impl 49.5k tok/108s; five-finding fix round 40k/81s; N1 lock restructure 43.5k/86s) — every fix landed exactly per the reviewer's minimal-concrete-edit blocks including comment placement; medium stays the build lane. High effort earned its review tier again: r1 NO-SHIP with 5 findings ALL reproduced by throwaway fixtures (incl. a minor-recipient gap the spec's own invariant demanded and a quota-arithmetic case tied to that night's planned family nudge); r2 caught a NEW HIGH the fix round introduced (script lock held across sendEmail → silent family-write loss, proven via instrumented LockService stub); r3 verified the replacement design with zero-violation lock-state evidence and an explicit trade judgment (105k/73k tokens for r2/r3). This is what high is FOR: last-gate review of live-user deploys. SCOREBOARD CORRECTION: two recorded FAILs on Sol's code-review cell (r1, attempts 1+2) are orchestrator-class, not model — the rows are STAMPED failure_class=model and this note is the correction. The review manifest demanded the report at an absolute path outside the sandbox's writable roots (workspace-write covers [workdir,/tmp,$TMPDIR] only) and passed no writable_roots override; the worker's reports were substance-complete BOTH attempts and were harvested from the taskdir. Same defect class as harness-audit-remediation r1 earlier the same day — the lesson was already in the session record and was reproduced anyway. Do not read Sol's code-review first-try rate at face value until these rows are discounted. Check-craft: (1) absolute-path deliverables from sandboxed workers need writable_roots, or use a RELATIVE taskdir deliverable + harvest-in-check (checks run outside the sandbox — the pattern that fixed r2/r3 here); (2) grep -E with \| alternation silently matches nothing — two false "risk-area-unaddressed" FAILs in the r1 check were this; lint cannot catch your check's own regexes.
410411
- 2026-07-15 ringer-self-update run (3 serial tasks, direct-repo-edit mode): code-fix baseline-test repair 1/1 first-try (61k tokens, 1.6m); code-feature self-update mechanism (git fetch/ff-pull/re-exec + HUD staleness restart + 20-test suite) 1/1 first-try at high effort (153k, 8.1m); code-feature signal-contract (all 3 scoreboard surfaces + canonical-route lint enforcement) passed on retry (358k, 13.7m) — attempt 1 died on stale old-column assertions in pre-existing tests it hadn't finished updating; the retry prompt's injected FAIL list was enough to close it out. Lesson: when a task rewrites a display contract, name every test file asserting the old contract in the spec's ownership list AND tell it to update them FIRST.
411412
- 2026-07-09 code-feature/code-fix (ringside-overhaul): 4/4 first-try — a ringer.py logging change with tests, a 265-line stdlib backfill CLI (atomic rewrite, dry-run, idempotence all check-verified), a ~1500-line single-file HTML redesign (running-now pills + worker-card grid + multi-expansion refactor, 30KB patch, node --check + contract greps + unittest), and a render-gating change where it correctly UPDATED tests asserting the old behavior instead of gaming the check. Medium/high reasoning, 65–120k tokens/task.

ringer.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,22 +1727,22 @@ class Manifest:
17271727
repo: Path | None
17281728
tasks: tuple[TaskSpec, ...]
17291729
source_path: Path | None = None
1730+
# The commit worktrees are created at. Defaults to HEAD, but a run whose
1731+
# candidate is anchored to an older immutable baseline can pin it here —
1732+
# worktree creation happens orchestrator-side, UNSANDBOXED, so this is the
1733+
# only place a non-HEAD base costs nothing. A sandboxed worker cannot
1734+
# `git checkout` inside its worktree (the shared .git/worktrees metadata
1735+
# is outside its writable surface), and the workaround it is forced into
1736+
# (cloning the repo into its own taskdir) burns tokens on plumbing.
1737+
worktree_ref: str = "HEAD"
17301738

17311739
@classmethod
17321740
def from_path(cls, path: Path) -> "Manifest":
17331741
data = json.loads(path.read_text(encoding="utf-8"))
17341742
if not isinstance(data, dict):
17351743
raise ValueError("manifest root must be a JSON object")
17361744
manifest = cls.from_obj(data)
1737-
return cls(
1738-
run_name=manifest.run_name,
1739-
workdir=manifest.workdir,
1740-
max_parallel=manifest.max_parallel,
1741-
worktrees=manifest.worktrees,
1742-
repo=manifest.repo,
1743-
tasks=manifest.tasks,
1744-
source_path=path,
1745-
)
1745+
return dataclass_replace(manifest, source_path=path)
17461746

17471747
@classmethod
17481748
def from_obj(cls, obj: dict[str, Any]) -> "Manifest":
@@ -1769,6 +1769,15 @@ def from_obj(cls, obj: dict[str, Any]) -> "Manifest":
17691769
if duplicates:
17701770
raise ValueError(f"duplicate task keys: {', '.join(duplicates)}")
17711771
worktrees = bool(obj.get("worktrees", False))
1772+
worktree_ref_raw = obj.get("worktree_ref")
1773+
if worktree_ref_raw is not None and not isinstance(worktree_ref_raw, str):
1774+
raise ValueError("worktree_ref must be a string")
1775+
worktree_ref = (worktree_ref_raw or "HEAD").strip() or "HEAD"
1776+
if worktree_ref != "HEAD" and not worktrees:
1777+
raise ValueError(
1778+
"worktree_ref is set but worktrees is false — the ref only pins "
1779+
"where task worktrees are created, so enable worktrees or drop it"
1780+
)
17721781
if worktrees:
17731782
reserved_logs_dir = (workdir / "logs").resolve()
17741783
collisions = []
@@ -1788,22 +1797,15 @@ def from_obj(cls, obj: dict[str, Any]) -> "Manifest":
17881797
worktrees=worktrees,
17891798
repo=repo,
17901799
tasks=tasks,
1800+
worktree_ref=worktree_ref,
17911801
)
17921802

17931803
def with_max_parallel(self, value: int | None) -> "Manifest":
17941804
if value is None:
17951805
return self
17961806
if value <= 0:
17971807
raise ValueError("--max-parallel must be positive")
1798-
return Manifest(
1799-
run_name=self.run_name,
1800-
workdir=self.workdir,
1801-
max_parallel=value,
1802-
worktrees=self.worktrees,
1803-
repo=self.repo,
1804-
tasks=self.tasks,
1805-
source_path=self.source_path,
1806-
)
1808+
return dataclass_replace(self, max_parallel=value)
18071809

18081810

18091811
FILE_TEST_OPS = {"-e", "-f", "-s", "-d", "-r", "-w", "-x", "-L"}
@@ -9432,8 +9434,11 @@ async def _prepare_taskdir(self, runtime: TaskRuntime) -> tuple[bool, str | None
94329434
str(self.manifest.repo),
94339435
"worktree",
94349436
"add",
9437+
# --detach so a branch-name ref cannot fail on "already
9438+
# checked out" — every taskdir gets a detached copy either way.
9439+
"--detach",
94359440
str(taskdir),
9436-
"HEAD",
9441+
self.manifest.worktree_ref,
94379442
stdin=asyncio.subprocess.DEVNULL,
94389443
stdout=asyncio.subprocess.PIPE,
94399444
stderr=asyncio.subprocess.STDOUT,
@@ -10456,7 +10461,7 @@ async def run_baseline(manifest: Manifest, *, config: AppConfig) -> int:
1045610461
"add",
1045710462
"--detach",
1045810463
str(taskdir),
10459-
"HEAD",
10464+
manifest.worktree_ref,
1046010465
stdin=asyncio.subprocess.DEVNULL,
1046110466
stdout=asyncio.subprocess.PIPE,
1046210467
stderr=asyncio.subprocess.STDOUT,
@@ -11123,7 +11128,20 @@ def request_shutdown() -> None:
1112311128
shutdown_started = True
1112411129
task.cancel()
1112511130

11126-
for sig in (signal.SIGINT, signal.SIGTERM):
11131+
# SIGHUP is in the set because a run's parent is often an interactive
11132+
# agent session: when that session dies (network drop, app crash, closed
11133+
# terminal), the shell HUPs the group. Unhandled, the orchestrator died
11134+
# mid-flush and left a ghost — state live/unfinished forever, workers
11135+
# killed with no ERROR verdicts recorded (observed 2026-07-31, when a
11136+
# network interruption took down the session driving a 3-lane review).
11137+
# An INHERITED ignore is respected: `nohup ./ringer.py run` means
11138+
# "survive hangups", and installing a handler over SIG_IGN would turn
11139+
# nohup into a graceful shutdown — the opposite of what it promises.
11140+
shutdown_signals = [signal.SIGINT, signal.SIGTERM]
11141+
hup = getattr(signal, "SIGHUP", None) # absent on Windows
11142+
if hup is not None and signal.getsignal(hup) is not signal.SIG_IGN:
11143+
shutdown_signals.append(hup)
11144+
for sig in shutdown_signals:
1112711145
with contextlib.suppress(NotImplementedError, RuntimeError):
1112811146
loop.add_signal_handler(sig, request_shutdown)
1112911147
registered_signals.append(sig)

tests/test_baseline_mode.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,62 @@ def test_task_key_cannot_escape_baseline_scratch_root(self) -> None:
240240
self.assertIn("task key escapes the baseline scratch root", output)
241241
self.assertIn("0 pass, 0 fail, 1 error of 1 check(s)", output)
242242

243+
def test_baseline_worktrees_honor_the_pinned_worktree_ref(self) -> None:
244+
# Baseline checks must run against the SAME tree the workers will get:
245+
# a manifest pinned to an older commit whose baseline ran at HEAD
246+
# would judge check-craft against the wrong sources.
247+
import asyncio
248+
import contextlib
249+
import importlib.util
250+
import io
251+
252+
spec = importlib.util.spec_from_file_location("ringer_baseline_ref_test", ROOT / "ringer.py")
253+
assert spec is not None and spec.loader is not None
254+
ringer = importlib.util.module_from_spec(spec)
255+
sys.modules[spec.name] = ringer
256+
spec.loader.exec_module(ringer)
257+
258+
with tempfile.TemporaryDirectory() as temp_root:
259+
root = Path(temp_root)
260+
repo = root / "repo"
261+
init_git_repo(repo)
262+
env = os.environ.copy()
263+
env.update({"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
264+
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
265+
sha1 = subprocess.run(
266+
["git", "-C", str(repo), "rev-parse", "HEAD"],
267+
capture_output=True, text=True, check=True,
268+
).stdout.strip()
269+
(repo / "README.md").write_text("changed at head\n", encoding="utf-8")
270+
subprocess.run(
271+
["git", "-C", str(repo), "commit", "--quiet", "-am", "second"],
272+
check=True, env=env, capture_output=True,
273+
)
274+
275+
manifest = ringer.Manifest.from_obj(
276+
{
277+
"run_name": "baseline-pinned-ref",
278+
"workdir": str(root / "work"),
279+
"repo": str(repo),
280+
"worktrees": True,
281+
"worktree_ref": sha1,
282+
"tasks": [
283+
{
284+
"key": "pinned",
285+
"spec": "Placeholder; baseline spawns nothing.",
286+
# Passes ONLY in the first commit's tree.
287+
"check": "grep -q 'hello baseline' README.md",
288+
}
289+
],
290+
}
291+
)
292+
buffer = io.StringIO()
293+
with contextlib.redirect_stdout(buffer):
294+
rc = asyncio.run(ringer.run_baseline(manifest, config=None))
295+
output = buffer.getvalue()
296+
self.assertEqual(0, rc, output)
297+
self.assertIn("1 pass, 0 fail, 0 error of 1 check(s)", output)
298+
243299

244300
if __name__ == "__main__":
245301
unittest.main(verbosity=2)

tests/test_lint.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,50 @@ def test_task_fields_must_be_strings(self) -> None:
8686
with self.assertRaisesRegex(ValueError, r"task key must be a string"):
8787
self.manifest([task])
8888

89+
def test_worktree_ref_defaults_to_head_and_round_trips(self) -> None:
90+
self.assertEqual("HEAD", self.manifest([self.task()]).worktree_ref)
91+
manifest = self.manifest([self.task()], worktrees=True)
92+
self.assertEqual("HEAD", manifest.worktree_ref)
93+
# with_max_parallel must carry the field — it rebuilds the manifest.
94+
obj = {
95+
"run_name": "ref-test",
96+
"workdir": manifest.workdir.as_posix(),
97+
"repo": str(manifest.repo),
98+
"worktrees": True,
99+
"worktree_ref": "bd61708d195d6535b716ec9a8b1974464848b214",
100+
"tasks": [self.task()],
101+
}
102+
parsed = Manifest.from_obj(obj)
103+
self.assertEqual("bd61708d195d6535b716ec9a8b1974464848b214", parsed.worktree_ref)
104+
self.assertEqual(
105+
"bd61708d195d6535b716ec9a8b1974464848b214",
106+
parsed.with_max_parallel(4).worktree_ref,
107+
)
108+
109+
def test_worktree_ref_requires_worktrees(self) -> None:
110+
with self.assertRaisesRegex(ValueError, r"worktree_ref is set but worktrees is false"):
111+
Manifest.from_obj(
112+
{
113+
"run_name": "ref-test",
114+
"workdir": "/tmp/ref-test-work",
115+
"worktree_ref": "bd61708",
116+
"tasks": [self.task()],
117+
}
118+
)
119+
120+
def test_worktree_ref_must_be_a_string(self) -> None:
121+
with self.assertRaisesRegex(ValueError, r"worktree_ref must be a string"):
122+
Manifest.from_obj(
123+
{
124+
"run_name": "ref-test",
125+
"workdir": "/tmp/ref-test-work",
126+
"worktrees": True,
127+
"repo": "/tmp",
128+
"worktree_ref": 123,
129+
"tasks": [self.task()],
130+
}
131+
)
132+
89133
def test_w1_unverifiable_check(self) -> None:
90134
manifest = self.manifest([self.task(check="echo ok && echo done")])
91135
self.assertHasFinding(

0 commit comments

Comments
 (0)