feat(watchdog): independent journal-driven restore path - #9
Open
undeemed wants to merge 4 commits into
Open
Conversation
Implement the Linux-safe watchdog that recovers experiments the gateway, workload, or agent abandoned. It reads the durable experiment journal, finds write-ahead apply-intents with no terminal record (a crash between the mutation and its rollback, or an elapsed TTL lease), and rolls each back through its provider using the journaled snapshot, verifying the restore by re-reading provider state. It writes only its own correlation-ID-linked restore-outcome record and the terminal record that closes a restored experiment, never the schema, so recovery never depends on the components it recovers from. - watchdog library: scan/reclaim with AllUnclosed and ExpiredLeasesOnly policies, per-experiment isolation, and idempotent restores - journal module: read-only access plus append-only restore records, with zero control-plane changes - binary: interval poll loop with a single --once pass for a later Windows-service timer, and --recover-all for crash/reboot recovery - tests: fault-injection E2E covering crash mid-apply, real gateway termination, expired lease, idempotence/no-double-rollback, failed and corrupted rollback retry, provider isolation, closed experiments left untouched, and a binary smoke test - docs: ARCHITECTURE watchdog section, README feature list, AGENTS rule
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Implement the independent watchdog restore path for fpsmaxxing: a Linux-safe watchdog (library + binary in apps/watchdog) that recovers experiments abandoned when the gateway, workload, or agent dies, with no gateway/experiment-runner/LLM involvement, satisfying the safety invariant that the watchdog can restore state independently.
Detection and recovery: read the durable experiment journal owned by crates/control-plane (ADR 0002) and find two leak classes - unclosed experiments (a write-ahead apply-intent with no terminal completed/failed record = crash between mutation and rollback) and expired TTL leases (lease elapsed, no terminal record). Roll each back through its provider using the journaled snapshot, verifying the restore by re-reading provider state.
Key deliberate design decisions (so these are not mistaken for oversights in review): (1) The watchdog owns its OWN read-only journal module (apps/watchdog/src/journal.rs) and reads the SQLite journal directly, rather than adding read APIs to control-plane. This keeps control-plane changes at ZERO, preserves the independence invariant, and avoids overlap with a sibling crewmate concurrently adding an additive trial-journal API to control-plane. (2) The watchdog writes NOTHING to the journal except its own correlation-ID-linked 'watchdog-restore' records and the terminal 'failed' record that closes a restored experiment; it never creates or mutates the schema. (3) Idempotence by design: a successful restore writes a terminal record so the experiment is never selected again (no double-rollback); a failed rollback is left unclosed for a later pass to retry. Two policies: AllUnclosed (crash/reboot recovery) and ExpiredLeasesOnly (steady-state poll). (4) Lease age is computed with the journal's own SQL clock while lease_seconds is decoded from the write-ahead ChangeRequest in Rust, avoiding a JSON1 dependency; a missing/negative age fails safe to not-expired. (5) The binary provides an interval poll loop, a single --once pass (the unit a later Windows-service timer would drive), and --recover-all; Windows service registration is explicitly out of scope - the container is Linux-only, no Windows APIs, no real hardware.
Tests: 10 fault-injection E2E cases against sidecars/mock-provider covering crash mid-apply restoring defaults, real gateway/control-plane termination mid-apply (a panicking provider driven through the actual run_lifecycle write path), expired-lease reclaim, a live lease left for its owner, idempotence/no-double-rollback, failed and silently-corrupted rollback both retried, provider isolation, cleanly-closed experiments left untouched (real ControlPlane E2E), and a --once binary smoke test.
Docs: updated ARCHITECTURE watchdog section, README feature list, and an AGENTS repository rule pointing to apps/watchdog. Constraints honored: additive only, no control-plane schema or run_lifecycle changes; full local validation (cargo fmt --check, cargo check --workspace --all-targets, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace, cargo deny check) passes.
What Changed
apps/watchdoglibrary and binary that recovers experiments abandoned when the gateway, workload, or agent dies, with no gateway/experiment-runner/LLM involvement. It ships its own read-only journal module (journal.rs) that reads the control-plane SQLite journal directly (zero control-plane changes), detects the two leak classes (unclosed apply-intents from a crash mid-apply, and expired TTL leases), rolls each back through its provider using the journaled snapshot, and verifies the restore by re-reading provider state. Restores are idempotent by writing a correlation-ID-linked terminal record; failed rollbacks are left unclosed for a later pass to retry.--oncepass, and--recover-all, selecting between theAllUnclosed(crash/reboot) andExpiredLeasesOnly(steady-state) policies. Per review feedback,--recover-allis forced to a single pass so continuous polling always staysExpiredLeasesOnly, and repeated restore failures are deduped to bound journal growth.--oncesmoke) plus 2 binary-config unit tests, and updated the ARCHITECTURE watchdog section, README feature list and binary usage, and an AGENTS repository rule pointing toapps/watchdog.Risk Assessment
✅ Low: Additive, well-isolated watchdog crate that only reads the control-plane journal and writes its own restore records; both prior-round findings were correctly resolved (single-pass --recover-all with unit tests, and retry-preserving failure dedup), and no new material bug was substantiated across the changed code, queries, or contracts.
Testing
Ran the watchdog crate's 11 fault-injection E2E tests and 2 config unit tests and the full
cargo test --workspacesuite (all pass) after fixing a build-environment issue where the sandbox's zig-cc compiler rejected the Rust target triple for bundled SQLite (resolved with ephemeral compiler wrappers, no source or global-config changes). Beyond the tests, I demonstrated the intent as an operator would experience it by running the real compiled watchdog binary against journals seeded to match the control-plane schema: it independently restored crash-leaked state and closed the experiment, was idempotent on re-run, and in steady-state poll reclaimed only the expired lease while leaving a live lease for its owner. Evidence captured as a CLI transcript with before/after journal state.Evidence: Watchdog CLI transcript: crash-recovery + idempotence + expired-lease-only poll with before/after journal state
Evidence: Watchdog fault-injection E2E result
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed (2) ✅
apps/watchdog/src/main.rs:121---recover-allwithout--onceruns the AllUnclosed policy on every poll interval (main loop lines 32-39 + Config::policy lines 120-126). AllUnclosed selects unclosed experiments regardless of lease deadline (lib.rs:99), so a continuous AllUnclosed poll will roll back an in-flight experiment that is still inside its lease as soon as any component holds a real lease (the experiment-runner scaffold, or a provider whose operation outlives a short lease), fighting the live owner and violating the 'only one provider may own a knob at a time' / 'leave live leases to their owner' invariants that ExpiredLeasesOnly is designed to protect. Harmless today only because the mock lifecycle is synchronous. Consider making --recover-all imply a single pass (or rejecting --recover-all combined with the loop) so continuous polling is always ExpiredLeasesOnly.🔧 Fix: force --recover-all to a single pass in watchdog
2 issues (1 warning, 1 info) still open:
apps/watchdog/src/main.rs:32- In the steady-state loop (ExpiredLeasesOnly), a permanently-unrestorable expired-lease experiment is re-selected and re-journaled on every tick with no backoff or attempt cap.reclaimre-runsscan()each pass (lib.rs:180); a failed restore callsrecord_failure(journal.rs:148) which appends a newwatchdog-restorerow but no terminal record, so the same experiment stays selectable forever. A genuinely stuck knob (or the corrupt-rollback case that never verifies) therefore writes a fresh failure record everyintervalseconds (default 5s) indefinitely, growing the shared journal without bound and hammeringprovider.rollback/snapshot. Because the scan query does a full-table JOIN + NOT EXISTS, the accumulating rows also progressively slow every scan for both the watchdog and the control plane that share the file. Consider a bounded retry / exponential backoff, or a distinct non-selectingwatchdog-abandonedrecord after N failures so a stuck experiment stops being retried at 5s cadence.apps/watchdog/src/lib.rs:182-reclaimprocesses candidates in ascendingexperiment_idorder (journal.rs:64ORDER BY intent.experiment_id), rolling each back through the single shared provider instance. If two experiments on the same knob are simultaneously unclosed (a double-fault that also breaches the one-owner-per-knob invariant), the knob ends at the second-oldest experiment's snapshot rather than the true original baseline: e.g. exp1 snapshots A then leaks, exp2 snapshots the leaked B then leaks; reclaim sets knob->A (exp1) then knob->B (exp2), leaving B instead of A. Cannot occur on the current synchronous single-knob mock path and is guarded by the one-owner-per-knob invariant, but the watchdog is precisely the path that runs after that invariant may have been violated, so the recovery order is worth a deliberate decision.🔧 Fix: dedup repeated watchdog restore failures; document reclaim order
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
cargo test -p fpsmaxxing-watchdog— 11 fault-injection E2E tests (crash mid-apply, real control-plane gateway termination via PanicOnApply, expired-lease reclaim, live-lease left alone, idempotence, failed rollback retried, corrupted rollback caught by verify probe, provider isolation, cleanly-closed experiment untouched, --once binary smoke) + 2 binary-config unit tests, all passcargo test --workspace— full suite green (contracts 8, control-plane 9, gateway mcp_e2e 2, mock-provider 1, watchdog 11+2)Manual CLI E2E: ran the realtarget/debug/fpsmaxxing-watchdogbinary via--once --recover-all --journal <db>(crash recovery + idempotence re-run) and--once --journal <db>(default expired-leases-only poll) against seeded leaked journals, dumping journal rows before/afterSetup fix:CC/CXX(zig-cc) rejected the Rust tripleaarch64-unknown-linux-gnu; added ephemeral /tmp wrapper scripts translating the triple and exposingzig arso the bundled-SQLite C build compiles✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.