Skip to content

fix: complete the keys a killed sibling held, and stop waiting to find out it is dead - #52

Merged
sotashimozono merged 7 commits into
mainfrom
fix/run-loop-must-not-abandon-busy-keys
Sep 22, 2026
Merged

sotashimozono merged 7 commits into
mainfrom
fix/run-loop-must-not-abandon-busy-keys

Conversation

@sotashimozono

@sotashimozono sotashimozono commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

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.

This PR was reviewed after being declared ready, and was not ready. A multi-agent review found three Critical defects, all in the direction the section below originally claimed was impossible. They are fixed, each with a regression test, and the review is summarised at the bottom. Read that section before the rest.


1. run_loop! abandoned keys a killed sibling held

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! stopped before that. Its exit test was max_empty_rounds rounds with done == 0, and a round where every remaining key came back :lock_busy counted as empty. With the defaults that is 90 s against a stale_after of 600 s.

the tail of a campaign, one key held by a killed master before after
run_loop! returned after 5.3 s 62.1 s
work_fn ran on the key no yes
key done at exit NO yes

A busy round no longer counts toward max_empty_rounds until stale_after has been waited out. busy is now in the return, so "finished" is distinguishable from "someone else still has work out". Bounded by stale_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

holder_liveness(owner) -> :alive | :dead | :unknown

A key whose holder is a dead pid on this host completes in under 30 s with stale_after = 600.0.

source reach
/proc/<pid> the holder is on this host. A recycled pid reads :alive, the safe side.
squeue across hosts, when the holder carries a Slurm job id and this process is itself inside an allocation and the queue can see this process

:slurm is 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 on ProcessExitedException with no bound, where the pmap path it replaced uses ExponentialBackOff(; n=2). Invisible while a dead holder's lock sat until stale_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.jl reproduced at least one Critical.

Critical: three ways :dead was answered for a live master

The original body claimed ":dead is returned only on positive evidence. Every error path, unparseable token, and un-askable holder is :unknown." That was false three ways.

# condition why it answered :dead
1 hostname begins with slurm the Slurm field was found by scanning every token field, so slurm-node-01:12345:ab01cd23 had -node-01 read out of its hostname as a job id, found absent, and returned :dead without reaching the pid check. Clusters name nodes slurm* routinely.
2 any Slurm array task but the first Slurm gives each task its own raw SLURM_JOB_ID (36, 37, 38 …) while squeue -o %i prints <ARRAY_JOB_ID>_<TASK_ID> (36_0, 36_1 …). Stamping the raw id made every task unfindable.
3 squeue pointed at another cluster it answers successfully and lists none of our ids, so every holder was absent.

Fixes: the Slurm field is read only at position 4 (where owner_token writes it); owner_token stamps 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_ID holds. An oracle that shares the convention under test cannot see it.

Important

  • _reap_if_dead! was fatal. An unlink that failed escaped run! 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 over stale_after; nothing in it may be fatal.
  • squeue had 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_up acquired a second source independent of opts.max_attempts, contradicting three docstrings. Split out as :worker_died.
  • One bound, two mechanisms. ExponentialBackOff(; n=2) and a hand counter. Now one _WORKER_DEATH_REDISPATCHES feeds both, since expressing one bound twice is how the unbounded give-back got in.
  • owner_token did not say to call it fresh. Exported and in api.md; a caller caching it per process defeats the nonce.
  • Four .cov artifacts (528 lines) committed by a git add -A. Removed, .gitignore updated.
  • Comments narrating how a trap was found, rather than the trap, cut. One named a contributor's machine.

Test gaps found, now closed

  • The real fetch-and-parse had no value-level test. Every testset seeded the cache, so the parser never ran; the one "real fetch" test asserted only that the result was nothing or a Set, which an always-nothing stub satisfies. Now exercised against the shapes squeue -o %i actually prints, via a fake squeue on PATH.
  • That removes a live network call from CI. The squeue on the machine this was written on is exec ssh -o BatchMode=yes <front> -- squeue, and CI runs on that machine.
  • :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 branches were only 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.

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.opts replaced the caller'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 running it, which is exactly the
failure a 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. They bound the job, not the stage.

stopped_by was read off 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 diverge in both directions:

reported actually
loop sleeps idle_sleep between rounds, crosses the deadline, then gives up on max_empty_rounds :deadline — retryable a key that cannot be produced, and will fail again next allocation
flag file removed after the stop was observed nothing :flag

The reason now travels back with the outcome that carried it (:stop_flag / :stop_deadline) and
is recorded where the stop happened. run!'s docstring promised this already: "so a short stage is
attributable 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, 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.

The recipe the docs recommend had never been run

Prerequisite's docstring says to build keys with ParamIO.project "so the two spaces cannot
drift apart by hand". Every test passed DataVault.keys(prep) instead — the hand-written
projection that is prep.toml. The recipe now runs end to end, with the fixture as its oracle and
a 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_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.

Round 2's own labelled "control" asserted this as correct:

p2 = Prerequisite(work, prep, keys; opts=RunOpts(deadline=time() + 3600))
r2 = run_prerequisite!(p2; opts=RunOpts(deadline=time() - 1), poll=0.01)
@test r2.complete == true          # caller's deadline ALREADY EXPIRED, barrier runs anyway

It now takes the tighter of the two, and that test asserts the opposite.

Important: === nothing is not "unset" for stop_flag

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, and only reachable in the deployment this
feature exists for.

complete=false beside remaining=0

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 a self-contradictory tuple, 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 was two hand-written ternaries with a
    third site re-implementing the precedence rule. It is written once in _stop_outcome and
    throws on an unmapped reason instead of relabelling it as a deadline.
  • _is_stopped is removed, having become dead.
  • 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.
mutation result
explicit deadline wins again RED
prerequisite stop_flag wins again RED
complete hardcoded false again RED
drop the round's own reason at the no-progress exit RED
control, unmutated GREEN

Not changed, with reasons: run_loop! keeps stopped_by as "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=nothing bucket is a
pre-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

…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>
@github-actions github-actions Bot added the bug Something isn't working label Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://qatlashub.github.io/SweepRunner.jl/previews/PR52/

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.49593% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Liveness.jl 92.72% 4 Missing ⚠️
src/Run.jl 93.33% 4 Missing ⚠️

📢 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>
@sotashimozono sotashimozono changed the title fix: run_loop! abandoned keys a killed sibling still held, and said nothing fix: complete the keys a killed sibling held, and stop waiting to find out it is dead Sep 15, 2026
sotashimozono and others added 5 commits September 15, 2026 13:32
…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
sotashimozono merged commit d8a6e5e into main Sep 22, 2026
19 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant