Repository navigation
feat(delegation): background delegate_task restart survival (persist + auto-redispatch) - #285
Conversation
Persist gateway-origin background delegation intent and outbox events, recover eligible attempts once per boot, and replay notifications through the existing completion queue. Verified: scripts/run_tests.sh on 12 focused files (506 tests passed); exact RC-1/RC-2 gate (2 tests passed).
|
| Filename | Overview |
|---|---|
| tools/async_delegation_store.py | New 886-line durable registry: file-locked JSON store with SHA-256 integrity, outbox/acknowledgement pattern, claim_recoveries boot-scan, and cancel_matching. Minor unguarded record["attempt"] access in transition_owned. |
| tools/async_delegation.py | Wires durable store into dispatch/finalize paths and adds recover_async_delegations + enqueue_pending_outbox. The shutdown-window guard (status != "completed") fixes the previous P1 but omits "success", leaving a gap for ACP runners. |
| gateway/run.py | Adds boot-scan recovery hook, profile-scoped credential re-resolution, bounded retry cap (900 cycles), profile-matching guard for session source lookup, and scoped interrupt_for_session call. All previously flagged P1s addressed. |
| tools/delegate_tool.py | Adds _build_durable_background_spec, build_recovered_delegation_runner, and _recovery_spec plumbing. Credentials absent from persisted spec and re-resolved on recovery. Sync fallback on registry rejection is correct. |
| gateway/status.py | Adds is_boot_id_alive using psutil create_time contract; compact and correct. |
| tools/process_registry.py | Adds async_delegation_restarted formatter; low-risk additive change. |
| gateway/slash_commands.py | Adds scoped interrupt_for_session on /stop; mirrors the main interrupt path correctly. |
| hermes_cli/config.py | Adds delegation.resume_on_restart default (true) to DEFAULT_CONFIG; clean single-line addition. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant GW as Gateway Boot
participant Store as async_delegation_store
participant Exec as ThreadPoolExecutor
participant Queue as completion_queue
Note over GW: Normal dispatch
GW->>Store: persist_dispatch(delegation_id, durable_spec, boot_id)
Store-->>GW: record with attempt_id
GW->>Exec: submit _worker
Exec->>Store: mark_submitted(attempt_id)
Exec->>Exec: runner()
Exec->>Store: append_terminal(attempt_id, result, status)
Store-->>Queue: terminal event queued
Queue-->>GW: watcher delivers completion notification
Note over GW: Gateway restart kills workers mid-flight
Note over GW: Next boot recovery path
GW->>Store: claim_recoveries(current_boot_id)
Store->>Store: find running records with dead boot_id
Store->>Store: increment generation, write restart outbox event
Store-->>GW: claimed records
GW->>Exec: submit recovery _worker with continuation note
GW->>Store: enqueue_pending_outbox(current_boot_id)
Store-->>Queue: restart and pending terminal events
Queue-->>GW: watcher delivers restarted notification
Note over GW: Shutdown with recoverable=True
GW->>Store: transition_owned(ids, recoverable) best-effort
GW->>Exec: interrupt signals sent to workers
Note over Store: Record stays recoverable, next boot re-claims it
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant GW as Gateway Boot
participant Store as async_delegation_store
participant Exec as ThreadPoolExecutor
participant Queue as completion_queue
Note over GW: Normal dispatch
GW->>Store: persist_dispatch(delegation_id, durable_spec, boot_id)
Store-->>GW: record with attempt_id
GW->>Exec: submit _worker
Exec->>Store: mark_submitted(attempt_id)
Exec->>Exec: runner()
Exec->>Store: append_terminal(attempt_id, result, status)
Store-->>Queue: terminal event queued
Queue-->>GW: watcher delivers completion notification
Note over GW: Gateway restart kills workers mid-flight
Note over GW: Next boot recovery path
GW->>Store: claim_recoveries(current_boot_id)
Store->>Store: find running records with dead boot_id
Store->>Store: increment generation, write restart outbox event
Store-->>GW: claimed records
GW->>Exec: submit recovery _worker with continuation note
GW->>Store: enqueue_pending_outbox(current_boot_id)
Store-->>Queue: restart and pending terminal events
Queue-->>GW: watcher delivers restarted notification
Note over GW: Shutdown with recoverable=True
GW->>Store: transition_owned(ids, recoverable) best-effort
GW->>Exec: interrupt signals sent to workers
Note over Store: Record stays recoverable, next boot re-claims it
Reviews (4): Last reviewed commit: "fix(delegation): restore either-selector..." | Re-trigger Greptile
…te_tool signature/call-site conflicts: recovery params + skills param coexist; PROGRESS.md union)
…ults kept (finalize + batch), bounded temporary-retry loop (900, park to durable outbox), /new-scoped cancellation (key+parent pair narrows; legacy key-only records still match)
…ession_id OR key — the /new reset passes both, either claims); scoped narrowing retained ONLY for key-matches on records carrying a DIFFERENT parent id; update call-site expectation test
…00 summary-role pin) (#431) * vendor(lcm): cherry-pick 9 upstream fixes from hermes-lcm (03b74f8 -> selected from 49e99a2) Upstream hermes-lcm went MIT (LICENSE added 2026-06-26, f7ae61f) — vendoring and cherry-picking now permitted with attribution. Tier-1 picks per /tmp/lcm-refresh-out/CHERRY-PICK-LIST.md, code-only (their tests/docs/changelog excluded; our vendored copy carries no tests dir — coverage rides tests/context_engine/): #263 preserve source lineage after long sessions #264 perf: aggregate DAG status stats #265 harden externalized payload durability #269 preserve raw session ownership across compression rollover #278 avoid payload integrity false positives from log examples #280 pin summary role to user after system anchor (Anthropic HTTP 400) <- highest value #285 make context engine deepcopy clone-safe (subagent spawn safety) #282 strip injected context before compaction (+) discard reasoning-only summaries (unclosed <think> = quality bug + prompt leak) All 9 verified clean-apply by the refresh-analysis worker on a simulated copy of our tree; re-applied here onto fork/main. * test(compaction): regenerate in-turn reconcile fixture for the vendored sanitizer The 9 LCM cherry-picks (3c1d61c) add one line to _sanitize_active_context_messages (upstream pick #282, strip injected context before compaction), which moves the fixture's sanitizer_source_sha1 provenance hash and reds test_fixture_sanitizer_provenance_current. Regenerated via the committed generator: python tests/agent/fixtures/gen_inturn_reconcile_fixture.py Diff is the provenance hash ONLY -- messages, compressed, true_kept_count and fresh_tail_count are byte-identical. The new line routes through _preserved_objective_context_content, which returns "" unless a row starts with the preserved-objective prefix, so it is a strict no-op on all 632 fixture rows (verified: 0 rows mutated) and the real sanitizer output is unchanged. Only one of the four hashed functions changed; the other three are byte-identical. Follow-up candidate (not this PR): this provenance test is a change-detector, which AGENTS.md discourages -- it should assert the sanitizer's behaviour (live sanitize(raw_tail) == committed comp tail) rather than pinning its source SHA. --------- Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Summary
Background
delegate_taskjobs now SURVIVE gateway restarts — persist at dispatch, boot-scan for orphans, auto-redispatch with a continue-from-partial-artifact note.Problem (two live incidents, 2026-07-10)
Background delegates run on a daemon ThreadPoolExecutor inside the gateway process. A gateway restart SIGTERMs them silently mid-flight with no trace and no resume: a 35-min research delegate (72 messages of work) died to a deploy restart; its redispatch died to a SECOND restart the same day. Kanban workers (separate processes) survived both.
Design (Momus-reviewed spec, APPROVE-WITH-CHANGES, all 4 RCs folded)
tools/async_delegation_store.py): the existing self-contained task-source block persisted at dispatch; status transitions on the existing completion rail.runningwhose boot_id != current (psutil create_time contract, same as the F2 breadcrumb) were killed by a restart → auto-redispatch with a prepended "interrupted at ; a partial artifact may exist — read it and CONTINUE" note; parent session notified via the existing completion-queue rail (newasync_delegation_restartedevent type).delegation.resume_on_restart(default true) in config.yaml + DEFAULT_CONFIG (no new env vars). Rollback = flip false → exactly today's in-memory behavior.Momus RC folds (each with a named test)
test_rc1_claimed_but_never_submitted_does_not_count_attempt.test_rc2_pending_restart_event_replays_and_superseded_event_drops.recoverableclean-shutdown persistence is best-effort with a bounded lock-acquire timeout; the running+dead-boot path is the guaranteed fallback (documented + enforced).Tests
41 new tests green across 3 files (persistence store, boot-scan recovery, gateway integration); barrier-based race tests (no sleep-ordering); 506/506 broad focused sweep by the worker. Cache/alternation safe: completions re-enter as fresh turns on the existing rail.