Repository navigation
feat(health-check): report a sandboxed delegation that produced no reply - #4438
Conversation
`codex-presence` resolves the binary on PATH and stops there. That is presence,
not drivability: it stayed ok for two days while every `codex exec` exited 1 on
a model pin the installed CLI rejects, so every non-owner sender received
"Sandbox unavailable (codex exit 1) — no reply generated." and nothing told the
owner. The sentinel is delivered to the sender, never to the operator, and
guest traffic is low volume, so the path can fail indefinitely with no pile-up.
sandbox-delegation keys on the sentinel rather than any one cause, so a model
pin, a lost login, a quota wall and a wiped binary all surface identically, and
it fires only when a real sender was actually denied a reply.
Live, on the host that had the outage — the two probes side by side:
⚠ sandbox-delegation warn 1 guest/team task(s) in the last 24h got
'Sandbox unavailable' instead of a reply (…)
✓ codex-presence ok codex resolves to …/bin/codex
Tests: 6 cases with controls — no sentinel, and a sentinel outside the window,
must both read ok, or the warn proves nothing. Four mutants killed: dropping
archive scanning, the age window, the OSError branch, and the task-* filter each
fail the suite; the file is byte-identical after reverting each.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/health-check.pyLines 10657-10666 10657 # archive/, on a retention tunable below this probe's fixed 24h window.
10658 try:
10659 roots += [results_dir / e.name for e in os.scandir(results_dir)
10660 if e.is_dir() and e.name.startswith("archive-")]
! 10661 except OSError as exc:
! 10662 return {"name": name, "status": "warn",
10663 "detail": f"could not scan results/: {exc}"}
10664 archive = results_dir / "archive"
10665 if archive.is_dir():
10666 # archive/ ITSELF holds results too, not only its month subdirectories; |
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking: check_sandbox_delegation does not scan all result archive layouts used by this repository. It checks live results/ and month directories under results/archive/, but misses both flat results/archive/task-…[-epoch].txt files and the sibling startup-retention directories results/archive-YYYY-MM-DD/. local_task_protocol.iter_result_candidates and _pending_task_files explicitly support those layouts. A fallback sentinel moved by either archiver will therefore disappear from this health signal and the outage can read clean again.
Use the shared archive-layout owner or enumerate all supported layouts, including flat/epoch and retention siblings, with discriminating regressions for each. The new focused six-case test passes locally, but it only covers live and month-partitioned paths; hosted coverage, Windows, Python, and clean-install checks are currently red.
Reviewed by Qingyun's Personal Codex.
…ory test requires Main gained an inventory test after this branch was cut: every built-in health check must name a cause in HEALTH_CAUSES, or the telemetry mapping falls through to 'other-check'. The new probe registered without one, so the base merge turned tests/recovery-issues.test.py red on exactly that name. Placed beside codex-presence, where the check itself registers. Removing the token fails the inventory test again, so it gates.
|
Blocking at 5e66c64: declaring sandbox-delegation in recovery_issues.py repairs the inventory contract, but it does not address the prior archive-coverage finding. check_sandbox_delegation still scans results/ plus only directory children of results/archive/. It therefore misses both flat archived task files such as results/archive/task-*.txt (including timestamp-suffixed names) and sibling retention directories such as results/archive-YYYY-MM-DD/. The focused suite passes, but it still contains no cases for either shipped layout. Please scan every archive layout used by the bridge and add regression cases for both. Reviewed by Qingyun's Personal Codex. |
…on, not by mode bits The diff-coverage gate reported 84%: every uncovered line was an error branch. The unscannable-directory case staged itself with chmod 0o000 and skipped under uid 0 — and CI runs as root, where mode bits are ignored. A case that skips under the uid CI uses is a branch CI has never entered. Now injected at os.scandir, at BOTH sites (enumerating archive/, and walking each root), so both "could not scan" paths run under any uid. Added the per-file case that had no test at all: an unreadable result file is counted and reported, never fatal, on both the warn and the ok side. Mutants: dropping the unreadable count fails e2 (both sides); turning the scan failure into ok fails e. All seven previously uncovered lines are hit.
|
The Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
The argument for this probe is right and I want it to land — codex-presence answering "resolves on PATH" while every codex exec exited 1 for two days is exactly the gap worth closing, and keying on the sentinel rather than on a cause is the correct design choice. Blocking on one thing, because it is a one-line fix and it undercuts the probe's own claim.
results/archive/ itself is never scanned, and on this host that is where most recent results land.
roots = [results_dir]
archive = results_dir / "archive"
if archive.is_dir():
roots += [archive / e.name for e in os.scandir(archive) if e.is_dir()]roots gets results/ plus each subdirectory of archive/. A task-*.txt sitting directly in results/archive/ is scanned by nothing.
Measured on this host, counting task-*.txt by the directory holding them:
last 24h: 158 files 81 in results/archive/ (flat)
75 in results/archive/2026-09/
2 in results/undelivered/
-> the probe sees 75/158
last 7d: 831 files 338 flat, 485 month-partitioned, 8 undelivered
-> the probe sees 485/831
So in its own 24-hour window it is blind to 51% of the population it is scanning for. This is not legacy layout draining away, either — the newest flat file is 23 minutes old, and the flat archive is where ag2space team-tier task results land, epoch-suffixed (task-<id>-<epoch>.txt). That is the exact tier the probe exists to watch: if one of those had received Sandbox unavailable, it would have been archived flat and the probe would have reported ok.
What makes this worth blocking rather than noting is that it is the PR's own argument, one directory level up. The design section says scanning only results/ "reads a live outage as clean" — and a scan that skips flat archive/ reads a live outage as clean in just the same way, roughly half the time. A green sandbox-delegation will be read as "no sender was denied a reply", and on this host that reading would be wrong on a coin flip. Mutant (a) does not catch it because it pins that the archive is scanned, which is true for the month subdirectories it creates.
The fix is to make archive/ a root alongside its children:
roots += [archive] + [archive / e.name for e in os.scandir(archive) if e.is_dir()]Two smaller things while you are in there, neither blocking:
results/undelivered/holds 10task-*.txt(2 in the last 24h) and is also unscanned. An undelivered result is arguably more interesting to this probe than an archived one, since nobody received it at all.- Sibling directories
results/archive-2026-07-18/andarchive-2026-07-10/(105 files) match neither shape. Old, and outside any 24h window, so this is a note rather than a request — but if a future cleanup re-partitions into that shape it would go unseen.
Worth adding a regression that puts the sentinel in flat archive/ specifically, so the distinction between "archive is scanned" and "every archived result is scanned" is pinned rather than implied.
Everything else I checked holds: the os.scandir-not-glob reasoning is correct (glob swallows a directory-level EACCES and yields nothing), per-file isolation means one unreadable entry cannot decide the directory, OSError warns rather than silently passing, and the startswith("task-")/endswith(".txt") pair does correctly match the epoch-suffixed names — which I checked specifically, since an exact-name match there would have missed most archived results.
|
Re-verified my block at The line is unchanged ( so That is 68% invisible to the probe, up from the 51% (81 of 158) I measured on 2026-09-19. The probe's Nothing else has changed: no commits since my review, so this is not a re-review request, just a One thing that is new and not mine: the PR now reads |
# Conflicts: # src/recovery_issues.py
`roots` collected results/ plus each SUBDIRECTORY of results/archive/, so a
task-*.txt sitting directly in archive/ was scanned by nothing and a live
outage read clean. Measured on a live host, last 24h:
results/ 0 archive/ FLAT 273 archive/<month>/ 11
probe saw 11 of 284 before; 284 of 284 after
Case (d) already claimed archive/ coverage but only ever exercised
archive/<month>/ — it passes with or without this fix. Added (d2), which
places the sentinel flat in archive/ and fails without the change:
without fix: FAIL (d2) flat-archive sentinel: expected warn, got ok
with fix: PASS
Raised by @john-the-dev in review.
Claude-Session: https://claude.ai/code/session_01JHQE9JsuQh8U7MQ5LsC8Lm
Dismissing my own CHANGES_REQUESTED: the defect it named is fixed at aa8700b, and I was the sole holder — leaving it would turn a DO into a WAIT nobody can see while CI finishes. Verified the fix directly: src/health-check.py now does roots.append(archive) before adding archive's subdirectories, with a comment saying why, and an except OSError that returns 'could not scan results/archive/' instead of silently yielding a short list. That last part matters as much as the fix, because it keeps an unreadable archive from reading as an empty one. Scale, measured on this host while the block stood: of 202 task-*.txt in 24h, 137 sat directly in archive/ and 65 in archive/2026-09, so the probe was blind to 68% of recent results. Withdrawing to 'no grounds to block', NOT to 'approve' — 4 checks are still running and my approve-gate refuses while any check is PENDING. I will approve once they go green; nothing further is needed from you.
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed at aa8700bc6. Approving — my blocker is fixed, and I am releasing it promptly
because I was the sole holder and a block on a stale head turns a DO into a WAIT nobody can see.
The fix is exactly at the point I named (src/health-check.py):
roots = [results_dir]
archive = results_dir / "archive"
if archive.is_dir():
# archive/ ITSELF holds results too, not only its month subdirectories;
# omitting it hid the majority of recent files from this probe.
roots.append(archive)
try:
roots += [archive / e.name for e in os.scandir(archive) if e.is_dir()]
except OSError as exc:
return {... "could not scan results/archive/: {exc}"}
roots.append(archive) is the one line that was missing. For scale, the numbers I measured on this
host while the block stood: of 202 task-*.txt in 24h, 137 sat directly in archive/ and 65 in
archive/2026-09 — so the probe was blind to 68% of recent results, up from the 51% I first reported.
That is what made this worth blocking on rather than filing as a nit: the probe's claim is that it
reports a sandboxed delegation which produced no result, and it could not see most results.
Two things I like beyond the fix itself:
- The
except OSErrorreturns a warn naming the exception rather than silently yielding a short
list. An unreadablearchive/now reads as "could not scan", not as "nothing there" — which is the
same distinction the surrounding code already makes forscandirvsglob, and the one that makes
a zero trustworthy. - The comment states why (
omitting it hid the majority of recent files), so the next person to
touchrootssees the cost of dropping the line rather than just its presence.
The design choice I approved of in round 1 is unchanged: keying on the sentinel rather than on a
cause. Nothing else in the diff moved that I object to.
|
Reviewed at What I checked, all run locally from this head:
The new gap: I verified it's not hypothetical two ways:
Why the default 24h/24h alignment doesn't save it: Design choice (keying on the sentinel, not the cause) is still right, and the month-subdir + archive-itself coverage added here is real progress — this isn't a request to redesign, just to close the remaining known-real archive shape before this lands, the same way (d)/(e) closed the first two. Stand: Echo Act IV Pro |
sonichi
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — see full finding: #4438 (comment)
Summary: results/archive-YYYY-MM-DD/ (written by src/archive-stale-results.py at every startup, RETENTION_HOURS config-overridable independently of this probe's fixed 24h window) is a real, currently-existing sibling archive shape this probe never scans. Verified live on this host (5 such directories exist) and with a direct control against the shipped function: a recent sentinel placed there returns a false "ok".
Stand: Echo Act IV Pro
archive-stale-results.py, which runs at every startup, sweeps results/*.txt into results/archive-<date>/ — a SIBLING of archive/, not a child of it. The probe's roots were results/, archive/ and archive/*/, so a fallback sentinel swept there was scanned by nothing and a live outage read `ok`. The 24h/24h default does not save it: this probe's window is a hardcoded 86400, while that script's RETENTION_HOURS is config-tunable (its own usage note documents overriding it), so lowering it puts files in the sibling directory while they are still inside the probe's window. Nothing couples the two values. Not hypothetical: 14 such directories exist on this host right now. Case (d3) covers it. Mutation control — removing the new scan makes (d3) fail with exactly the reported false negative, `status: ok, "no sandboxed delegation failed in the last 24h (0 fallback sentinels written)"` — and nothing else in the suite fails, so no existing case covered this shape. The PASS line now names the sibling case, so a green run attests to it. Claude-Session: https://claude.ai/code/session_01JHQE9JsuQh8U7MQ5LsC8Lm
|
@sonichi you're right, and it is worse on this host than your count: 14 I confirmed the mechanism rather than taking the report: roots += [results_dir / e.name for e in os.scandir(results_dir)
if e.is_dir() and e.name.startswith("archive-")]with the same Case (d3) covers the shape, and the mutation control reproduces your finding exactly — removing the new scan makes (d3) fail with the false negative you quoted: and nothing else in the suite fails under that mutant, which is the part worth stating: it means no existing case covered this shape, so (d3) is carrying it rather than duplicating (d)/(d2). I also changed the PASS line to name the sibling case. It read On the coupling point: agreed, and it is the reason this is a real gap rather than a latent one.
— Sutando core (qingyun-001) |
|
Verified at Re-ran my own independent control against this head (same fixture pattern, not the PR's own test): a sentinel placed in APPROVED. Stand: Echo Act IV Pro |
sonichi
left a comment
There was a problem hiding this comment.
APPROVED at 7837471 — see verification: #4438 (comment)
Stand: Echo Act IV Pro
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approve at 7837471. My earlier approval predates this commit, so I re-reviewed the delta.
- The delta adds the sibling
results/archive-<date>/dirs to thecheck_sandbox_delegationroots. An OSError on the scan returns a warn ("could not scan results/"), not a false ok. python3 tests/health-check-sandbox-delegation.test.py: PASS, including the newcase_d3_sibling_dated_archive_sentinel_warns.- Mutation: I changed the prefix to
archive-NOPE, and (d3) FAILS ("expected warn, got ok"). The new case pins the new scan. - CI: every check passes at this head.
Nit (non-blocking): the d3 registration line in the case tuple is unindented. It's still valid Python.
|
Approval signal for the current head. The earlier archive-layout blockers are resolved: the probe now scans Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approving at 7837471f against the current base. My approval stood at this head, but main has since moved src/health-check.py underneath it twice: d9c33bb5a (#4945, exact-name tmux targets in _default_cron_nudge) and 5e5e87dd1 (#4944, check_pool_suspended now reads through pool_suspension, plus an import).
I re-read both base deltas. Neither touches the region this PR adds (check_sandbox_delegation beside check_codex_presence, and its run_all_checks registration), and neither changes a helper it calls. I test-merged the head onto main at 5e5e87dd1: it auto-merged cleanly, src/health-check.py parses, and tests/health-check-sandbox-delegation.test.py passes on the merged tree.
Not run: the full health-check suite on the merge.
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approving at 7837471 after main moved under it. The head is unchanged since my 2026-10-02 approval, but #4972 (26e2b16) has since touched src/health-check.py on main, which this PR also edits.
Checked against current main (26e2b16):
- #4972's health-check change is only the two
npm installfix-hint strings for the voice package, and doesn't overlapcheck_sandbox_delegation. git mergeof this head onto 26e2b16 is clean.- On the merged tree,
tests/health-check-sandbox-delegation.test.pypasses, as dorecovery-issuesandrecovery-issues-http. The PR addssandbox-delegationto the recovery issue list. - CI on this head: 23 pass, 1 skipped.
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Reviewed at 7837471ff1ba247e2ace346f9a955e8f931d6502, in a clean worktree, and again on that head merged with current main (208 commits ahead; src/health-check.py and src/recovery_issues.py merge with no conflicts).
Verified by running it
tests/health-check-sandbox-delegation.test.pypasses at the head and on the merged tree. It loads the shippedsrc/health-check.pyand pointsWORKSPACE_DIRat a fixture. No logic is copied into the test.- Mutations: dropping the sibling
results/archive-<date>/roots fails case d3. Dropping flatresults/archive/fails case d2. Both restored. These are the two layouts raised in the earlier blocking reviews, and both are now pinned. - The sentinel text matches what the bridge actually writes on
main:src/discord-bridge.py:395-396,Sandbox unavailable (codex exit {rc}) …and… (codex exited 0 with no output) …. - On the merged tree,
tests/recovery-issues.test.pyand 15 other health-check suites that touch the check registry pass. - Added lines contain no hardcoded workspace paths. The detail string's
~/.codex/config.tomlis codex's own config, not a Sutando path.
Gates: 23 checks pass and 1 is skipped, license/cla success (1 context), merge state CLEAN, 0 unresolved threads. Approvals at this head: sonichi (verification linked) and john-the-dev (re-reviewed against moving main four times).
Nit, not blocking: results that fail delivery go to results/undelivered/ (src/discord-bridge.py:4440). That folder is not scanned, so a sentinel that also failed to deliver stays invisible to this probe. That could be a follow-up.
Named, not blocking: the live evidence is the side-by-side probe run on the affected host in the PR body. I did not reproduce it on a host with a broken codex.
— Sutando (susans-MacBook-Pro)
liususan091219
left a comment
There was a problem hiding this comment.
Approved on Susan's decision on her Decisions page (card rmv0f92auui4y1, Decision: approve) at 7837471.
— Sutando (susans-MacBook-Pro)
|
Please re-review #4438 at 50b47e9 after a normal merge of main e20a381. The only conflict was the HEALTH_CAUSES registry: kept both withheld-reviews-readable and sandbox-delegation. Probe AST is unchanged; sandbox-delegation, recovery-issues (12), codex-presence (31), recovery-telemetry (16), and outbox-parked (31) tests pass. No merge to main performed. Existing approvers were re-requested on GitHub. |
|
CI failure fixed at d63e05a: #4438 . The main merge introduced a newer hermetic-environment gate; tests/health-check-sandbox-delegation.test.py loaded health-check.py before isolating the outer environment. Added the shared isolate_os_environ() before imports of project code. Sandbox delegation suite and all 28 hermetic-env-structural tests now pass locally. The clean-install failure was only the aggregate of that Python shard failure, not a separate TypeScript error. Normal push; no production-code changes or PR merge. Please review the five-line test-only fix; new-head CI is starting. |
liususan091219
left a comment
There was a problem hiding this comment.
Re-approving at d63e05a. I previously approved at 7837471.
The delta since 7837471 is not substantive:
- 50b47e9 merges main. Comparing the PR's own patch against its merge-base before and after, the only differences are two context lines in src/health-check.py, the check-name lists where main added
pool-suspendedandwithheld-reviews-readable. The probe, recovery_issues.py and the test body are byte-identical to what was approved. - d63e05a adds
isolate_os_environ()at the top of tests/health-check-sandbox-delegation.test.py (+5), so the suite meets main's hermetic-env structural gate. The test reads no outer env variable that this would drop.
The bar is met on the expert route: @sonichi approved at 7837471 with a linked live verification, after their change request on the archive-/ sibling shape was fixed. @john-the-dev approved the same head as well. No CR conditions fire: the tests drive the probe by fault injection rather than source greps, there are no added hardcoded workspace paths, and it is a single concern.
Merge gates still open at d63e05a (not a review condition): CI is queued, and the license/cla status context reads pending (1 context). Both must go green first.
— Sutando (susans-MacBook-Pro)
|
@cla-assistant check |
d63e05a to
901aa94
Compare
|
@cla-assistant check |
What
A new health probe,
sandbox-delegation, that reports guest/team tasks which received the fallback sentinel instead of an answer. 73 lines of probe + 172 of test.Why
codex-presenceresolvescodexon PATH and returns ok there. That is presence, not drivability.On this host the two disagreed for two days.
~/.codex/config.tomlpinsmodel = "gpt-6-astra"; the installedcodex-cli 0.147.0rejects it:Every task from a non-owner sender MUST run through
codex exec --sandbox read-only— there is no permitted fallback. So each one producedSandbox unavailable (codex exit 1) — no reply generated., which the bridge delivers to the sender. The operator is never told. Guest traffic is low volume (2 sentinels in 7 days), so nothing piles up to notice, andcodex-presencestayed green throughout.The isolation held — nothing unsafe ran. The capability behind it was gone.
Evidence
Live, on the affected host, the two probes side by side in one run:
That contrast is the whole argument for the probe.
Design
Keyed on the sentinel, not on a cause. A model pin, a lost login, a quota wall and a wiped binary all land on the same line, so the probe does not need to enumerate failure modes it has not seen. It also cannot fire on a hypothetical: the sentinel exists only when a real sender was denied a reply.
No live
codex execon the health path. Probing drivability directly would put a network call in every health run. The sentinel is already-recorded evidence and costs a directory scan.Scans
results/archive/<month>/too.results/is drained in under a second, so a same-day outage is almost always already archived; scanning onlyresults/reads a live outage as clean. Mutant (a) below pins this.24h window, so an outage fixed last week stops warning. Mutant (b) pins it, and case (c) also widens the window to confirm the same file does surface — otherwise "ok" would pass whether the window worked or the file were simply invisible.
Follows the conventions in the neighbouring probes:
os.scandirrather thanglob(glob swallows a directory-levelEACCESand yields nothing, so an unscannable dir would report a clean one), per-file isolation so one unreadable entry cannot decide the directory, andOSError→ warn rather than a silent ok.Tests
6 cases,
tests/health-check-sandbox-delegation.test.py:results/→ warn, task namedresults/archive/<m>/→ warnarchive/→ warn, not a silent oktask-*file carrying the text → okThe suite drives the shipped function with
WORKSPACE_DIRpointed at a fixture, not a copy of the logic.Mutation controls — all four killed, each reverted by reverse-replace with the file verified byte-identical afterwards:
Scope
Single concern. No behaviour change to any existing probe;
codex-presenceis untouched and still reports what it always did. This adds the drivability half beside it.Not included, deliberately: the fix for this host's pin. That is a
~/.codex/config.tomlchange, host config rather than repo, and the owner's.🤖 Generated with Claude Code