Skip to content

fix: make the check timeout configurable so real build gates can pass - #74

Open
bryfa wants to merge 25 commits into
NateBJones-Projects:mainfrom
bryfa:fix/configurable-check-timeout
Open

bryfa wants to merge 25 commits into
NateBJones-Projects:mainfrom
bryfa:fix/configurable-check-timeout

Conversation

@bryfa

@bryfa bryfa commented Jul 23, 2026

Copy link
Copy Markdown

The defect

CHECK_TIMEOUT_S = 60 is a module constant referenced directly inside Verifier._run_check, with no parameter, no config key, and no manifest field. A task's timeout_s governs only the worker subprocess — it has never applied to the check.

So any check that legitimately runs longer than 60 seconds is killed, regardless of what the task declares. The repo-feature kit is the clearest case: checks/check_repo_feature.py sets timeout=1800 on its own subprocess.run for the build command, so the kit believes it has 30 minutes while the parent gives it 60 seconds.

Reproduced on 71763e6:

task:   timeout_s=2400, check="sleep 65 && echo 'build+test done' && exit 0"
result: elapsed=60.0s  timed_out=True  ok=False  rc=-15 (SIGTERM)
output: [ringer.py] check timed out after 60s

This matters more than a slow build. A killed check produces verdict TIMEOUT, which is written to runs.jsonl and aggregated into the per-(model, task_type) scoreboard. On that scoreboard a slow-but-correct build is indistinguishable from a worker that produced nothing — so the routing signal Ringer exists to produce is quietly wrong for any lane whose check compiles or runs a test suite.

The fix

Resolution order, highest precedence first:

  1. the task's check_timeout_s (new, optional manifest field)
  2. check_timeout_s in config.toml (new, optional install-wide default)
  3. DEFAULT_CHECK_TIMEOUT_S = 60 (unchanged)

Every resolved value is clamped to MAX_CHECK_TIMEOUT_S = 3600 and validated at parse time, so the gate is still guaranteed to terminate — an unbounded check would be an unbounded run.

The repo-feature kit now declares "check_timeout_s": 1800, since its check shells out to a real build/test command. Its README explains when a kit must set this.

Backward compatibility

  • A manifest that does not opt in resolves to exactly 60 — identical behaviour to before.
  • CHECK_TIMEOUT_S is retained as an alias for anything referencing it.
  • Verifier() still constructs with no arguments.
  • Retry behaviour is untouched: TIMEOUT remains a retryable verdict and max_attempts stays 2.

Tests

New tests/test_check_timeout.py, 20 tests covering resolution precedence, parse-time validation, config loading and rejection, actual subprocess enforcement, accurate timeout reporting (the message names the resolved budget, not the constant), and the unchanged retry contract. One test is an unscaled proof that a check surviving 65 real seconds now passes — opt-in via RINGER_SLOW_TESTS=1 so it does not cost a minute on every run.

Suite goes 179 → 199. The three failures present on 71763e6 (test_design_reference ×2, test_scoreboard_page ×1) are unchanged — they are HTML/design-token assertions unrelated to execution; one asserts a hardcoded "Generated July 6, 2026" date string.

Run with:

cd tests && python3.13 -m unittest discover -s . -t .
cd tests && RINGER_SLOW_TESTS=1 python3.13 -m unittest test_check_timeout.SlowRealTimeoutTests -v

CHECK_TIMEOUT_S was a module constant with no override path, so every task's
check command was killed at 60s regardless of what the task declared. A task
with "timeout_s": 2400 whose check runs `dotnet build && dotnet test` was
SIGTERM'd at exactly 60.0s and reported TIMEOUT — a false negative on the
mechanism that decides PASS, and indistinguishable on the scoreboard from a
worker that produced nothing.

Reproduced on 71763e6: task timeout_s=2400, check `sleep 65 && echo ok`
-> elapsed=60.0s timed_out=True rc=-15.

Resolution order, highest first:
  1. the task's "check_timeout_s" (new, optional)
  2. config.toml "check_timeout_s" (new, optional, install-wide default)
  3. DEFAULT_CHECK_TIMEOUT_S = 60 (unchanged)
Every resolved value is clamped to MAX_CHECK_TIMEOUT_S = 3600 and validated at
parse time, so the gate is still guaranteed to terminate.

Backwards compatible: a manifest that does not opt in resolves to 60, exactly
as before. CHECK_TIMEOUT_S is retained as an alias. Verifier() still constructs
with no arguments. Retry behaviour is untouched — TIMEOUT remains a retryable
verdict and max_attempts stays 2.

The repo-feature kit now declares check_timeout_s: 1800, since its check shells
out to a real build/test command; its README explains when a kit must do this.

tests/test_check_timeout.py: 19 tests covering resolution precedence, parse-time
validation, config loading, actual subprocess enforcement, accurate timeout
reporting, and the unchanged retry contract. Plus an opt-in unscaled proof
(RINGER_SLOW_TESTS=1) that a check surviving 65 real seconds now passes.

Suite: 179 -> 199 tests. The 3 pre-existing failures on 71763e6
(test_design_reference x2, test_scoreboard_page x1) are unchanged.
Uncommitted locally since 2026-07-23.

engines/opencode-sandboxed.sh: RINGER_EXTRA_WRITABLE (colon-separated absolute
dirs) widens the writable set. The .NET SDK writes NuGet caches, ~/.dotnet and
MSBuild node state outside the repo; when those writes are denied MSBuild does
not error, it HANGS with no output — undiagnosable from inside a worker. Entries
are passed as -D params like every other path and never interpolated into the
profile text, so the rule-injection guarantee still holds.

docs/MODEL-NOTES.md: codex run record for hospedo phase-5c. The single FAIL was
the check's bug, not the model's — the gate grepped \b85\b while C# writes 85m,
and no word boundary exists between a digit and m, so a correct implementation
could never pass. Recorded so the scoreboard is not read as evidence against
codex on code-feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bryfa and others added 13 commits September 10, 2026 03:37
A run's cost was effectively invisible. `worker_tokens` records a single
step rather than the sum across steps, so the figure Ringer reports — and
every cost estimate built on it — reads far below the truth. Measured on
real tasks the gap is 19x to 40x.

That is not a rounding problem. A fix swarm that cost $41.71 reported as
roughly $3, and was restarted sixteen times over two days by an operator
who had no way to see otherwise. Its most expensive single restart spent
$19.43 and failed 31 of 36 tasks, because the manifest asked every worker
to write a deliverable to a path its sandbox forbids. Nothing noticed the
identical failures, nothing capped the spend, and the run only stopped
when the provider's own key limit ran out — which takes every concurrent
run down with it and cannot tell a productive run from a looping one.

Providers already report the price of each model call in the worker's JSON
stream, so:

- parse_step_costs() sums that per task. It is exact: no price catalog, no
  assumption about the prompt/completion split, and it stays correct when a
  provider discounts or caches. An engine that reports no cost yields None
  rather than zero, so plan-billed work is not silently counted as free.
- `budget_usd` is a hard ceiling, checked the moment each worker exits and
  before its check runs, so a slow verification cannot overshoot it.
- `abort_after_repeated_failures` stops a run when N tasks fail in a row
  with the same signature — the shape an impossible manifest makes.
- lint refuses a spec that orders the worker to write an absolute path
  outside its task directory. An absolute path in expect_files stays legal
  when the check produces it, which is the fix-swarm export pattern, so
  only the spec naming the path is flagged.
- the summary prints per-task and per-run USD alongside step counts.

All three controls default to off; existing manifests are unaffected.

Tests provoke each gate on purpose, including the two cases that must NOT
fire, and parse_step_costs is verified against real worker logs where it
reproduces the provider's figures exactly ($0.463 / $0.093 / $0.068).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
Committed as-is to protect 206 lines of working-tree notes before bringing
the branch up to date with upstream. Content unreviewed and unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
The fork was 21 commits behind, which cost real time: `run --baseline`
(execute every check against the unmodified tree) already existed upstream
and was being done by hand, and `setup_error` -- "an ERROR verdict at 0.0s
carries no diagnostics anywhere the operator looks" -- was diagnosed
manually after hitting exactly that.

All five ringer.py conflicts were additive: this side adds check_timeout_s,
upstream adds max_attempts, redact_spec and the update config. Both kept.
config.sample.toml and MODEL-NOTES.md likewise.

Upstream's test suite then caught a real bug in the local
configurable-check-timeout work: `timeout_s: int = DEFAULT_CHECK_TIMEOUT_S`
binds at import time, so any later change to CHECK_TIMEOUT_S was silently
ignored. Resolved at call time instead, which keeps the per-task override
and makes the install-wide setting actually take effect.

274 tests, 273 pass. The one failure is the contributor audit, which needs
the README '## Contributors' section to name Barry Faassen -- left for
upstream to add when PR NateBJones-Projects#129 merges, since that list is theirs to manage.
The repo was also a shallow clone, so that audit could not run at all until
`git fetch --unshallow`; it now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
Two gaps that made the previous commit's numbers unsteerable rather than
merely incomplete.

A run could not say what a requirement cost. Measured on a real estate, 175
of 178 product tasks were keyed `fix-L13` and similar, so 96% of the money
could not be traced to the work item it served. TaskSpec gains an optional
`ticket`, lint requires it for task types that change a product (a bakeoff
or probe legitimately serves none, so the rule is scoped rather than
universal), and the eval log now records ticket, cost and step count. The
three shipped templates that demonstrate product work gained the field --
they were the first thing the new rule caught.

Codex reports tokens but never a cost, so every task it runs shows as free.
On the same estate that was 797 of 922 tasks, every code review among them,
which made review look free when it was the expensive half. Engines gain an
optional `token_scale` and `price_*_per_mtok`. The scale matters more than it
looks: Codex reports THOUSANDS -- its "tokens used" runs 12-230 across 40
real tasks where OpenCode reports hundreds of thousands for comparable work
-- so an unscaled estimate is off by a factor of a thousand.

An estimate is stored in its own field, never added to measured cost, and
always labelled. With no prices configured the run says plainly that those
tasks carry no cost of any kind and names them, because an unknown cost that
defaults to zero is how a bill becomes a surprise.

7 further tests, including the two cases that must NOT fire: a research or
probe task needs no ticket, and an engine with no prices yields None rather
than 0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
The ticket rule shipped with `dotnet-fix` and `dotnet-feature` hard-coded
into the source. Those are one estate's task-type names. Baking them into a
shared tool is how a shared tool stops being shared, and it would have made
the finding either wrong or invisible for every other user.

The default is now only this project's own documented vocabulary -- code-fix
and code-feature. An estate that coins its own product task types lists them
in config:

    ticketed_task_types = ["code-fix", "code-feature", "dotnet-fix"]

A malformed value is refused rather than quietly ignored, because a setting
that silently does nothing is worse than one that is absent.

Tested through the real config path rather than a hand-built AppConfig: a
setting nothing can reach proves nothing about portability.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
An adversarial review failed this branch on three counts. All three were
right, and all three came from the author verifying their own work.

P1 — `budget_usd` was not a ceiling under parallelism. It was checked only
when a worker exited, so with max_parallel=4 a $6 budget could reach $24
without one check firing. Live costs are now re-read on a timer while
workers run, which closes most of the gap. It does not close all of it and
the documentation no longer pretends otherwise: a task's price is not
knowable before it runs, so nothing can be reserved, and the residual is
whatever parallel workers spend between polls. "Hard ceiling" was a lie with
a number attached; it now says "as soon as the spend is visible".

P1 — `abort_after_repeated_failures` counted ATTEMPTS. `_note_failure` fired
inside the retry loop, so one task failing twice identically reached a limit
of 2 on its own and could stop the whole run. It now fires once the task is
finished, retries included, which is what both the option name and the docs
always said.

P2 — the budget ignored estimated spend entirely, making it useless for the
one case it is most needed: an engine that reports no cost of its own. On
one estate that was 797 of 922 tasks. Enforcement now weighs measured plus
estimated while REPORTING still keeps them apart, because presenting a guess
as a measurement is how a number stops being trustworthy.

Also documented what the failure signature cannot do. It is exit code plus
first printed line, one streak across parallel tasks: two unrelated checks
that both open with "FAIL" share it. A circuit breaker, not a diagnosis.

The tests deserved the review's sharpest finding: the "must not fire" cases
passed against unfixed code, because a rule that does not exist never fires.
Each negative now sits beside its positive in one test, so it fails both
when the rule is missing and when it is too broad. Verified in both
directions -- 4 of the new tests fail against the pre-fix tree, all 22 pass
after.

276 tests, 275 pass; the remaining failure is the contributor audit, which
needs full git history and fails identically on an untouched tree here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
…olicy

Five findings from the second review. Four fixed, one declined with a reason.

NEW, and the worst of them: a run stopped by its budget could exit ZERO. The
stop killed the workers, but `run()` returned success whenever every runtime
that finished happened to pass, without consulting `stop_reason`. A budget
stop could therefore pass a CI gate silently -- the exact shape of failure
this branch exists to prevent. It now returns 1 and says why.

The manifest field comment still read "Hard ceiling" after the README had
been corrected, which is worse than never having fixed it: the two now
disagreed, and a reader believes whichever they find first.

The lint docstring claimed a worker may write in its task directory "and its
assigned temp dir", while the code exempts only the task directory. The temp
path is assigned at run time and is not knowable at lint time, so the doc was
narrowed to what the code actually reasons about rather than the code widened
to a promise it cannot keep.

`ticketed_task_types` now defaults to EMPTY. Shipping `code-fix` and
`code-feature` enabled was still one estate's idea of product work handed to
every installation as a new lint failure it never asked for. The reviewer was
right that "configurable" does not excuse a default policy.

One more negative-only test was paired with its positive, and the count in
the previous message was wrong: 22 methods, not 21.

DECLINED -- the failure signature is exit code plus first printed line, so two
unrelated checks that both open with "FAIL" can collide. Making it precise
means tracking per-check failure families, which defeats the purpose: an
impossible manifest fails IDENTICALLY, and that sameness is the whole signal.
A more specific signature would make every task unique and the breaker would
never fire. It is documented as a circuit breaker rather than a diagnosis,
and the cost of a false trip is a stopped run, not a wrong result.

277 tests, 276 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
…expose

Six findings, five fixed and one previously-declined finding now implemented
after the reviewer rejected the reasoning. It was right to.

P1, and the sharpest catch of the three passes: the cost ESTIMATE was priced
from `runtime.tokens` -- the single-step field this entire change exists to
condemn. Every engine with no measured cost, which is the only kind the
estimate serves, was therefore underpriced by the same 19x-40x factor, and
its budget exposure could sit under the limit while real spend continued.
parse_step_tokens() now sums the stream; the legacy field remains only as a
fallback for harnesses that stream nothing.

`budget_usd` accepted NaN and infinity. NaN is truthy so it started the
watcher, and `spent < NaN` is false, so the run stopped immediately for no
reason. Infinity was a ceiling nothing could reach. Both now refused.

The test file put `if __name__ == "__main__"` in the middle: five of eight
classes were defined after it, so running the file directly executed 15 of 28
tests and silently skipped every one added since. Moved to the end. Direct
execution now runs all 28.

The README still granted a worker its "assigned temp dir" while the code
reasons only about the task directory -- the same doc-contradicts-code shape
as the ceiling wording last pass, in the file I had already corrected once.

PREVIOUSLY DECLINED, now fixed: the failure counter was one streak, so
interleaved parallel failures (A,B,A,B) never reached a threshold even though
A had failed twice identically, and completion order could make unrelated
failures look consecutive. It is now a tally per signature, which answers the
question actually being asked. The reviewer's objection was concrete and the
original defence -- that sameness is the signal -- argued for per-signature
counting rather than against it.

Template ticket examples are neutral placeholders instead of one estate's
`work#...` convention.

277 tests via discovery, 28 in this file directly; the four new ones fail
against the pre-fix tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
Five findings fixed. The one that mattered most was not a bug but an
absence: every test of the budget and the circuit breaker called a helper
and compared numbers. Nothing started the watcher, stopped a run, killed a
worker or exercised a retry -- which is exactly where the previous three
passes found their defects.

tests/test_enforcement_e2e.py runs the real loop with no model involved. An
engine is a binary plus arguments, so a shell that prints step_finish JSON is
a perfectly good worker: the runner reads cost from the log either way. Three
cases -- a budget stop that must also make the run report failure, identical
failures that must stop the manifest early, and a healthy run that must NOT
be stopped. That third one is the point: without it the first two would pass
against a runner that always aborts. The two enforcement tests fail against
upstream and pass here.

Provider cost is untrusted input and was summed unchecked. A NaN makes
`spent < budget` false forever and disables the stop; a negative refunds
exposure the run has really spent. Both are now skipped rather than poisoning
the total. The manifest budget was already validated; the log was not.

The README claimed the breaker counts failures "in a row" while the code now
keeps a tally per signature. The tally is right -- workers finish in
arbitrary order and a streak misses the repetition worth stopping on -- so
the documentation was corrected to match, not the code.

The estimate's fallback comment claimed the legacy token field is broken. For
the harness that actually needs the fallback that is unproven: measured over
619 Codex logs there are zero step_finish events, and its "tokens used"
median sits in the same range as OpenCode's fresh input for comparable work,
so it reads as a task total rather than one step. The comment now says what
is known, what is not, and that token_scale is the per-engine correction.

Remaining estate-specific anecdotes removed from the shared README, sample
config and templates.

285 tests, 284 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
…ate's ledger

Three findings fixed, two declined with reasons that are checkable rather
than preferences.

Configured estimate prices rejected negatives but accepted NaN and infinity,
while feeding the same exposure comparison the budget already validates. A
NaN price makes every comparison false and stops a run for a nonsensical
amount. Now finite-and-non-negative, with the refusals provoked in tests.

The shared source still contained this estate's ledger -- exact dollar
figures, task counts, `work#666`, `fix-L13`, `dotnet-fix`. The README, sample
config and templates had been cleaned two passes ago; ringer.py had not, which
is the same half-finished shape as the ceiling wording. The lessons stay, the
numbers are now general. An upstream reader should not have to read someone
else's invoice to understand a comment.

The breaker's stop message now names the tasks it counted. The signature is
deliberately coarse, so a stop can be a real repeated failure or a collision
between checks that open with the same line -- and the reader could not
previously tell which.

DECLINED, with evidence rather than preference:

- "The signature still conflates unrelated failures." Making it more specific
  defeats the control. An impossible manifest fails identically across tasks
  whose checks differ -- that is exactly the case this exists for -- so adding
  a check-command discriminator would make every task unique and the breaker
  would never fire. The collision is real, is documented, and now names the
  tasks so it is diagnosable. The cost of a false trip is a stopped run, not a
  wrong result.

- "Lint rejects a valid deliverable in the assigned temp dir." That path is
  assigned when the worker starts and carries a random suffix, so it cannot be
  written into a manifest in advance. The rejected case cannot arise.

285 tests via discovery, 30 in the cost-control file directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
…ires

Two additive conflicts in ringer.py: upstream adds engine_bin_diagnostics,
this branch adds ticketed_task_types. Both kept.

The README Contributors entry is not self-promotion but the project's own
enforced rule -- "every community PR that lands in main is credited here,
that's a project rule, enforced by a test" -- and test_contributors fails
without it.

295 tests, all passing. This is the first fully green run on this branch:
the contributor audit needed `git fetch --unshallow` before it could run at
all, and then it was correct to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
RINGER_DIR points every workspace at this checkout, so merging here is what
actually gives nexo, aios, monorepo and sombra-audio a spend meter, a budget
that stops a run, an abort on repeated identical failures, and a lint rule
for deliverables a sandboxed worker cannot write.

One conflict, in the timeout constants. Kept the configurable-timeout block
(which already exports CHECK_TIMEOUT_S as a backwards-compatible alias) and
added BUDGET_POLL_INTERVAL_S beside it, dropping upstream's plain
re-declaration rather than shadowing the resolver.

315 tests, all passing.

Reviewed adversarially over five passes before this merge. Two findings were
declined with reasons recorded in the branch history, and one limitation is
documented rather than fixed: budget_usd stops a run as soon as the spend is
VISIBLE and is not a hard ceiling -- a task's price does not exist until it
has run, so nothing can be reserved. Measured overshoot at 20 parallel lanes:
median $0.09, worst observed $1.12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125QPzJL8qBQsayptsvuPgB
Every control that existed fired after the spend: a meter, a budget, an
abort. This is the half that comes before.

Baseline stops being a flag. `run --baseline` was already shipped and had
never once been used here, which is what a flag people are supposed to
remember is worth. Every `run` now baselines itself first, and refuses the
dispatch on the two shapes that make a task unbuyable: a check that is
already green (green now, green at the end, so it can never tell you the
work happened) and a check that could not be executed at all. A check that
FAILS baseline is the wanted result and dispatches normally -- that is the
whole point of the phase, and refusing on it would refuse every honest
manifest.

The canary is a stop, not a smaller batch. A multi-task run releases its
first task alone, judges it by its own executed check, and only then
releases the rest; a bad verdict marks every remaining task SKIPPED without
spawning. Measured both ways on the same night: a 3-ticket run that did this
caught a design fault on its first task, and a 32-task run that did not lost
a whole round to a fault its first task had already demonstrated.
--canary-confirm adds a human on top, deliberately not by default -- a pause
met on every run becomes a keypress people learn to hit.

Both gates take a REASON to skip, not a bare flag, and print
WAIVED (not proved, not verified). A blank reason is rejected rather than
quietly re-enabling the gate the operator believed they had turned off. The
whole verdict lands in the run record as a `preflight` block, so
"was this checked?" is answerable later instead of remembered.

Two existing tests now waive baseline explicitly: the budget e2e checks
`exit 0` on purpose, and the workdir-escape test needs to reach the runtime
guard the gate would otherwise pre-empt. Both keep testing what they are
named for, and both refusals are pinned independently in the new file.
Every new test was run against the unmodified tree first: all nine fail
there.

Demo keeps its parallel fan-out -- the canary is skipped for it with the
reason recorded, since serialising the first of three workers would hide the
thing the demo exists to show. Its baseline passes cleanly (3 fail, 0 pass,
0 error).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
bryfa and others added 10 commits September 10, 2026 13:31
…deliverable a real refusal

Two findings from the lens review, both about this change.

The record answered "was a canary configured?" but not "was it judged, and
what did it say?" -- and only the second question tells you whether the batch
was released on evidence. The verdict now lands in the run record at all four
points where the canary is actually decided: released, held, waived, and the
single-task auto-skip. It lives on the state writer rather than in Preflight,
which stays frozen and describes only what was decided BEFORE dispatch.

The unreachable deliverable had no demonstrated failure case. The test
provoked the `error` outcome through an escaping task key, which is a
different fault -- the ticket's named case is a deliverable no sandboxed
worker can write, and nothing detected that at all. Baseline now probes
declared absolute `expect_files` against the nearest existing ancestor and
refuses when nothing could be created there. Only absolute paths are probed,
on purpose: a relative deliverable lands in the scratch dir the harness makes
and is always writable, so probing those would refuse nearly every honest
manifest. That complement is pinned too.

The third finding, a failure-counter docstring contradicting its
implementation, is declined as out of scope: `failure_counts` is untouched by
this change and belongs to the cost-control work. It arrived because the
review kit staged the wrong diff -- see the PR thread.

All three new assertions were run against the previous commit first and fail
there. Suite: 327 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
The lens is right that `unwritable_deliverables` cannot establish that a
WORKER can write a path: it probes as the dispatcher, and a sandbox can deny
what `os.access` here calls writable. Widening the code is not available --
proving it needs a spawned worker, and spawning nothing is exactly what makes
baseline free enough to run before every dispatch. So the claim is narrowed
to what the code does.

Naming the layers while correcting it, because the boundary is the useful
part: lint's `worker_unwritable_paths` reasons about sandbox SCOPE and is the
one that matches the measured incident; baseline catches what no process
could write at all; the canary buys whatever neither could know statically,
once rather than once per task. The docstring, the error text and the README
now each say which of the three they are.

Suite: 327 pass, unchanged -- this narrows claims, not behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
…pawning nothing

The docstring promised "Spawn nothing" and the function does spawn things --
every check is a subprocess, and the worktree path launches git helpers. The
guarantee that actually matters, and the one the refusal depends on, is that
no worker starts: no model, no billable token. Claiming more invites a
maintainer to assume there are no side effects at all, when a check can
legitimately export files, which the fix-swarm pattern relies on.

Inherited wording, but this function is rewritten here, so it is corrected
here.

Suite: 327 pass, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
The demo disabled the canary by command name. Nobody asked for it, and
unlike a real waiver it printed nothing -- it was recorded in the run record
and silent on the terminal, which is the difference between a decision and a
default nobody can see. A gate with a third path that the tool takes on your
behalf is the shape this whole change exists to remove.

The justification was that the demo shows parallel fan-out and the canary
serialises the first of its three workers. That is true and it is not worth a
special case: a demo of a path real runs never take teaches the wrong
behaviour, and the canary is now what a real run does. The demo still passes
end to end -- its baseline is clean (3 fail, 0 pass, 0 error) and its first
task writes the file its own check demands.

If the three-at-once visual is wanted back it is one flag away, and that flag
announces itself. Which is the design.

Suite: 327 pass, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
Prove a task can pass before buying it
…atch

This entry was written on 2026-09-10 and deliberately not committed: the file
had uncommitted changes from a concurrent factory run at the time. That run
has since landed, so it goes in now.

The model entry is the small half. The two process lessons are the ones worth
having:

An aggregate scoreboard row cannot separate "the model cannot do this" from
"the harness was broken". glm-5.2's code-fix row read 0.17 first-try over 63
tasks and was used to justify routing a 24-ticket batch elsewhere -- but all
63 came from one sibling run whose dominant failure was a single block of 64
identical missing_expect_files. That is a harness signature. The same model
then went 3/3 on a different factory's pilot. Group failures by run_id and
read the failure mode before routing on the number.

And: deliverables land in the worktree, the CHECK exports them. A scout task
declared its deliverable at an absolute path outside the sandbox; the log
shows the model finding the right answer in four tool calls and then burning
~40 on write, cat >, dd, cp, python, xattr -c and touch against a path it was
never allowed to touch. The sibling fix-swarm tasks were immune only because
their check exported from the check side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
Record what nexo Run 1 taught, including the row that nearly cost a batch
work#1031's canary took runtimes[0] -- manifest order, with no notion of
whether that task looks anything like the batch behind it. Measured the same
day it shipped: a 32-task scout ran 31 tasks on one model and task 1 on a
weaker one left over from an earlier audition. Task 1 failed, 31 tasks were
skipped, and the model they would have used had passed the identical check
first try minutes earlier.

That direction is free and reversible, which is why it was survivable. The
other direction is not: a canary EASIER than its batch passes and releases
work that then fails once per task, reaching the expensive failure THROUGH
the gate meant to prevent it.

Selection is now by (engine, model) -- both, because either alone misleads:
two tasks on one engine may run different models, and one model under a
different engine is a different harness. The most common pair wins, ties
break by manifest order, and a uniform batch therefore keeps its first task
exactly as before.

Being right is not enough if nobody can see it, so the run now says which
task was chosen, what it was moved off, and how much of the batch it speaks
for -- and the record carries the same under preflight.canary.selection. A
batch with no majority still runs its canary (one task's spend is cheap) but
prints that a FAIL there means "this task failed", not "the batch is broken".
That distinction is the whole point: a hold nobody can explain is how a gate
gets waived by reflex.

Five new tests, each run against the pre-fix tree first, where all five fail:
a minority first task, a uniform batch, dominance beating manifest order, the
exact half boundary, and a fragmented batch that warns. Suite 327 -> 332.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
…tually happened

The lens is right that the tests did not exercise it. Every case
differentiated tasks by ENGINE, so the model half of the (engine, model)
pairing was unproven -- and the model half is the one the measured incident
used: one harness, two models, the weaker declared first. Pairing on engine
alone would pick the failing task there and hold the batch, because all its
tasks share an engine.

The gap was not an oversight in judgement so much as a dead end I did not
push past: the first attempt gave two tasks different models on an engine
whose args_template has no {model} placeholder, and ringer correctly refused
it -- "model is set but engine mock has no {model} placeholder, so it would
be silently ignored". I took the refusal as "cannot test this" and moved to
engines. It meant "use an engine that takes a model".

So there is now one: mockm, with {model} ahead of {spec} because the mock
worker reads argv[-1] as its spec, leaving the model arg inert to it. The
new test is the incident in miniature -- weak-model first and failing,
strong-model x3 behind it -- and it fails against the pre-fix tree.

Suite 332 -> 333.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KX9rgZWnE5zAmJmhaBz35S
The canary must resemble the batch it gates, not merely come first
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant