fix: complete the keys a killed sibling held, and stop waiting to find out it is dead - #52
Merged
Merged
Conversation
…othing A job that follows one the wall clock killed cannot complete the campaign, and does not report that it failed to. `acquire_running!` already reclaims a lock whose heartbeat is older than `stale_after`, so the stack CAN recover from a master killed mid-key: something just has to keep trying until the lock expires. `run_loop!` stops before that. Its exit test is `max_empty_rounds` rounds with `done == 0`, and a round where every remaining key came back `:lock_busy` counts as empty. With the defaults that is 90 s against a `stale_after` of 600 s. Measured, the tail of a campaign: one key left, held by a `.running` whose owner was killed. | | before | after | |---|---|---| | run_loop! returned after | 5.3 s | 62.1 s | | stale_after | 60 s | 60 s | | work_fn ran on the key | no | YES | | key done at exit | NO | yes | It gave up 55 s before the lock could be reclaimed, left the key undone, and returned a value with no field that could have said so. A busy round no longer counts toward `max_empty_rounds` until `stale_after` has been waited out. That is exactly the quantity that separates the two cases the caller cannot otherwise tell apart: past it the holder is gone and the lock is reclaimable, before it a live sibling is doing the work. `busy` is now in the return, so "everything is finished" is distinguishable from "someone else still has work out". The wait is bounded by `stale_after + 2 * idle_sleep`, so a sibling that keeps refreshing forever is not waited on forever. The loop is not idle during it: every round still calls `run!`, so any key that frees up is taken immediately, and `stop_flag` / `deadline` are checked each round. The cost is holding an allocation up to 11 min instead of 90 s when a LIVE sibling holds the remainder. That is the right side to err on: exiting early and having the sibling then die leaves those keys with nobody to finish them. Three testsets, one per direction. The dead-holder case completes the key and takes longer than `stale_after`. The control, with nothing held, still exits well under `stale_after`, so this is not "always wait". The live-sibling case never steals the key, returns within the budget, and reports `busy > 0`. Verified locally: all 23 files under test/, green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
📚 Docs preview: https://qatlashub.github.io/SweepRunner.jl/previews/PR52/ (updates on each push to this PR) |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Builds on the previous commit, which made `run_loop!` wait `stale_after` out rather than abandon
keys a killed sibling held. That is correct on its own and stays the fallback. This removes the
WAIT, where the answer can be had.
holder_liveness(owner) -> :alive | :dead | :unknown
Measured: a key whose holder is a dead pid on this host now completes in under 30 s with
`stale_after = 600.0`. Before, nothing could distinguish it from a live holder for ten minutes.
`:dead` is returned only on positive evidence. Every error path, every unparseable token, every
holder that cannot be asked about is `:unknown`, and `stale_after` then decides exactly as before.
A false `:dead` hands a live master's key to someone else, which is the one outcome the lock exists
to prevent.
Two sources:
- `/proc/<pid>` when the holder is on THIS host. A recycled pid reads as `:alive`, the safe side.
- `squeue` when the holder carries a Slurm job id AND this process is itself inside an allocation.
The Slurm guard is not defensive programming, it is a measurement. On the development box behind
this package, `/home/souta/.local/bin/squeue` is a wrapper that answers about a REMOTE cluster's
queue, and it lists live jobs there while this process has no SLURM_* variables at all. Any job id
from anywhere else is absent from that queue and would read as `:dead`. Requiring that we are
ourselves a Slurm job means the queue being consulted is demonstrably the one that would have run
the holder.
The queue is read whole and tested for membership rather than asked with `squeue -j <id>`, because
`-j` exits non-zero BOTH for a finished job and for a broken scheduler, and those must not collapse
into one answer. The result is cached for 15 s: this is consulted per contended key and `squeue` is
a scheduler RPC.
SweepRunner now stamps an owner into `.running` and uses the owner-aware `refresh_running!` /
`clear_running!`. It had been calling the two-argument forms, so DataVault 0.8.1's owner-stamped
locks were unused here. The heartbeat's loss detection is no longer best-effort: the previous
existence-based refresh returned `true` against the reclaimer's own file. The comment in `Run.jl`
saying a race-free guarantee needed owner-stamped locks in DataVault is resolved rather than moved.
DataVault's compat floor moves to 0.8.1 for it.
## A defect this surfaced
`_run_affinity!` gave a key back to the queue on `ProcessExitedException` with NO bound, where the
`pmap` path it replaced uses `retry_delays=ExponentialBackOff(; n=2)`. Unbounded, a key that kills
whoever takes it is handed to worker after worker forever. It was invisible while a dead holder's
lock sat until `stale_after`, because only one worker died per round; reclaiming immediately turned
it into an immediate cascade.
Bounded to the same two give-backs, after which the key is logged `:gave_up` and reported `:error`.
Measured on a key that kills every worker: 3 deaths (one dispatch plus two give-backs), `err = 1`,
and the other 23 keys still complete.
The worker-death test modelled a POISON key, which is not what the feature is for. It now models
PREEMPTION: a fuse file makes the death happen exactly once, and the assertion is that the next
worker completes the key inside the same run. The poison case is its own testset, asserting the
bound.
Verified locally: all 24 files under test/, green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le outside an allocation codecov flagged the patch at 77.14% against a target of 87.01%. The gap was the whole Slurm path: `holder_liveness` returns early unless the process is itself inside an allocation, so nothing below that guard ran. The membership rule is the part with real content and it was pure assertion until now: an array task is `12345_7` in the queue while `SLURM_JOB_ID` is `12345`, so a job is alive if ANY of its tasks is. Only the squeue CALL is stubbed, by priming the cache; the decision under test is the package's own. A stubbed empty queue plus no allocation also pins that the guard is what produces `:unknown`, not the absence of jobs. One test forces the cache miss so the real call runs. Its assertion is the safety contract rather than a value: this executes with no scheduler (CI), with a `squeue` that answers about a remote cluster (the development box), and inside a real allocation, and in all three it has to come back with an answer rather than an exception. Each stub restores `_squeue_cache` in a `finally`, so no stubbed queue leaks into another file on the same shard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multi-agent review of this PR found three Critical defects, all of them in the direction the PR body claimed could not happen: "`:dead` is returned only on positive evidence". Each would hand a live master's key to another worker, which is the one outcome the lock exists to prevent. Each now has a regression test. 1. A HOSTNAME beginning with `slurm`. `findfirst(p -> startswith(p, "slurm"), parts)` scanned every field of the owner token, so `slurm-node-01:12345:ab01cd23` had `-node-01` read out of its hostname as a job id, found absent from the queue, and answered `:dead` without ever reaching the pid check. Clusters name nodes `slurm*` routinely. The Slurm field is written by `owner_token` at the FOURTH position and nowhere else, so that is the only position now read. 2. Slurm ARRAY tasks. Slurm gives each task its own raw `SLURM_JOB_ID` (36, 37, 38 ...) while `squeue -o %i` lists them as `<SLURM_ARRAY_JOB_ID>_<SLURM_ARRAY_TASK_ID>` (36_0, 36_1 ...). Stamping the raw id made every task but the first unfindable, and unfindable read as `:dead`. `owner_token` now stamps the id the queue PRINTS. The old test could not have caught this: it hand-wrote the token with the ARRAY id, sharing the implementation's own assumption about what `SLURM_JOB_ID` holds. 3. A `squeue` pointed at a DIFFERENT cluster. It answers successfully and lists none of our ids, so every holder was absent and therefore `:dead`. The queue must now be shown to see THIS process before its silence about anyone else counts as evidence. Two more, Important: 4. `_reap_if_dead!` had no error handling, so an unlink that failed escaped `run!` and took every other key in the round with it. Measured: with one lock in a read-only directory, `run!` threw and 0 of 3 healthy keys were attempted; it now returns with all 3 done. Reaping is an optimisation over `stale_after`, so nothing in it may be fatal. 5. `squeue` had no timeout, on the hot path of every contended key, holding the cache lock, where the binary can be an SSH wrapper. Neither `stop_flag` nor `deadline` is read inside a key, so a stall took the allocation silently. Bounded to 10 s, then killed, then `:unknown`. Also from the review: - `:gave_up` had acquired a second, unrelated source. `_run_affinity!`'s bound is independent of `opts.max_attempts`, so a key could carry `:gave_up` under `max_attempts = 1`, contradicting three docstrings and miscounting for anything aggregating the log by `kind`. Split out as `:worker_died`. - The re-dispatch bound was written twice through unrelated mechanisms (`ExponentialBackOff(; n=2)` and a hand counter). One `_WORKER_DEATH_REDISPATCHES` now feeds both; expressing one bound twice is how the two dispatchers drift apart, which is how the unbounded give-back got in. - `owner_token`'s docstring did not say to call it fresh per acquisition. It is exported and in api.md, and a caller caching it per process would defeat the nonce. - Comments narrating how a trap was found, rather than the trap, are cut. One named a contributor's machine. - `InterruptException` is no longer swallowed by the squeue catch. - Four `.cov` coverage artifacts (528 lines) were committed by a `git add -A`; removed, and `*.cov`/`*.info` added to .gitignore. Test gaps the review named, now closed: - The real fetch-and-parse had NO value-level test: every testset seeded the cache, so the parser never ran. It is now exercised against the shapes `squeue -o %i` actually prints (plain job, running array task, pending array range, throttled array) through a fake `squeue` on PATH. - That also removes a live network call from the suite. The `squeue` on the machine this was written on is `exec ssh -o BatchMode=yes <front> -- squeue`, and CI runs on that machine, so the previous test SSHed to a remote cluster on every run. - `:lock_reaped` was emitted but never asserted. - Slurm evidence outranking the pid on the same host was never exercised: every Slurm test used a foreign hostname, so the two branches were only ever tested apart. - `test_eventlog.jl`'s enumerated-kinds contract was missing four kinds. - The deaths bound asserted a range where the count is structurally exact. Verified by EXIT CODE, not by grepping output: 24 of 24 files under test/ pass. Three times today a grep over test output reported green where the run was red or red where it was noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_by named the wrong reason Two findings from the review of the published 0.6.2, both about a control answering for a condition it was not measuring. `Prerequisite.opts` replaced the dependent stage's `RunOpts` WHOLESALE. `deadline` defaults to `nothing`, so a prerequisite built for the documented reason — raising `stale_after`, because the shared setup is the slow half — ran with no deadline at all. The barrier then waits for a sibling past the end of the allocation that is running it, which is the exact failure the deadline exists to prevent. `stop_flag` and `deadline` are now inherited from the caller when the prerequisite leaves them unset, and still take precedence when it sets them. `stopped_by` was re-read from the clock at return time, in `run!` and again in `run_loop!`. That answers "is a stop condition true NOW", not "why did this stop", and the two differ in both directions: a loop that sleeps `idle_sleep` between rounds crosses the deadline while doing so, so giving up on `max_empty_rounds` was reported as `:deadline` — a retryable answer for a key that cannot be produced; and a flag file removed in the meantime turned a real flag stop into `nothing`. The reason now travels back with the outcome that carried it (`:stop_flag` / `:stop_deadline`) and is recorded where the stop happened. Fixing that exposed a third: the sequential dispatcher `break`s on a stop and emitted NO outcome for the keys it dropped, while `pmap` hands every remaining key back as stopped. The same stop reported a different `stop` count depending on which dispatcher ran. Both now attribute them, and `done + stop + err + busy == length(keys)` holds on either path. Three tests, each mutation-checked: reverting the fix individually turns the intended testset red, and the unmutated control stays green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Prerequisite`'s docstring says to build `keys` by projecting the dependent key space with `ParamIO.project`, "so the two spaces cannot drift apart by hand". Every test in the file passed `DataVault.keys(prep)` instead — the hand-written projection that IS prep.toml — so the recommended recipe had never been executed, and the fixture it would replace was the thing standing in for it. The recipe reproduces the fixture exactly (`Set(projected) == Set(DataVault.keys(prep))`) and runs end to end. A control grows the sweep's N axis and shows the projection follows while the hand-written file does not, so the equality is not one a projection that ignored the spec could also satisfy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the other side A review of the previous commit found that its own fix reopened the failure it was named for. `_merged_opts` inherited `stop_flag`/`deadline` when the Prerequisite left them unset, but let an explicitly set one win outright. A deadline is not a stage preference: it names when Slurm kills this process tree. A Prerequisite built once with a generous ceiling therefore discarded a caller's tighter, freshly computed one, and `run_prerequisite!`'s busy-wait is unbounded except by that deadline. The previous commit's own "control" asserted this as correct, with the caller's deadline ALREADY EXPIRED and the barrier running to completion anyway. It now takes the tighter of the two. `stop_flag` had a second problem: `=== nothing` is not "unset" for that field, because `RunOpts` resolves its default from `ENV["SWEEPRUNNER_STOP_FLAG"]` — which the shipped `submit_slurm.sh` exports for the whole job. So a Prerequisite built for `stale_after` alone carries a flag it never asked for, and that ambient value outranked the flag the campaign was actually configured with: an operator raising the one they know about would never be seen by the barrier. The caller's now governs whenever it has one. Invisible in CI, which does not set that variable. `run_prerequisite!` hardcoded `complete=false` the moment a stop was observed, computing `remaining` on the same line. A setup finished by an earlier run reported `complete=false, remaining=0`, and `run_loop!` gates the dependent stage on exactly that field: a fully provisioned barrier refused to let the work start. Also: `_merged_opts` rebuilds `RunOpts` by name over `fieldnames`, so a field added later cannot be silently reset to its constructor default; the `:flag`/`:deadline` to `:stop_flag`/`:stop_deadline` map is written once in `_stop_outcome` and THROWS on an unmapped reason, rather than relabelling it as a deadline at each of two hand-written ternaries; `_is_stopped` is removed, having become dead; and the two new wall-clock margins are an order of magnitude wider than the work they bound, since CI here is self-hosted and shares the box. Four mutations, four reds against the intended testset, control green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sotashimozono
added a commit
that referenced
this pull request
Sep 22, 2026
…uild, group keys by artifact DataVault 0.8.2's `artifact!` builds an intermediate result once and reuses it. This adds the scheduling half: - `work_fn` throwing `DataVault.ArtifactBusy` (artifact! with wait=false) is a DEFERRAL, not a failure: logged as `:artifact_busy`, no attempt spent, and `run!` re-dispatches the deferred keys once the pass drains (`:deferred_round`, waiting `RunOpts(defer_poll)` after a pass that finished nothing). A key still deferred at stop is counted with `busy`. - `artifact_affinity(vault, name)`: an `affinity` that groups keys by the artifact they need. Compat floors: DataVault 0.8.2, ParamIO 0.4.11. 0.6.3 → 0.6.4 (0.6.3 is #52). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sotashimozono
added a commit
that referenced
this pull request
Sep 22, 2026
…uild, group keys by artifact (#53) * feat: schedule around artifacts — defer a key whose artifact is mid-build, group keys by artifact DataVault 0.8.2's `artifact!` builds an intermediate result once and reuses it. This adds the scheduling half: - `work_fn` throwing `DataVault.ArtifactBusy` (artifact! with wait=false) is a DEFERRAL, not a failure: logged as `:artifact_busy`, no attempt spent, and `run!` re-dispatches the deferred keys once the pass drains (`:deferred_round`, waiting `RunOpts(defer_poll)` after a pass that finished nothing). A key still deferred at stop is counted with `busy`. - `artifact_affinity(vault, name)`: an `affinity` that groups keys by the artifact they need. Compat floors: DataVault 0.8.2, ParamIO 0.4.11. 0.6.3 → 0.6.4 (0.6.3 is #52). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: list artifact_affinity in the API reference Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A job that follows one the wall clock killed cannot complete the campaign, does not report that it failed to, and when it eventually can, waits ten minutes for permission.
1.
run_loop!abandoned keys a killed sibling heldacquire_running!already reclaims a lock whose heartbeat is older thanstale_after, so the stack can recover from a master killed mid-key: something just has to keep trying until the lock expires.run_loop!stopped before that. Its exit test wasmax_empty_roundsrounds withdone == 0, and a round where every remaining key came back:lock_busycounted as empty. With the defaults that is 90 s against astale_afterof 600 s.run_loop!returned afterwork_fnran on the keyA busy round no longer counts toward
max_empty_roundsuntilstale_afterhas been waited out.busyis now in the return, so "finished" is distinguishable from "someone else still has work out". Bounded bystale_after + 2 * idle_sleep, so a sibling that keeps refreshing is not waited on forever.The cost, stated: holding an allocation up to 11 min instead of 90 s when a live sibling holds the remainder. Exiting early and having the sibling then die leaves those keys with nobody to finish them.
2. Ask whether the holder is gone, instead of waiting
A key whose holder is a dead pid on this host completes in under 30 s with
stale_after = 600.0./proc/<pid>:alive, the safe side.squeue:slurmis one of four worker modes, so none of this is load-bearing. Layer 1 completes the campaign on its own in all four; this only removes the wait.SweepRunner also now actually uses DataVault 0.8.1's owner-stamped locks (it had been calling the two-argument forms), so the heartbeat's loss detection is no longer best-effort. Compat floor moves to
0.8.1.3. A defect layer 2 surfaced
_run_affinity!gave a key back onProcessExitedExceptionwith no bound, where thepmappath it replaced usesExponentialBackOff(; n=2). Invisible while a dead holder's lock sat untilstale_after; reclaiming immediately turned it into a cascade. Measured on a key that kills every worker: 3 deaths,err = 1, other 23 keys still complete.Review findings
Five specialised agents plus an independent pass. Every agent that looked at
Liveness.jlreproduced at least one Critical.Critical: three ways
:deadwas answered for a live masterThe original body claimed "
:deadis returned only on positive evidence. Every error path, unparseable token, and un-askable holder is:unknown." That was false three ways.:deadslurmslurm-node-01:12345:ab01cd23had-node-01read out of its hostname as a job id, found absent, and returned:deadwithout reaching the pid check. Clusters name nodesslurm*routinely.SLURM_JOB_ID(36, 37, 38 …) whilesqueue -o %iprints<ARRAY_JOB_ID>_<TASK_ID>(36_0, 36_1 …). Stamping the raw id made every task unfindable.squeuepointed at another clusterFixes: the Slurm field is read only at position 4 (where
owner_tokenwrites it);owner_tokenstamps the id the queue prints; and the queue must be shown to see this process before its silence about anyone else counts.#2 was invisible to the test suite by construction. The test hand-wrote the token with the ARRAY id, sharing the implementation's own assumption about what
SLURM_JOB_IDholds. An oracle that shares the convention under test cannot see it.Important
_reap_if_dead!was fatal. An unlink that failed escapedrun!and took the whole round. Measured: one lock in a read-only directory,run!threw, 0 of 3 healthy keys attempted; now returns with 3 done. Reaping is an optimisation overstale_after; nothing in it may be fatal.squeuehad no timeout, on the hot path of every contended key, holding the cache lock, where the binary can be an SSH wrapper. Bounded to 10 s, killed, then:unknown.:gave_upacquired a second source independent ofopts.max_attempts, contradicting three docstrings. Split out as:worker_died.ExponentialBackOff(; n=2)and a hand counter. Now one_WORKER_DEATH_REDISPATCHESfeeds both, since expressing one bound twice is how the unbounded give-back got in.owner_tokendid not say to call it fresh. Exported and inapi.md; a caller caching it per process defeats the nonce..covartifacts (528 lines) committed by agit add -A. Removed,.gitignoreupdated.Test gaps found, now closed
nothingor aSet, which an always-nothingstub satisfies. Now exercised against the shapessqueue -o %iactually prints, via a fakesqueueonPATH.squeueon the machine this was written on isexec ssh -o BatchMode=yes <front> -- squeue, and CI runs on that machine.:lock_reapedwas emitted but never asserted.test_eventlog.jl's enumerated-kinds contract was missing four kinds.Round 2 — a second review, of this PR
Two more findings from reviewing the published 0.6.2, plus one this PR's own fix exposed. All three
are a control answering for a condition it was not measuring.
Prerequisite.optsreplaced the caller'sRunOptswholesaledeadlinedefaults tonothing, so a prerequisite built for the documented reason — raisingstale_after, because the shared setup is the slow half — ran with no deadline at all. Thebarrier then waits for a sibling past the end of the allocation running it, which is exactly the
failure a deadline exists to prevent.
stop_flaganddeadlineare now inherited from the caller when the prerequisite leaves themunset, and still take precedence when it sets them. They bound the job, not the stage.
stopped_bywas read off the clock at return timeIn
run!and again inrun_loop!. That answers "is a stop condition true now", not "why didthis stop", and the two diverge in both directions:
idle_sleepbetween rounds, crosses the deadline, then gives up onmax_empty_rounds:deadline— retryablenothing:flagThe reason now travels back with the outcome that carried it (
:stop_flag/:stop_deadline) andis recorded where the stop happened.
run!'s docstring promised this already: "so a short stage isattributable without re-reading the clock".
Exposed by that fix: the two dispatchers disagreed on what a stop costs
The sequential path
breaks on a stop and emitted no outcome for the keys it dropped, whilepmaphands every remaining key back as stopped. The same stop reported a differentstopcountdepending on which dispatcher ran. Both now attribute them, and
done + stop + err + busy == length(keys)holds on either path.The recipe the docs recommend had never been run
Prerequisite's docstring says to buildkeyswithParamIO.project"so the two spaces cannotdrift apart by hand". Every test passed
DataVault.keys(prep)instead — the hand-writtenprojection that is
prep.toml. The recipe now runs end to end, with the fixture as its oracle anda control that grows the sweep's axis to show the projection follows where the hand-written file
does not.
Round 3 — a review of round 2
Round 2's fix reopened the failure it was named for, from the other side.
Critical: a stage could still loosen a bound the job set
_merged_optsinheritedstop_flag/deadlinewhen the Prerequisite left them unset, but let anexplicitly set one win outright. A deadline is not a stage preference: it names when Slurm kills
this process tree. A Prerequisite built once with a generous ceiling therefore discarded a caller's
tighter, freshly computed one, and
run_prerequisite!'s busy-wait is unbounded except by thatdeadline.
Round 2's own labelled "control" asserted this as correct:
It now takes the tighter of the two, and that test asserts the opposite.
Important:
=== nothingis not "unset" forstop_flagRunOptsresolves its default fromENV["SWEEPRUNNER_STOP_FLAG"], which the shippedsubmit_slurm.shexports for the whole job. So a Prerequisite built forstale_afteralone carriesa flag it never asked for, and that ambient value outranked the flag the campaign was actually
configured with. An operator raising the one they know about would never be seen by the barrier.
The caller's now governs whenever it has one.
Invisible in CI, which does not set that variable, and only reachable in the deployment this
feature exists for.
complete=falsebesideremaining=0run_prerequisite!hardcodedcomplete=falsethe moment a stop was observed, computingremainingon the same line. A setup finished by an earlier run reported a self-contradictory tuple, and
run_loop!gates the dependent stage on exactly that field: a fully provisioned barrier refused tolet the work start.
Also
_merged_optsrebuildsRunOptsby name overfieldnames, so a field added later cannot besilently reset to its constructor default.
:flag/:deadlineto:stop_flag/:stop_deadlinemap was two hand-written ternaries with athird site re-implementing the precedence rule. It is written once in
_stop_outcomeandthrows on an unmapped reason instead of relabelling it as a deadline.
_is_stoppedis removed, having become dead.here is self-hosted and shares the box.
stop_flagwins againcompletehardcodedfalseagainNot changed, with reasons:
run_loop!keepsstopped_byas "why the loop exited" rather than"was a stop ever observed" (a flag raised and then cleared should not tell a resubmitter to stop);
and the worker-death vs deterministic-failure conflation in the
stopped_by=nothingbucket is apre-existing design question, not a patch-release change.
Verification
24 of 24 files under
test/pass, judged by exit code. Round 2's three fixes were then reverted individually in a disposable copy: each time the intended testset goes red and the unmutated control stays green. Three times during this work a grep over test output reported green where the run was red, or red where it was expected noise; the suite is now judged by whether Julia exits non-zero.Patch bump, 0.6.2 -> 0.6.3.
🤖 Generated with Claude Code