Skip to content

fix(supervisor): mark module alive=false when init returns Err (COW-1070) - #34

Closed
brunota20 wants to merge 3 commits into
feat/m3-edge-case-validationfrom
fix/supervisor-alive-on-init-err
Closed

fix(supervisor): mark module alive=false when init returns Err (COW-1070)#34
brunota20 wants to merge 3 commits into
feat/m3-edge-case-validationfrom
fix/supervisor-alive-on-init-err

Conversation

@brunota20

Copy link
Copy Markdown

What does this PR do?

When `Supervisor::load` boots a module and `Guest::init` returns `Err(HostError)`, the supervisor now marks the module `alive = false` (instead of `alive = true`). The dispatcher's fast-path `if !alive { continue; }` already excludes dead modules, so the init-failed module no longer receives per-block dispatches.

Boot log changes from `supervisor up count=N` to `supervisor up loaded=N alive=M` so the discrepancy is visible to the operator.

Why

Surfaced live on Sepolia by scenario 1.4 of `docs/operations/m3-edge-case-validation.md` (PR #33). Pre-fix: init-failed module stayed `alive = true`, received every block dispatch, and consumed fuel + RPC subscription cost on a no-op. Safe because all M3 example strategies guard with `SETTINGS.get().is_none() -> return Ok(())`, but future modules without that guard could trap.

Changes

File Change
`crates/nexum-engine/src/supervisor.rs` Capture init result into `init_succeeded: bool`; set `LoadedModule.alive` from it. Boot log uses `loaded` + `alive` instead of `count`. WARN message updated to make the dead-marking explicit.
`crates/nexum-engine/src/supervisor/tests.rs` New `init_failure_marks_module_dead_and_excludes_from_dispatch` regression test (48 nexum-engine tests, was 47).
`docs/operations/m3-edge-case-validation.md` Scenario 1.4 verdict updated to ✅ (no longer "with minor observation"); replaced the observation block with a note pointing at the regression test + the new log shape.

Live validation on Sepolia (re-run of scenario 1.4)

Before fix:
```
INFO supervisor up count=3 <-- includes dead module
```

After fix:
```
WARN init failed - module loaded but marked dead; dispatcher will skip it
module=price-alert kind=HostErrorKind::InvalidInput
INFO supervisor up loaded=3 alive=2
```

Subsequent block dispatches reach only the 2 alive modules. Confirmed over 14s of block flow.

Considered alternatives

  • Option B (skip pushing the dead module into `self.modules`): cleaner but requires every caller of `Supervisor::load_one` to handle the "module not added" case. Rejected for API stability.
  • Option C (cosmetic - rename log only): does not fix the per-block no-op cost. Rejected.

Chosen Option A (flip alive=false) because the dispatch fast-path already gates on alive; no caller-side change needed.

Breaking changes

The boot log key changed from `count` to `loaded`+`alive`. Anyone scraping engine logs by exact key match would need to update, but that is operator territory and the new shape is strictly more informative.

Testing

  • `cargo test -p nexum-engine init_failure_marks` -> 1 passed.
  • `cargo test --workspace` -> 151 host tests + 6 doctests passing.
  • `cargo clippy --all-targets --workspace -- -D warnings` clean.
  • `cargo fmt --all --check` clean.
  • Live re-run of scenario 1.4 on Sepolia confirms the new `loaded=3 alive=2` log + dispatcher skip.
  • 0 em-dashes in changed files.

AI assistance disclosure

AI Assistance: this fix + description was produced by a Claude Code agent (Claude Opus 4.7 1M context). The agent surfaced the behaviour in scenario 1.4 of PR #33, filed COW-1070, implemented the fix, added the regression test, re-validated against live Sepolia, and updated the runbook. A human (Bruno) reviewed and is accountable for the result.

Linear: COW-1070. Closes the only follow-up from PR #33.

Stacks on #33 (M3 edge-case validation) -> #32 (M3 runbook + event_loop fix) -> #31 (M2 runbook) -> #30 (COW-1068) -> #29 (COW-1067) -> #28 (COW-1069) -> #27 (COW-1066) -> #26 (COW-1063 QA cleanup).

…070)

Pre-fix behaviour: `Supervisor::load` pushed every module into
`self.modules` with `alive = true`, even when `Guest::init` returned
`Err(HostError)`. The supervisor logged `WARN init failed` but the
dispatcher still routed every block / log to the dead module, where
the M3 example strategies short-circuited via
`SETTINGS.get().is_none() -> return Ok(())`. Safe but wasteful, and
the `supervisor up count=N` log was misleading (counted the dead
module as up).

Surfaced live on Sepolia by scenario 1.4 of
`docs/operations/m3-edge-case-validation.md`: set
`[config] threshold = "not-a-number"` in price-alert, observe init
return InvalidInput, then watch the dispatcher hammer the dead
module every block for 14s.

## Fix

`Supervisor::load` now captures the init result into
`init_succeeded: bool` and sets `LoadedModule.alive = init_succeeded`.
The boot log changes from `supervisor up count=N` to
`supervisor up loaded=N alive=M` so the discrepancy is loud.

## Regression test

`supervisor::tests::init_failure_marks_module_dead_and_excludes_from_dispatch`:

- Synthesises a manifest matching real price-alert shape but with
  `threshold = "not-a-number"`.
- Boots the supervisor; asserts `module_count() == 1` (loaded) and
  `alive_count() == 0` (dead).
- Dispatches a synthetic Sepolia block; asserts `dispatched == 0`
  (the only "subscribed" module is dead, so the dispatch fast-path
  skips it).

## Live validation on Sepolia (rerun of scenario 1.4 with fix)

Before fix:
```
INFO supervisor up count=3    <-- includes dead module
```

After fix:
```
WARN init failed - module loaded but marked dead; dispatcher will skip it
     module=price-alert kind=HostErrorKind::InvalidInput
INFO supervisor up loaded=3 alive=2
```

## Docs update

`docs/operations/m3-edge-case-validation.md` scenario 1.4 verdict
updated from "✅ with minor observation" to
"✅; resolved in this PR series". The original observation block
is replaced with a note pointing at the regression test + the new
log line.

## Workspace state

- `cargo test --workspace` -> 151 host tests + 6 doctests passing
  (was 150 + 6; +1 from the new regression test).
- `cargo clippy --all-targets --workspace -- -D warnings` clean.
- `cargo fmt --all --check` clean.
- 0 em-dashes in changed files.

Linear: COW-1070. Closes the only finding from PR #33.

## Considered alternatives

**Option B** (skip pushing the init-failed module into
`self.modules` entirely) would have been cleaner but requires
callers of `Supervisor::load_one` to handle the "module not added"
case. Option A (this PR - flip alive=false) preserves the existing
API surface; the dispatch fast-path already gates on `if !alive
{ continue; }` so the dispatched-event count drops to 0 without any
caller-side change.

**Option C** (visibility only - rename the boot log) was rejected;
it surfaces the discrepancy but does nothing about the per-block
no-op fuel cost on the dead module.
@linear-code

linear-code Bot commented Jun 18, 2026

Copy link
Copy Markdown

COW-1070

12 review threads addressed end-to-end. Net diff is -720 lines despite
adding ~200 lines of new helpers + tests, because the WitBindgenHost
adapter deduplication alone wipes ~400 lines.

Per-thread:

  #1 (balance-tracker architecture): refactored to match the M3
  host-trait+adapter split the other 4 modules use. Created
  `strategy.rs` with `on_block(&impl Host, ...)`, moved check_one /
  fetch_balance / parse_balance_hex / parse_settings into it,
  converted parse_config to use SDK config helpers + typed
  HostError instead of String. Added 3 MockHost-driven tests
  covering first-seen-above-threshold, below-threshold-persist,
  and error-does-not-abort-loop.

  #2 + #3 (WitBindgenHost dedup): new
  `shepherd_sdk::bind_host_via_wit_bindgen!()` declarative macro.
  Single source of truth in `crates/shepherd-sdk/src/wit_bindgen_macro.rs`;
  the 4 trait impls + convert_err / sdk_err_into_wit / convert_level
  collapse to one macro invocation per module. Migrated all 5
  modules (twap-monitor, ethflow-watcher, price-alert, stop-loss,
  balance-tracker). Each module's lib.rs lost ~80 lines.

  #4 (scale_decimal + config_get dup): new `shepherd_sdk::config`
  with `get_required`, `get_optional`, `scale_decimal`, and a
  typed `ConfigError` enum (host-neutral). price-alert + stop-loss
  consume the SDK helpers; their local duplicates were deleted.
  Module-level decimal-parsing tests removed (covered by 7 SDK
  tests + 4 proptest cases now).

  #5 (Chainlink dup): new `shepherd_sdk::chain::chainlink` with
  `read_latest_answer(host, chain_id, oracle, domain) -> Option<I256>`.
  Encapsulates the eth_call → parse → ABI decode flow + Warn
  logging. price-alert + stop-loss now call the helper; their
  local AggregatorV3 sol! definitions + read_oracle / on_block
  oracle plumbing was deleted. SDK ships with 3 StubHost tests
  covering happy path, host error, and garbage-hex.

  #6 (WIT world capability elision): added new "Capability
  enforcement vs. the WIT world" section to ADR-0009 documenting
  that price-alert + balance-tracker compile against the
  shepherd:cow/shepherd supertype but their manifests omit
  cow-api, and that boot success depends on wasm-tools' unused-
  import elision. Flagged as load-bearing; M5 macro hardening
  path documented.

  #7 (poll-time revert classification inert): filed COW-1082 for
  the host-side fix (forward structured eth_call error data into
  HostError.data; analogous to COW-1075 for orderbook).

  #8 (classify_api_error retry-default unbounded): filed COW-1083
  for the rate-limit / max-retry follow-up on the backoff: marker.

  #9 (RetryAction::Backoff dead variant): no code change; replied
  to thread clarifying it is reserved API surface waiting on a
  richer upstream retry_hint shape (open question for mfw78).

  #10 (no proptest anywhere): added `proptest` to shepherd-sdk
  dev-dependencies. New `crates/shepherd-sdk/src/proptests.rs`
  with 6 properties covering eth_call_params/parse_eth_call_result
  round-trip, parse_eth_call_result rejection on unquoted input,
  config::scale_decimal round-trip + sign-preservation, U256 LE
  byte round-trip, and no-panic guards for decode_revert_hex +
  gpv2_to_order_data marker dispatch.

  #11 (ethflow chain capability least-privilege): moved `chain`
  from required to optional in `modules/ethflow-watcher/module.toml`,
  mirroring the M2 mirror fix already applied.

  #12 (ADR-0009 test-count census): dropped the "145 host tests
  (twap 20, ethflow 12, ...)" breakdown; kept the qualitative
  claim. CI is now the authoritative count.

Drive-by: alloy-sol-types moved from regular to dev-dependencies in
price-alert and stop-loss now that the Chainlink ABI helper is
inside shepherd-sdk and the modules only use sol! in their test
helpers.

Validation:
- cargo test --workspace: every crate green; 5 modules + SDK + sdk-test + engine all pass. 8 host tests gained on balance-tracker; 6 proptest props gained on shepherd-sdk; 3 Chainlink helper tests gained.
- cargo clippy --workspace --all-targets -- -D warnings: clean.
- cargo fmt --check: clean.
- cargo build --target wasm32-wasip2 --release for all 5 modules: clean.
- Zero em-dashes in source code added.

AI assistance disclosure: drafted by Claude (Opus 4.7, 1M context).
…ance)

Filtered subset of the compliance applied in PRs #66/#67 of bleu/nullis-shepherd,
restricted to files that exist on the M3 epic head. M4/M5-only files
(shepherd-backtest, baseline-latency tools, etc.) are skipped, and compliance
hunks that depended on M4-introduced types/functions (reconnect tasks, JoinSet
plumbing in event_loop, the M4-shape `ProviderError`/Rpc variant, the supervisor
restart loop, env-var substitution in engine.toml) are skipped too - they only
make sense once the underlying M4 code lands.

Brings the M3 epic in line with the repo-wide rust rubric in the cases that
do transfer cleanly:

- crates/nexum-engine/src/manifest/error.rs: swap manual Display/Error impls
  for `thiserror::Error` derives, mark `ParseError` `#[non_exhaustive]`,
  carry source via `#[from]`.
- crates/nexum-engine/src/manifest/load.rs: drop the `.map_err(ParseError::Io/Toml)`
  call sites that the `#[from]` impls now cover, swap `eprintln!` lines for
  structured `tracing::{info,warn}`.
- crates/nexum-engine/src/manifest/mod.rs: doc tidy-up.
- crates/nexum-engine/src/host/mod.rs: tighten submodule visibility from `pub`
  to `pub(crate)` (no out-of-crate users).
- crates/nexum-engine/src/host/error.rs + host/impls/cow_api.rs: drop the
  bespoke `hex_encode` helper in favour of `alloy_primitives::hex::encode_prefixed`,
  already a dep on M3 and used elsewhere in `shepherd-sdk`.
- crates/nexum-engine/src/engine_config.rs: introduce a trimmed `EngineConfigError`
  (Io + Toml only - the M5 `Substitute` variant covers an env-var-substitution
  path that does not exist on M3) and return it from `load_or_default` instead
  of `anyhow::Result`. `main.rs`'s `?` still works thanks to `From<EngineConfigError>
  for anyhow::Error`.
- crates/shepherd-sdk/src/cow/error.rs: mark `RetryAction` `#[non_exhaustive]`.
- modules/{twap-monitor,examples/stop-loss,ethflow-watcher}/src/strategy.rs:
  add a default arm to each `match RetryAction { ... }` that now needs one,
  treating unknown future variants conservatively (retry on next block / leave
  watch in place) instead of silently dropping the watch on an SDK bump.

cargo fmt + cargo clippy --all-targets -D warnings + cargo test --workspace
--all-features all green on the worktree.

AI Assistance: Claude Code (Opus 4.7) prepared and applied this filtered patch
under direct human review. The instruction \`STOP and report\` on any non-trivial
deviation was followed for each hunk; the skipped hunks (M4-coupled) are
documented in the propagation summary that accompanies this branch update.
A human (Bruno) is accountable for the result.
@brunota20

Copy link
Copy Markdown
Author

Work landed via dev/m3-base advance (FF to 9e76602 = M3 epic + rust-idiomatic compliance propagation). The HEAD commit of this PR is now an ancestor of dev/m3-base. Closing as merged-by-ancestor.

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.

1 participant