fix(sandbox): retry a spawn whose launcher interpreter is mid-rebuild - #7670
Conversation
52566d3 to
e901c7a
Compare
e901c7a to
731ff02
Compare
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of Every claim in the description that the base tree can confirm, does: the probe latch at First-Principles-Verdict: PASS A reported cron-killing ENOENT blip, fixed at the one choke point every sandboxed spawn shares, with each rider forced by the retry window the fix itself opens. What this change shipsIntent: stop a one-second interpreter-relink blip from recording hard cron failures and auto-pause strikes — a FIX.
The riders pass the tie-breaker test in reverse: the fix alone does not remove the defect safely, because the backoff it adds is a window where Subtractions
[FIRST-PRINCIPLES-REVIEWED] 013aa43 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All the load-bearing claims verify against the base tree: the Design-Verdict: PASS A real transient-failure harm, fixed at the right layer with a narrow discriminator, and every rider (cancel window, overlap guard) is a verified consequence of the retry. Suggestions
[DESIGN-REVIEWED] 013aa43 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've independently traced every load-bearing claim in this PR against the code at the PR commit:
Nothing survives to the 80+ bar, and I could not ground a new defect of my own. No findings. [OPUS-REVIEWED] 013aa43 |
731ff02 to
b8d7be6
Compare
b8d7be6 to
de65e37
Compare
d58103c to
622c61a
Compare
e10a209 to
ffdaa3a
Compare
ffdaa3a to
eab3cac
Compare
eab3cac to
5ca1629
Compare
5ca1629 to
c8a3959
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Verified all four relationship findings at the current head #7414 — the contended statement, and where the boundary now sits Confirmed: both PRs rewrite the same
So the composition for whichever lands second is #8232 — agreed, no behavioural interaction. Noting for sequencing that it has merged and its merge commit is not yet an ancestor of this head, so it meets this branch's next rebase rather than being already under it. #7140 and #7787 — agreed, sequencing only. Both currently read |
wrap_argv prepends sys.executable to every sandboxed argv, and a managed-runtime rebuild unlinks that symlink for ~1s, so all three sandbox spawn wrappers now retry only that ENOENT.
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review: code change verified correct and safe. Applied allow-fork-workflow-change label to clear the fork-workflow-change-guard block on the trusted .github/** path touch. Approving; will re-check CI after the guard re-runs before merging.
Problem / Motivation
wrap_argvprependssys.executableto every sandboxed argv, so that interpreterpath is on the critical path of every sandboxed spawn. When it is a symlink into a
directory tree that can be rebuilt underneath a live process, the path can vanish for
about a second and then come back.
Rebuilding a managed install tree deletes and re-creates its entries, including the
interpreter symlink itself. A spawn landing inside that window dies with ENOENT on a path
that both existed before it and exists after it. Any packaging that relinks an
interpreter in place reaches this: an environment rebuild, a toolchain reinstall, a
swapped container layer.
Observed symptom: three scheduled script jobs sharing one 900-second tick all fired
inside a single rebuild window. Two died with
FileNotFoundError: [Errno 2] No such file or directorynaming the interpreter, and each recorded a hard job failure plus a striketoward auto-pause — for a condition that had already healed by the time anyone looked.
Why it matters
The caller cannot distinguish this from a broken install, so a self-healing one-second
blip is recorded as a real failure. Cron auto-pause is latching and only clears on a
later success, so transient strikes accumulate against jobs that are fine. Because the
window hits every sandboxed spawn at once, jobs sharing a tick fail together, which
reads like a systemic outage rather than a moment of filesystem churn.
What changed (motivation → approach → change)
Root cause is a spawn against a path that is briefly absent, so the fix is to retry that
one spawn rather than to surface it.
1. The retry itself (
sandbox.py).popen_limitedretries on a 0.25 / 0.5 / 1.0 /2.0 s backoff (5 attempts, ~3.75 s total). The loop is inline in
popen_limitedrather than extracted into a helper, and deliberately so: both spawn audits key on
<relpath>::<enclosing function>, so moving thePopeninto its own function migratesits audit key — stranding the
popen_limitedentries in_SYNC_ALLOWEDandBENIGN_SPAWNSas stale while the relocated call reads as a brand-new unrouted spawn.An extraction was tried first and broke four guard tests at once;
TestTheSpawnStaysInsidePopenLimitednow asserts the shape by AST so the regressionfails at source.
The gate is narrow, because ENOENT from
Popenis ambiguous — it is also raised for amissing
cwd, and a genuinely absent user binary must still fail on the first attempt._is_transient_interpreter_enoenttherefore accepts only an ENOENT whosefilenameis
cmd[0]and whosecmd[0]is this process's ownsys.executable. Amissing-
cwdENOENT names the directory and a missing user binary names that binary, soboth fall through untouched.
filenamebeing populated is not guaranteed by everyplatform, so when it is absent the fallback is a live check that the interpreter really
is gone from disk — an observation rather than an assumption that this ENOENT must be
ours.
Two deliberate properties:
a permanently broken install still reports exactly the error it reports today, ~4 s
later. The retry cannot convert a real failure into a different one.
stays visible instead of being silently absorbed.
2. The backoff had to be made cancellable (
cron_script.py). A retry that waitsseconds is a window in which a cancellation was previously lost, not delayed:
kill_running_processkeys on a registered child, and during the backoff noneexists, so it returned
Falseand recorded nothing — the retry then launched the workand the run reported
ok. This rider is the larger half of the diff and is listedexplicitly because it is not implied by "retry a spawn":
_SPAWNING_JOBSmarks a job whose spawn is in flight, sokill_running_processcanrecord a cancel in that window instead of dropping it. That is a semantic change:
it now returns
Truefor a job that is only spawning (one consumer,cron.py).popen_limitedgained anabort_retryhook, consulted after each backoff, whichre-raises instead of spawning when a cancel has landed. It is wired at the two cron
spawn sites. It matters only where cancellation is mediated by a registry keyed on the
live child; a caller holding the
Popenhandle and polling a stop flag (such asauto_improvement.spine.agent_runner, which calls_terminate_groupon the handle)loses nothing by omitting it, and the docstring says so.
_finish_spawnmoves a job from spawning to registered under a single hold of_PROCS_LOCK, so a cancel can never fall between the two states. When a cancel racedthe successful
Popen, the child is live and unregistered, so the caller kills it andreports
cancelled— reporting a stop that did not happen would be worse than theoriginal bug.
_abandon_spawnclears the flag when a spawn produced no child, so itcannot leak into that job's next run.
3. Cron output keeps locale decoding, now declared deliberate. Relocating the two
popen_limitedcalls into theirtry:blocks made the repo'ssubprocess-encodinggatecount them as newly added lines. An earlier revision satisfied that by pinning them with
**UTF8_TEXT(text=True, encoding="utf-8", errors="replace") — that was wrong and hasbeen reverted. A cron runs an arbitrary command, so its output carries the host's
encoding; forcing UTF-8 with
errors="replace"turned every non-UTF-8 byte into U+FFFDbefore the output was persisted and delivered, which is irreversible corruption of the
thing the user asked to see. Both calls therefore read `text=True,
subprocess-encoding: locale` — the gate's own opt-out marker for deliberate locale
decoding — and
TestCommandOutputUsesLocaleDecodingasserts that neither call pinsencodingorerrors. Runtime decoding behaviour is unchanged from the base.Either remedy takes those two calls out of the gate's violation set, so its per-file count
drops the same way, and the gate is a ratchet: the one-line count for
cron_script.pyin.github/subprocess-encoding-baseline.txtmust move4 -> 2or the gate fails with"1 entry to prune". That is the only
.github/**path this PR touches, and it is why theFork workflow-change guardlane is red: it needs theallow-fork-workflow-changelabelfrom a maintainer, and no push can clear it.
4. An overlapping wake of the same job is refused.
_begin_spawnreturns false when thejob is already spawning or registered, and the caller returns without touching the other
run's state. This is required by items 2 and 3 rather than optional: every cancellation
surface here is keyed on the job id alone (
kill_running_processtakes only an id,_RUNNING_PROCSholds one child per job), so two concurrent runs make "cancel this job"ambiguous and whichever finishes its spawn first consumes the flag — a cancel aimed at a run
still in its backoff could be eaten by a rerun that was never cancelled. It also backstops a
documented pre-existing gap: when a deadline fires with a worker already claimed, the
scheduler's own overlap guard clears while the child runs on.
The refusal reports
status: "skipped", not"error", and both cron dispatch pathsintercept it before the strike branch. An overlapping wake is a scheduling condition, not a
job defect, so counting it toward auto-pause would inflate strikes for exactly the transient
class this change exists to stop striking. That matches the disposition of the scheduler's
own pre-existing guard, which logs "previous execution still running, skipping" and returns
without counting anything.
5. A raced-cancel command run reports the child's returncode, not
-1. When a cancelraces a successful spawn the child is killed and the run reported cancelled; it now carries
proc.returncode(the signal that stopped it), matching the existing post-communicatecancellation contract rather than inventing a synthetic value. The spawn-failed case still
reports
-1, because no child exists and there is no signal to report.Scope — now all three spawn wrappers, not one. The named cause is
wrap_argvprependingsys.executableto every sandboxed argv, andspawn_shim_argvputs it atcmd[0]forpopen_limited,run_limited(17 call sites, 15 files) andcreate_subprocess_limited(41 call sites, 26 files) alike — so the exposure was identical and a point fix on one of
three was not honest to the root cause. All three now retry on the same budget, through one
shared discriminator (
_retry_interpreter_enoent): one definition, three call sites, nocopies. The async wrapper backs off with
asyncio.sleep, nevertime.sleep— blocking theevent loop for up to ~3.75s is the exact hazard that wrapper exists to avoid. The final
attempt stays unguarded in every wrapper, so a genuinely broken install reports its original
error, just ~4s later.
abort_retrystays onpopen_limitedalone — it is the onlywrapper with consumers (the two cron spawn sites), and a sibling backoff is therefore not
cancellable: neither sibling hands back a handle a cancellation registry could key on, so the
lost-cancel window the hook closes has no consumer there. An AST test pins that placement.
What deliberately did not move: the three
subprocesscalls themselves stay lexicallyinside their own wrappers. Both spawn audits key on
<relpath>::<enclosing function>, sohoisting any of them into a shared helper would migrate its audit key — stranding the
_SYNC_ALLOWEDandBENIGN_SPAWNSentries as stale while the relocated call read as abrand-new unrouted spawn. An AST test pins each spawn to its own wrapper, and another pins the
async wrapper to an async sleep.
This also cures, rather than defers, the
_shell_is_posix_strictlatch flagged in review:that probe runs through
run_limitedand catchesOSErrorintoresult = False, which itcaches in
_POSIX_STRICT_CACHEfor the process lifetime — so a ~1s blip used to make everylater caller believe the shell expands braces.
FileNotFoundErroris anOSError, so theretry now absorbs the blip before it reaches that clause. A test pins it, with a negative
control (empty delay budget) proving the latch reappears without the retry.
Two deferrals remain, and no tracking issue is filed for them — deliberately, since filing on
the upstream repo would notify its watchers and that is not mine to do; they are recorded here
instead. First, the broader
_POSIX_STRICT_CACHEdesign still caches a permanent failure, soa tree down longer than the ~3.75s budget still latches; only the transient blip is covered.
Second, a child that starts and then fails its own imports mid-rebuild (a transient
ModuleNotFoundErrorfor akiro_crewsubmodule) is a different phase this does not address.Tests
New file
test/test_sandbox_interpreter_enoent_retry.py, 56 tests in fourteen classesplus one end-to-end case:
TestIsTransientInterpreterEnoent— the discriminator, both directions: our owninterpreter retries; a missing
cwd, a missing user binary, and a non-ENOENTOSErrordo not.
TestPopenLimitedToleratesAnAbsentInterpreter— the budget is spent and then the finalunguarded attempt raises; a mid-budget success returns that handle.
TestTheSpawnStaysInsidePopenLimited— AST assertion that thePopencall islexically inside
popen_limited, pinning the audit-key constraint above.TestAbortRetryClosesTheCancellationWindow— an aborting hook stops after one attempt;a
Falsehook does not shorten the retry (negative control).TestSpawnToRegisteredIsAtomic—_finish_spawnregisters and clears atomically; araced cancel is reported and the child deliberately left unregistered so the caller
kills it; a kill inside the spawn window records the cancel, and one outside it records
nothing (negative control);
_abandon_spawncannot leak a flag.TestCommandCronSpawnFailureIsCancellable— the spawn-failure arm consumes a recordedcancel and reports cancelled; without one the run is still an error (negative control).
TestOverlappingRunCannotEatTheCancel— a second run is refused while the first isspawning and while it is registered; a refused rerun leaves the pending cancel intact; a
distinct job is not refused, so this is not a global lock (negative control); the slot is
reusable once the run ends.
TestCommandOutputUsesLocaleDecoding— the command spawn passes neitherencodingnorerrors; forcing UTF-8 with replacement really does destroy the bytes irrecoverably(mechanism, with a positive control); and an AST guard fails at source if either cron
spawn is re-pinned.
TestAnOverlappingWakeCostsNoFailureStrike— a refused overlap reportsskippedratherthan
error, leaves the other run's claim and pending cancel untouched, and bothdispatch paths intercept the status before the strike branch.
TestARefusedOverlapIsNotPersistedAsASuccessfulRun— the refusal setslast_statusand
run_never_startedso_executecannot write a fabricatedokand reset theauto-pause budget; it calls neither
record_failurenorrecord_success(negativecontrol); and a positive control asserts
_executereally does treat a non-errorstatus as success, so the other assertions cannot pass vacuously.
TestRunLimitedToleratesAnAbsentInterpreter— the sibling sync wrapper, same shape asthe
popen_limitedclass: retries mid-budget, re-raises when the budget is spent, doesnot retry a missing
cwdor a missing user binary, and stillreports the caller's own argv in a
CalledProcessError(the retry nests inside thathandler). No
abort_retrycase — the hook is not on this wrapper.TestCreateSubprocessLimitedToleratesAnAbsentInterpreter— the async wrapper: retriesmid-budget, re-raises when spent, does not retry a user-binary ENOENT,
and never calls
time.sleep(asserted directly), because blocking theloop is the hazard this wrapper exists to avoid. No
abort_retrycase here either.TestAllThreeWrappersShareOneDiscriminator— one_retry_interpreter_enoentdefinitionwith three call sites and no inline re-implementation of the predicate (negative
control); an AST assertion that each of the three spawns still lives in its own wrapper,
pinning the audit-key constraint; an AST assertion that the async wrapper sleeps with
asyncio, nottime; andtest_abort_retry_exists_only_on_the_wrapper_with_consumers,which pins the hook to
popen_limitedand asserts its absence on both siblings, so azero-consumer parameter cannot creep back.
TestTheStrictShellProbeNoLongerLatchesOnABlip— a transient blip during the_shell_is_posix_strictprobe no longer caches a wrongFalsefor the process lifetime,with a negative control (empty delay budget) that reproduces the latch.
Gate results on the current head:
mypy src/kiro_crew/with the repo's pinnedmypy==1.14.1:Success: no issues found in 1261 source files. An earlier revision failed this lane oncron_script.py(discardgivenstr | None); a single-file mypy run had reportedclean, so the whole-package command is what catches it.
flake8 src/kiro_crew test conftest.py xdist_budget.py: 0 findings from this change.scripts/check_black_formatting.py,scripts/check_subprocess_encoding.py,scripts/check_sync_io_in_async.py,scripts/scrub-lint.sh: all pass with the changein diff scope. The diff-scoped gates must be run with the upstream base ref; scoped
against a fork
mainthat is behind, the encoding gate reported a pass it should nothave.
test_spawn_preexec_guard.py,test_spawn_audit.py,test_cron_script.py,test_cron_script_more_coverage.pyandtest_sandbox_argv.py; 3329 passed across everytest/*sandbox*,test/*spawn*andtest/*cron*file, which is the scope covering all three wrappers and theslack/gateway.pydispatch change. The 16 spawn-audit tests pass unchanged, confirming noaudit key moved.
Screenshots / video
Why no screenshot: no user-visible surface changes — this touches subprocess spawn
wrappers and cron spawn bookkeeping, and adds a unit test file.
Related Issues
N/A — no tracking issue; found while diagnosing scheduled-job failures.
Pattern harvest
Rule candidate: review-prompt
Pattern: a retry that waits is a cancellation window. Adding a backoff to a spawn whose
cancellation is mediated by a registry keyed on the live child converts "cancel
arrives slightly late" into "cancel is discarded", because during the backoff there is no
child to look up and the canceller records nothing. Widening a retry therefore requires
auditing the cancel path, not just the failure path — and the two state changes
(no-longer-spawning, now-registered) must happen under one lock hold or the same cancel
falls through the gap between them.