Skip to content

fix(kernel): stop the claim repair from stealing an in-flight claim (#160) - #171

Merged
kjgbot merged 1 commit into
mainfrom
fix/160-claim-repair-race
Sep 5, 2026
Merged

fix(kernel): stop the claim repair from stealing an in-flight claim (#160)#171
kjgbot merged 1 commit into
mainfrom
fix/160-claim-repair-race

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #160. Unblocks #168. Do not merge without an independent signoff at the exact head — this is the exactly-once core.

The bug

CI's left: 2 was two_racing_deliveries_of_one_event_produce_exactly_one_run. It is a real exactly-once violation, not a flake: two racing deliveries of one event each started a run.

claim_event repairs a claim whose run never materialised, testing for it with "no row in runs for the claimed run_id". That predicate is true of two situations it could not distinguish:

  • a run that crashed before registering — nobody will ever spawn it, so the claim must be taken over;
  • a run that is in flight and has not registered yet — taking its claim over starts a second run for one event.

wake.rs makes the window explicit — the claim is written, and only several statements later does the run come into existence:

if self.registry()?.claim_event(&flow_key, &trigger.id, &event_key, &run_id)?.is_some() { ... }
let path = self.run_path(&run_id);
let mut journal = SqliteJournal::create(&path, &run_id, now_ms)?;   // ← run appears only here

So: A claims and proceeds; B's insert is ignored, B reads A's run_id, B finds no runs row because A is mid-window, B judges the claim abandoned, takes it over, and spawns. left: 2.

This explains why #168's sequential restart test passes (A is registered by then), why busy_timeout was necessary but not sufficient (lock contention, not this window), and why one commit both hung and passed.

The fix

The claim now carries the engine's boot id. A claim from this boot is in flight → dedupe. A claim from a previous boot with no registered run is wreckage → repair, exactly as before.

That deliberately preserves the crash recovery the original comment defends — it was guarding a real exactly-once bug of its own, where a claim pointing at a run that could never be resumed made every retry answer "deduped" with no run.

The same-boot rule creates one obligation: a claim this boot takes but cannot turn into a run would strand the event, because every retry inside the process would be told "duplicate" with no run to carry it. So the span from claim to register now releases the claim on its failure path, scoped to the claiming run_id so a release cannot steal a claim another delivery has legitimately taken over.

Existing databases get the column via a guarded ALTER TABLE (CREATE TABLE IF NOT EXISTS is inert against a table that already exists). Pre-existing rows carry '', which matches no live boot id and is therefore treated as a previous boot — which is right: the process that wrote them is gone.

Rejected: a time-based abandonment threshold. It swaps a correctness bound for a timing guess — the same class of mistake as the tick-count window in the allSettled repair.

Evidence

The racing integration test is probabilistic by nature: it only catches the bug when a loser reads inside the winner's window. Measured against a deliberately broken guard (if false && existing_boot == boot_id):

topology runs caught
2 bare threads 5 / 10
2 threads + Barrier 8 / 10
8 threads + Barrier 7 / 10

More racers does not help — SQLite serialises the writes, so the miss is always "winner registered before any loser read". I kept the barrier and 2 racers.

A gate that misses a fifth of its regressions cannot be the only witness, so the rule is also asserted directly in relayflowd-journal, where no scheduling is involved: same-boot dedupes, previous-boot repairs, registered-run dedupes across boots, release frees the event, release is scoped to its run. Two of those fail deterministically under the mutation:

assertion `left == right` failed: a claim held by this boot is in flight, not abandoned
  left: None
 right: Some("run-a")
failures:
    registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage
    registry::tests::releasing_is_scoped_to_the_claiming_run

Mutation applied and restored with hashes checked at each step (8829889b5e9b1a95 → restored), so the gate is verified against a failing witness rather than assumed.

Kernel workspace after: 148 passed, 0 failed, no warnings (143 before, 5 added).

Review notes

Worth an adversarial look at two things specifically:

  1. Is per-Engine the right granularity for the boot id? Two Engines in one process are two "boots" here. That is what makes a drop-and-reopen restart test behave like a real restart, but it does mean two engines sharing a data dir in one process can repair each other's in-flight claims. No code path does that today outside tests.
  2. The release-on-failure path. It fires only before register succeeds; after that the run is discoverable and a later failure is a run to resume, not a claim to hand back. Check I have the boundary in the right place.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

kjgbot pushed a commit that referenced this pull request Sep 5, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 8 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 0652e4e9-4e30-465a-ae4d-0c86c4c42344

📥 Commits

Reviewing files that changed from the base of the PR and between 350dc5a and 128a6ef.

📒 Files selected for processing (2)
  • kernel/relayflowd/src/engine.rs
  • kernel/relayflowd/tests/event_wake.rs
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: b03e8d11-f9ba-4aaa-ad43-ff7322513cbe

📥 Commits

Reviewing files that changed from the base of the PR and between dfc638e and 350dc5a.

📒 Files selected for processing (6)
  • kernel/relayflowd-journal/src/lib.rs
  • kernel/relayflowd-journal/src/registry.rs
  • kernel/relayflowd-journal/src/subscriptions.rs
  • kernel/relayflowd/src/engine.rs
  • kernel/relayflowd/src/engine/wake.rs
  • kernel/relayflowd/tests/event_wake.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The registry now enforces WAL mode and tracks engine boot IDs for event claims. Same-boot unregistered claims remain duplicates, while prior-boot claims can be repaired. Failed run registration releases claims. Concurrency tests verify exactly-once delivery.

Changes

Event deduplication lifecycle

Layer / File(s) Summary
Registry storage and claim lifecycle
kernel/relayflowd-journal/src/lib.rs, kernel/relayflowd-journal/src/registry.rs
The registry retries WAL activation, reports non-WAL mode, migrates boot_id, distinguishes same-boot and prior-boot claims, and adds scoped release_claim.
Registry migration and claim validation
kernel/relayflowd-journal/src/registry.rs, kernel/relayflowd-journal/src/subscriptions.rs
Tests cover migration, boot-aware deduplication, claim repair, registered-run retention, scoped release, and updated claim_event calls.
Engine boot identity
kernel/relayflowd/src/engine.rs
Each Engine constructor creates a fresh ULID boot identifier and exposes it within the parent module.
Wake claim and recovery flow
kernel/relayflowd/src/engine/wake.rs, kernel/relayflowd/tests/event_wake.rs
Event wake passes the boot ID, releases claims after pre-registration failures, and tests concurrent delivery with one registered run.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EngineWake
  participant Registry
  participant RunRegistration
  EngineWake->>Registry: claim_event with boot_id
  Registry-->>EngineWake: claim or deduplicated result
  EngineWake->>RunRegistration: create and register run
  RunRegistration-->>EngineWake: success or failure
  EngineWake->>Registry: release_claim after failure
Loading

Poem

A rabbit records each boot
Claims follow the proper route
Old claims yield when boots renew
Failed runs release claims too
One event starts; one waits true


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@kjgbot

kjgbot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the evidence table above, and a flake I shipped

Two things in the opening description are now wrong. Both were caught by stress-testing my own change rather than trusting one green run.

1. The racing test was flaky, and my "148 passed, 0 failed" was one lucky run

Run 25 times, it failed 15 times with open run registry: database is locked. The test introduces the first genuinely concurrent access in the suite, and Registry::open could not survive it — every registry() call opens its own connection.

Two wrong fixes before the right one:

  1. PRAGMA busy_timeout at the end of the pragma batch → still 34/50 failures.
  2. Moved to the front, theorising the WAL switch ran first and was uncovered → still 36/50.

Instrumenting the batch statement-by-statement named journal_mode every time. SQLite takes an exclusive lock to change journal mode and does not invoke the busy handler for it, so busy_timeout cannot cover that statement however early it is set. I should have probed before the second guess.

The WAL switch now retries on a bounded loop and then verifies the mode, returning a new RegistryNotWal error otherwise — swallowing it would leave the registry in rollback-journal mode silently, which is the silent fallback this codebase refuses. busy_timeout is kept for ordinary statements, where it does apply.

50 of 50 racing runs pass now, 0 lock failures.

2. The kill-rate table was contaminated — the real number is much worse

With the lock noise gone I re-measured, separating assertion failures from lock failures:

caught by assertion lock noise missed
previously reported "8 / 10"
actual, 30 runs 3 0 27

Most of what I counted as "the test caught the bug" was database is locked. The racing test catches the seeded bug about 10% of the time, not 80%.

That does not change the fix, but it changes what the evidence is worth. The racing test stays because it exercises the real path and cannot false-positive — but it is not the gate, and its comment no longer claims to be. The deterministic registry::tests are the gate: they assert the same rule with no scheduling involved and fail under the mutation every time.

The boot_id fix itself is unchanged and still verified by those deterministic witnesses.

Kernel workspace: 148 passed, 0 failed, no warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

@kjgbot

kjgbot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Status: signoff obtained, merge blocked on #174 — not on anything in this PR.

Condition 1 — independent signoff: met. The cloud review-swarm gate cannot run at all (it invokes agent-relay and no step installs it; exit 127 — a defect common to every flows PR), so I ran the repo's own local 3-lens preswarm review against this head instead:

PRESWARM_maintainability: REVIEW_PASSED
PRESWARM_history:         REVIEW_PASSED
PRESWARM_structure:       REVIEW_PASSED

It earned it. Across iterations the lenses caught, and I fixed:

  • this branch reverting fix(gate): make the review-swarm scripts executable #172's executable bit on all three gate scripts (cut before fix(gate): make the review-swarm scripts executable #172 landed — a clean merge that undoes someone else's fix);
  • an unindented IIFE around the release guard, now the named spawn_claimed_run whose doc states that register is deliberately last;
  • a comment still asserting the registry repairs any unregistered claim, which stopped being true in this very PR;
  • a surviving 8 in 10 measurement in registry.rs after I had corrected the same figure in event_wake.rs — the branch carried both numbers at once;
  • a false scope claim about which files consume sdk/dist.

Condition 2 — green CI: not met, and not because of this change. Three runs were cancelled at ~25-30 minutes with the kernel step hung:

test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli has been running for over 60 seconds
##[error]The operation was canceled.

The same test hangs on feat/gate2-wake-context (#168), which changes registry.rs by 0 lines and shares no code with this PR. Filed as #174.

I also chased and rejected one hypothesis honestly: my first read was that busy_timeout armed before an attempt-bounded WAL retry could stall an open for 50 × 5s. That is a real hazard and the reorder is kept on its own merits (crash_resume now runs 37.9s locally against main's 38.8s in CI), but it is not the cause — the hang survived it.

Not merging. The signoff is real, but this is the exactly-once core and I have no green CI run. Earlier tonight I reported this branch green off a single lucky local run and it turned out to ship a flaky test; merging on a weaker basis than that would be the same mistake twice. #174 is the gate.

Local evidence at this head: kernel workspace 149 passed, 0 failed, no warnings; racing test 30/30; crash_resume 34/34.

kjgbot pushed a commit that referenced this pull request Sep 5, 2026
#174. A run SIGKILLed early was unrecoverable: its journal sat on disk,
complete and resumable, and `resume` refused it forever.

`Engine::start` creates the journal, appends RunSpawned, and registers
the run LAST. A crash in that window leaves a journal with `seq=1
RunSpawned` and no `runs` row. `server.rs` answered `run_not_found` on
the missing row alone, turning a recoverable run into a lost one. That
is a durability hole, not a lookup miss.

The journal is the authority and the registry is an index over it, so
the index is repaired from the authority: on a missing row, open the
run's journal and, IF IT SAYS IT IS THIS RUN, register it and continue.

That second condition is load-bearing. `SqliteJournal::open` does not
verify whose journal it opened -- it reports whatever run id the file
carries. Adopting on a successful open alone would register a
well-formed journal for run A sitting at `runs/B.sqlite3` as B,
accepting a foreign file on the strength of its filename. DRIVE-LOG
WP-12/F7 records filesystem-derived run existence being deliberately
replaced with registry-owned lookup for exactly that reason, so the id
comparison is what keeps this a repair of the index rather than a
reopening of that hole. An earlier revision of this change omitted it,
and claimed in its message that foreign files were refused; two review
lenses caught both the gap and the false claim, and this commit is
squashed so no message survives describing code that was never written.

Three tests now state the rule together: refuse a file that is not a
journal (pre-existing), refuse a journal that is not this run's (new),
adopt the one that is (new).

`Engine::run_path` is widened to `pub(crate)` and owns the
`runs/{id}.sqlite3` convention, so the repair path cannot drift from
every other opener.

How this surfaced: four #174 occurrences looked like a 25-30 minute hang
and produced nothing but a test name. #175 bounded the protocol read and
#176 dumped daemon-side state; the first CI run carrying both showed the
resumed process had exited INSTANTLY with `run_not_found` while the test
waited 60s for a dispatch from an already-dead process. It looked
runner-only because the test kills as soon as the journal shows zero
completed steps -- the earliest possible instant, squarely inside the
window. Locally `register` wins that race nearly always; the window is
real everywhere and a loaded runner merely samples it.

Same shape as #160, which #171 fixes for the event-claim path: writing
the index after the fact leaves a window where a run exists in one store
and not the other. Here the cost is durability rather than exactly-once.

Verified:
  * replacing the id comparison with `true` fails
    run_resume_refuses_a_valid_journal_that_belongs_to_another_run while
    the other two pass (sha256 dba64b31 -> 4f0bdeb3 -> restored dba64b31)
  * making adoption return run_not_found fails
    run_resume_adopts_a_real_journal_whose_registry_row_is_missing with
    the literal pre-fix error, while the orphan-file test still passes
  * workspace 144 passed, 0 failed, no warnings
  * CI `linux-x64-artifact` green, with crash_resume 34 passed in 39.4s
    -- the suite that had been hanging all night

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot added a commit that referenced this pull request Sep 5, 2026
…ng (#177)

Fixes #174. A run SIGKILLed early was unrecoverable: its journal sat on disk, complete and resumable, and `resume` refused it forever.

`Engine::start` registers the run last, so a crash between the RunSpawned append and the register leaves a journal with no `runs` row. `server.rs` answered `run_not_found` on the missing row alone. The journal is the authority and the registry an index over it, so the index is now repaired from the authority — on a missing row, open the run's journal and, if it says it is this run, register it and continue.

The id comparison is load-bearing: `SqliteJournal::open` reports whatever run id the file carries, so adopting on a successful open alone would register a journal for run A sitting at `runs/B.sqlite3` as B. DRIVE-LOG WP-12/F7 records filesystem-derived run existence being deliberately replaced by registry-owned lookup for that reason. Two review lenses caught an earlier revision that omitted the check and claimed otherwise in its message; the branch was squashed so no commit survives describing code that was never written.

Three tests state the rule together: refuse a file that is not a journal, refuse a journal that is not this run's, adopt the one that is.

Evidence at the merged head 3f84b2e:
- independent signoff: local 3-lens preswarm, maintainability / history / structure all REVIEW_PASSED
- CI: `linux-x64-artifact` run 33969935112 success on 3f84b2e, with crash_resume 34 passed in 39.4s — the suite that had hung and been cancelled on four previous runs
- mutation: replacing the id comparison with `true` fails the foreign-journal test; making adoption return `run_not_found` fails the adoption test with the literal pre-fix error. sha256 dba64b31 -> 4f0bdeb3 -> restored dba64b31
- workspace 144 passed, 0 failed, no warnings

The `review` check is red for a reason unrelated to this change and common to every flows PR: the gate invokes `agent-relay` and no step installs it (exit 127).

Unblocks #171 and #168, which had no green CI run because of this.
@kjgbot
kjgbot force-pushed the fix/160-claim-repair-race branch 2 times, most recently from d1fbbae to 986c245 Compare September 5, 2026 14:08
Two racing deliveries of one event each started a run (#160). CI caught
it as `left: 2`; it is an exactly-once violation, not a flake.

`claim_event` repaired a claim whose run never materialised, testing for
that with "no row in `runs` for the claimed run_id". That predicate is
true of two situations it could not tell apart: a run that CRASHED
before registering, which nobody will ever spawn, and a run that is IN
FLIGHT and has not registered yet. `wake.rs` makes the window explicit
-- the claim is written, and only several statements later does
`SqliteJournal::create` bring the run into existence -- so a second
delivery landing in that gap judged the first abandoned and spawned its
own run.

The claim now carries a boot id. A claim from THIS boot is in flight and
dedupes; one from a previous boot with no registered run is wreckage and
is repaired, preserving the crash recovery the original comment defends.

**The boot id identifies the PROCESS, not an `Engine`.** This is the
whole of the fix and an earlier revision got it wrong: it generated the
id per `Engine`, which made the change inert under the only topology
that matters. The server builds a fresh `Engine::with_runtime` inside
`handle_request`, so two concurrent `event.submit` calls hold two
different `Engine`s over one data dir; with per-`Engine` ids the second
delivery would still treat the first's live claim as wreckage and spawn
a duplicate. A review lens caught it, and it also caught that the test
shared one `Engine` and therefore passed for the wrong reason.

Both are fixed. The racing test now builds a separate `Engine` per
racer, which is what production does. And because that test is
probabilistic -- against a per-`Engine` id it caught the bug only 2
times in 20, since the winner usually registers before any loser reads
-- the property is also asserted deterministically in
`every_engine_in_this_process_shares_one_boot_id`.

The same-boot rule creates one obligation: a claim this boot takes but
cannot turn into a run would strand the event. The span from claim to
`register` is therefore a named method, `spawn_claimed_run`, whose
failure path releases the claim, scoped to the claiming run_id.

Concurrency also had to be made to work at all, which is a separate
question from who owns a claim:

  * `Registry::open` had no busy timeout, so concurrent deliveries
    failed with `database is locked` rather than waiting.
  * `PRAGMA journal_mode = WAL` does NOT honour the busy handler, so the
    timeout cannot cover it however early it is set. It retries
    explicitly and then VERIFIES the mode, returning `RegistryNotWal`
    otherwise rather than running in rollback-journal mode silently.
  * The timeout is armed AFTER the switch: the retry is bounded in
    attempts, not wall-clock, so arming it first let one open block
    50 x 5s.

Rejected: a time-based abandonment threshold, which swaps a correctness
bound for a timing guess. Deferred and tracked: a panic between claim
and `register` strands the event for the life of the boot (#173).

Mutation witnesses, which are cited instead of test totals because
totals drift with the base and a witness does not:

  * `new_boot_id` returning a fresh id per call fails
    `every_engine_in_this_process_shares_one_boot_id` deterministically:
    "two Engines in one process must share a boot id, or concurrent
    deliveries repair each other's live claims"
  * `if false && existing_boot == boot_id` fails
    `a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage` with
    `left: None, right: Some("run-a")`

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
@kjgbot
kjgbot force-pushed the fix/160-claim-repair-race branch from 986c245 to 128a6ef Compare September 5, 2026 14:18
@kjgbot
kjgbot merged commit 4e0df51 into main Sep 5, 2026
3 of 4 checks passed
@kjgbot
kjgbot deleted the fix/160-claim-repair-race branch September 5, 2026 14:26
kjgbot pushed a commit that referenced this pull request Sep 5, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 5, 2026
#173, deferred from #171 on purpose so it would get its own review.

`claim_event` tells every concurrent delivery that an event is a
duplicate on the strength of a claim row, so whoever takes that claim
owes the event either a registered run or the claim back. The `Err` path
handed it back; a PANIC did not. An unwind passes every match arm, and a
claim leaked that way survived for the life of the boot -- each later
delivery answered "duplicate" by the same-boot rule, with no run to carry
the event. Only a restart cleared it, because the claim then belonged to
a previous boot and was repaired.

A `Drop` guard covers both exits. It is disarmed once `register` has
succeeded, after which the run is discoverable and the claim is
permanent. The explicit release on the `Err` path is gone, replaced by
`spawned?` -- it was doing the guard's job, and two mechanisms for one
obligation is how they drift apart.

First `Drop` impl in the crate, so the trade is stated in the code: it
cannot return an error or be `?`-ed, so a failed release is reported via
`eprintln!` (the convention already used in `server/liveness.rs`) and
swallowed. That leaves the event exactly where a leak would have left it,
no worse, and never masks the error that caused the unwind. It must not
panic -- panicking in `Drop` during an unwind aborts the process, turning
a stranded event into a dead daemon.

Evidence, captured by running the commands rather than described:

  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  df3bd489a76431212faa826bafe00e8e022048b17382a4661f940ba23cdbda4f

  $ cargo test -p relayflowd --lib claim_guard
  test ...::a_disarmed_guard_leaves_the_claim_alone ... ok
  test ...::a_guard_only_releases_its_own_run ... ok
  test ...::an_armed_guard_releases_the_claim_when_dropped ... ok
  test ...::a_panic_between_claim_and_register_still_releases ... ok
  test result: ok. 4 passed; 0 failed; 25 filtered out; finished in 0.02s

  # MUTATION: Drop made a no-op -- `if true || !self.armed`
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  5ca06eb140fe77615e78524d8b5e546eec19cfbbb4a5dba78f68fd965695df4d
  $ cargo test -p relayflowd --lib claim_guard
  test ...::a_guard_only_releases_its_own_run ... ok
  test ...::a_panic_between_claim_and_register_still_releases ... FAILED
  test ...::an_armed_guard_releases_the_claim_when_dropped ... FAILED
  test ...::a_disarmed_guard_leaves_the_claim_alone ... ok
  "a claim leaked by a panic strands the event until the process restarts"
  "a dropped armed guard must hand the event back"
  test result: FAILED. 2 passed; 2 failed; 25 filtered out

  # RESTORED
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  df3bd489a76431212faa826bafe00e8e022048b17382a4661f940ba23cdbda4f

Exactly the two tests that should fail do, while the disarmed and
wrong-run cases keep passing -- so they pin the release without also
pinning its absence. The panic case runs through `catch_unwind` and
asserts the panic actually happened, so it cannot pass by never
unwinding.

Known coverage boundary, stated rather than glossed: the panic test
constructs `ClaimGuard` directly instead of injecting a panic through
`submit_event`, so it pins the guard's contract, not its placement. The
placement is pinned by reading -- armed immediately after the claim,
disarmed only after `spawn_claimed_run` returns Ok -- and injecting a
panic into production code would need a test-only seam in the span this
change exists to protect.

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 5, 2026
#173, deferred from #171 on purpose so it would get its own review.

`claim_event` tells every concurrent delivery that an event is a
duplicate on the strength of a claim row, so whoever takes that claim
owes the event either a registered run or the claim back. The `Err` path
handed it back; a PANIC did not. An unwind passes every match arm, and a
claim leaked that way survived for the life of the boot -- each later
delivery answered "duplicate" by the same-boot rule, with no run to carry
the event. Only a restart cleared it, because the claim then belonged to
a previous boot and was repaired.

A `Drop` guard covers both exits. It is disarmed once `register` has
succeeded, after which the run is discoverable and the claim is
permanent. The explicit release on the `Err` path is gone, replaced by
`spawned?` -- it was doing the guard's job, and two mechanisms for one
obligation is how they drift apart.

First `Drop` impl in the crate, so the trade is stated in the code: it
cannot return an error or be `?`-ed, so a failed release is reported via
`eprintln!` (the convention already used in `server/liveness.rs`) and
swallowed. That leaves the event exactly where a leak would have left it,
no worse, and never masks the error that caused the unwind. It must not
panic -- panicking in `Drop` during an unwind aborts the process, turning
a stranded event into a dead daemon.

Evidence. Commands were run from the repository root; `cargo` needs
`cd kernel` first, since the workspace manifest lives there. The block
below is the captured output, unabridged:

  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  df3bd489a76431212faa826bafe00e8e022048b17382a4661f940ba23cdbda4f  kernel/relayflowd/src/engine/wake.rs

  $ cd kernel && cargo test -p relayflowd --lib claim_guard   # baseline
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok
  test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.02s

  # MUTATION: Drop made a no-op -- `if true || !self.armed`
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  5ca06eb140fe77615e78524d8b5e546eec19cfbbb4a5dba78f68fd965695df4d  kernel/relayflowd/src/engine/wake.rs
  $ cargo test -p relayflowd --lib claim_guard
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... FAILED
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... FAILED
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  a claim leaked by a panic strands the event until the process restarts
  a dropped armed guard must hand the event back
  test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.01s

  # RESTORED
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  df3bd489a76431212faa826bafe00e8e022048b17382a4661f940ba23cdbda4f  kernel/relayflowd/src/engine/wake.rs
  $ cargo test --workspace   # tail
  test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
  test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Exactly the two tests that should fail do, while the disarmed and
wrong-run cases keep passing -- so they pin the release without also
pinning its absence. The panic case runs through `catch_unwind` and
asserts the panic actually happened, so it cannot pass by never
unwinding. The workspace tail is the last four result lines of
`cargo test --workspace`; the full run is 156 passed, 0 failed.

Known coverage boundary, stated rather than glossed: the panic test
constructs `ClaimGuard` directly instead of injecting a panic through
`submit_event`, so it pins the guard's contract, not its placement. The
placement is pinned by reading -- armed immediately after the claim,
disarmed only after `spawn_claimed_run` returns Ok -- and injecting a
panic into production code would need a test-only seam in the very span
this change exists to protect.

Two earlier drafts of this message were rejected by the history lens:
the first described the evidence instead of showing it, and the second
abbreviated cargo's test paths as `test ...::name` while calling the
block literal. Both were fair.

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 5, 2026
#173, deferred from #171 on purpose so it would get its own review.

`claim_event` tells every concurrent delivery that an event is a
duplicate on the strength of a claim row, so whoever takes that claim
owes the event either a registered run or the claim back. The `Err` path
handed it back; a PANIC did not. An unwind passes every match arm, and a
claim leaked that way survived for the life of the boot -- each later
delivery answered "duplicate" by the same-boot rule, with no run to carry
the event. Only a restart cleared it, because the claim then belonged to
a previous boot and was repaired.

A `Drop` guard covers the exit that has no `?` to take. The ORDINARY
`Err` path still releases explicitly and propagates: a release that fails
there strands the claim while later deliveries are told "deduped" with no
run behind them, and that is a runtime failure the caller must see rather
than one to report and swallow. Release happens before disarming, so a
failing release leaves the guard armed and the unwind still attempts a
best-effort cleanup on the way out. Best-effort is the right trade only
where the alternative is no cleanup at all.

First `Drop` impl in the crate, so the trade is stated in the code: it
cannot return an error or be `?`-ed, so a failed release there is
reported via `eprintln!` (the convention already used in
`server/liveness.rs`) and swallowed -- leaving the event exactly where a
leak would have left it, no worse. It must not panic: panicking in `Drop`
during an unwind aborts the process, turning a stranded event into a dead
daemon.

Evidence. Commands run from the repository root; `cargo` needs `cd
kernel` first, since the workspace manifest lives there. Full output,
nothing removed:

  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  bdc6d17b93f1aa5c3f256deeb5377943a68dec1fad6f018dee4dab88eaeb1cd3  kernel/relayflowd/src/engine/wake.rs

  $ PATH="$HOME/.cargo/bin:$PATH" RUSTUP_TOOLCHAIN=stable cargo test -p relayflowd --lib claim_guard
      Finished `test` profile [unoptimized + debuginfo] target(s) in 0.04s
       Running unittests src/lib.rs (target/debug/deps/relayflowd-01b0ec98cb5c81e7)

  running 4 tests
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok

  test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.01s

  # MUTATION: Drop made a no-op -- `if true || !self.armed`
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  548550ba2430c59fa1dd1760bb763b1d14e9f6a234dbe667b62cb73721bbb524  kernel/relayflowd/src/engine/wake.rs

  $ PATH="$HOME/.cargo/bin:$PATH" RUSTUP_TOOLCHAIN=stable cargo test -p relayflowd --lib claim_guard
     Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-173/kernel/relayflowd)
      Finished `test` profile [unoptimized + debuginfo] target(s) in 0.55s
       Running unittests src/lib.rs (target/debug/deps/relayflowd-01b0ec98cb5c81e7)

  running 4 tests
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... FAILED
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... FAILED

  failures:

  ---- engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped stdout ----

  thread 'engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped' (18112623) panicked at relayflowd/src/engine/wake.rs:367:9:
  a dropped armed guard must hand the event back

  ---- engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases stdout ----

  thread 'engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases' (18112622) panicked at relayflowd/src/engine/wake.rs:397:13:
  spawn_claimed_run blew up between the claim and register
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

  thread 'engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases' (18112622) panicked at relayflowd/src/engine/wake.rs:401:9:
  a claim leaked by a panic strands the event until the process restarts

  failures:
      engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases
      engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped

  test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.01s

  error: test failed, to rerun pass `-p relayflowd --lib`

  # RESTORED
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  bdc6d17b93f1aa5c3f256deeb5377943a68dec1fad6f018dee4dab88eaeb1cd3  kernel/relayflowd/src/engine/wake.rs
Exactly the two tests that should fail do, while the disarmed and
wrong-run cases keep passing -- so they pin the release without also
pinning its absence. The panic case runs through `catch_unwind` and
asserts the panic actually happened, so it cannot pass by never
unwinding. Kernel workspace at this head: 156 passed, 0 failed.

Known coverage boundary, stated rather than glossed: the panic test
constructs `ClaimGuard` directly instead of injecting a panic through
`submit_event`, so it pins the guard's contract, not its placement. The
placement is pinned by reading -- armed immediately after the claim,
disarmed only after `spawn_claimed_run` returns Ok -- and injecting a
panic into production code would need a test-only seam in the very span
this change exists to protect.

Three earlier drafts were rejected by review, each fairly: the first
described the evidence instead of showing it; the second abbreviated
cargo's test paths while calling the block literal; the third routed the
ordinary `Err` path through `Drop`, which silently downgraded a
propagating release to best-effort. The third was a real regression, not
a wording problem.

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 5, 2026
#173, deferred from #171 on purpose so it would get its own review.

`claim_event` tells every concurrent delivery that an event is a
duplicate on the strength of a claim row, so whoever takes that claim
owes the event either a registered run or the claim back. The `Err` path
handed it back; a PANIC did not. An unwind passes every match arm, and a
claim leaked that way survived for the life of the boot -- each later
delivery answered "duplicate" by the same-boot rule, with no run to carry
the event. Only a restart cleared it, because the claim then belonged to
a previous boot and was repaired.

A `Drop` guard covers the exit that has no `?` to take. The ORDINARY
`Err` path still releases explicitly and propagates: a release that fails
there strands the claim while later deliveries are told "deduped" with no
run behind them, and that is a runtime failure the caller must see rather
than one to report and swallow. Release happens before disarming, so a
failing release leaves the guard armed and the unwind still attempts a
best-effort cleanup on the way out. Best-effort is the right trade only
where the alternative is no cleanup at all.

First `Drop` impl in the crate, so the trade is stated in the code: it
cannot return an error or be `?`-ed, so a failed release there is
reported via `eprintln!` (the convention already used in
`server/liveness.rs`) and swallowed -- leaving the event exactly where a
leak would have left it, no worse. It must not panic: panicking in `Drop`
during an unwind aborts the process, turning a stranded event into a dead
daemon.

Evidence. Every command below is literally runnable from the repository
root, and the output is complete:

  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  bdc6d17b93f1aa5c3f256deeb5377943a68dec1fad6f018dee4dab88eaeb1cd3  kernel/relayflowd/src/engine/wake.rs

  $ (cd kernel && PATH="$HOME/.cargo/bin:$PATH" RUSTUP_TOOLCHAIN=stable cargo test -p relayflowd --lib claim_guard)
      Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
       Running unittests src/lib.rs (target/debug/deps/relayflowd-01b0ec98cb5c81e7)

  running 4 tests
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok

  test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.02s

  # MUTATION: Drop made a no-op -- `if true || !self.armed`
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  548550ba2430c59fa1dd1760bb763b1d14e9f6a234dbe667b62cb73721bbb524  kernel/relayflowd/src/engine/wake.rs

  $ (cd kernel && PATH="$HOME/.cargo/bin:$PATH" RUSTUP_TOOLCHAIN=stable cargo test -p relayflowd --lib claim_guard)
     Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-173/kernel/relayflowd)
      Finished `test` profile [unoptimized + debuginfo] target(s) in 2.24s
       Running unittests src/lib.rs (target/debug/deps/relayflowd-01b0ec98cb5c81e7)

  running 4 tests
  test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok
  test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... FAILED
  test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok
  test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... FAILED

  failures:

  ---- engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped stdout ----

  thread 'engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped' (18172736) panicked at relayflowd/src/engine/wake.rs:367:9:
  a dropped armed guard must hand the event back

  ---- engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases stdout ----

  thread 'engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases' (18172735) panicked at relayflowd/src/engine/wake.rs:397:13:
  spawn_claimed_run blew up between the claim and register
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

  thread 'engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases' (18172735) panicked at relayflowd/src/engine/wake.rs:401:9:
  a claim leaked by a panic strands the event until the process restarts

  failures:
      engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases
      engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped

  test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 measured; 25 filtered out; finished in 0.01s

  error: test failed, to rerun pass `-p relayflowd --lib`

  # RESTORED
  $ shasum -a 256 kernel/relayflowd/src/engine/wake.rs
  bdc6d17b93f1aa5c3f256deeb5377943a68dec1fad6f018dee4dab88eaeb1cd3  kernel/relayflowd/src/engine/wake.rs
Exactly the two tests that should fail do, while the disarmed and
wrong-run cases keep passing -- so they pin the release without also
pinning its absence. The panic case runs through `catch_unwind` and
asserts the panic actually happened, so it cannot pass by never
unwinding. Kernel workspace at this head: 156 passed, 0 failed.

Known coverage boundary, stated rather than glossed: the panic test
constructs `ClaimGuard` directly instead of injecting a panic through
`submit_event`, so it pins the guard's contract, not its placement. The
placement is pinned by reading -- armed immediately after the claim,
disarmed only after `spawn_claimed_run` returns Ok -- and injecting a
panic into production code would need a test-only seam in the very span
this change exists to protect.

Four earlier drafts were rejected by review, each fairly, and only one
was about the code: the first described the evidence instead of showing
it; the second abbreviated cargo's test paths while calling the block
literal; the third routed the ordinary `Err` path through `Drop`, which
silently downgraded a propagating release to best-effort -- a real
regression; the fourth printed commands without the `cd kernel` they were
actually run in, so "runnable from the root" was false.

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

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot added a commit that referenced this pull request Sep 5, 2026
Closes #173, deferred from #171 on purpose so it would get its own review rather than riding under an exactly-once fix.

`claim_event` tells every concurrent delivery that an event is a duplicate on the strength of a claim row, so whoever takes that claim owes the event either a registered run or the claim back. The `Err` path handed it back; a **panic** did not — an unwind passes every match arm, and a claim leaked that way survived for the life of the boot, each later delivery answering "duplicate" with no run to carry the event. Only a restart cleared it.

A `Drop` guard covers the exit that has no `?` to take. The ordinary `Err` path still releases explicitly and propagates, because a release that fails there strands the claim while later deliveries are told "deduped" with nothing behind them — a runtime failure the caller must see. Release happens before disarming, so a failing release leaves the guard armed and the unwind still attempts best-effort cleanup.

First `Drop` impl in the crate, so the trade is stated in the code: it cannot return an error or be `?`-ed, so a failed release there is reported via `eprintln!` (as `server/liveness.rs` already does) and swallowed — leaving the event exactly where a leak would have left it, no worse. It must not panic: panicking in `Drop` during an unwind aborts the process.

Evidence at the merged head bc41f9e:
- signoff: local 3-lens preswarm, maintainability / history / structure all REVIEW_PASSED, each re-run on the final head
- CI: run 33981376623 success on bc41f9e
- mutation: making `Drop` a no-op (`if true || !self.armed`) fails exactly `an_armed_guard_releases_the_claim_when_dropped` and `a_panic_between_claim_and_register_still_releases`, while `a_disarmed_guard_leaves_the_claim_alone` and `a_guard_only_releases_its_own_run` keep passing — pinning the release without pinning its absence. Full transcript with runnable commands and sha256 before/mutated/restored is in the commit message.
- kernel workspace 156 passed, 0 failed, no warnings

Known boundary, stated rather than glossed: the panic test constructs `ClaimGuard` directly rather than injecting a panic through `submit_event`, so it pins the guard's contract, not its placement.

Review caught one real regression on the way: an earlier revision routed the ordinary `Err` path through `Drop` too, silently downgrading a propagating release to best-effort. That is fixed here.
kjgbot added a commit that referenced this pull request Sep 5, 2026
Closes #167.

The row claimed gate 2 was missing "a test proving a duplicate event does not double-execute". That was already false when #167 was filed, and tonight's work went further, so the row understated progress and the gate at once.

Done, each claim checked against main rather than remembered: sequential duplicates (#14); concurrent racing deliveries under the production topology of one `Engine` per protocol request (#171); claims surviving the process that made them (#171 boot id, #182 panic unwind); resume adopting only a journal it can actually use (#177, #186).

Still missing, narrowly: the RFC-0001 Appendix A wake-time context contract — nothing specifies what `wake_context` guarantees, or that a resumed run observes the same context rather than a recomputed one.

And the correction #167 cared about most: the row was understating the gate. RFC-0001 §3's bar is `hn-monitor` running as a relayflow in production on its real events with zero bespoke persistence, not a passing test suite.

Evidence at the merged head 8e57b17:
- signoff: local 3-lens preswarm, maintainability / history / structure all REVIEW_PASSED
- CI: none applies. `cloud-runtime-artifact.yml` filters on `kernel/**`, `sdk/**`, `testdata/**` and `scripts/cloud-artifact*`; this touches only `ops/SCOREBOARD.md`, so no artifact run was triggered — verified by reading the workflow's `paths:` rather than waiting on a run that was never going to start. The `review` check is red for the reason common to every flows PR: the gate invokes `agent-relay` and no step installs it.

No counts in the row, deliberately — counts drift with the base, PR numbers and test names do not.
kjgbot added a commit that referenced this pull request Sep 5, 2026
Nothing verified main. `cloud-runtime-artifact.yml` triggered only on `pull_request` and `workflow_dispatch`, and `review-swarm.yml` only on `pull_request` — so every PR is checked at its own head and never as merged, and since we squash-merge onto a main that has moved since that CI ran, the composed result went unverified.

Not theoretical: twelve PRs merged on 2026-09-05 across the exactly-once claim path (#171, #182), resume adoption (#177, #186), the authored-flow executor (#184, #187) and the CLI run loop (#180) — each green on its own branch, the composed tree never run until dispatched by hand:

```
run 33987924703  workflow_dispatch  main  completed/success  ed917bf
```

Main is fine. But nobody knew that, and finding out required knowing to ask.

Without this, a bad compose surfaces as an unrelated PR going red — the most expensive way to find it, since the author debugs their own change first. Tonight already produced three failures on PRs that belonged to something else (#179, #185, the #174 chain), and each cost a tick to attribute.

Paths are deliberately not filtered on the push trigger: on a PR the question is "does this change affect the runtime", on main it is "is the tree good", and a docs-only merge can land on a tree someone else broke.

Evidence at the merged head d1cb32a:
- signoff: local 3-lens preswarm, maintainability / history / structure all REVIEW_PASSED
- CI: run 33988314276 success on d1cb32a, verified by headSha and event=pull_request
- YAML parsed and asserted: triggers are `pull_request`, `push`, `workflow_dispatch`; push is branch-scoped to main; the pull_request path filter is unchanged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent::rung_c_sigkill_... hangs on Linux runners ~half the time, costing 30min and cancelling the job

1 participant