Skip to content

feat(delegation): background delegate_task restart survival (persist + auto-redispatch) - #285

Merged
Kyzcreig merged 5 commits into
mainfrom
feat/delegation-restart-survival
Jul 12, 2026
Merged

Kyzcreig merged 5 commits into
mainfrom
feat/delegation-restart-survival

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Summary

Background delegate_task jobs 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)

  • Durable per-profile registry (tools/async_delegation_store.py): the existing self-contained task-source block persisted at dispatch; status transitions on the existing completion rail.
  • Boot-scan: records still running whose 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 (new async_delegation_restarted event type).
  • Redispatch cap 2 per delegation (generation-scoped attempt ids), then an honest FAILED completion — no infinite restart loops (F2 lessons).
  • Config: 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)

  • RC-1: claimed-but-never-submitted attempts (boot dies while queued pre-submit) are reconciled on next boot and do NOT count an attempt — test_rc1_claimed_but_never_submitted_does_not_count_attempt.
  • RC-2: pending restart events replay across boot-death identically to terminal events; superseded (older-generation) restart events transition to dropped(superseded), never stuck pending — test_rc2_pending_restart_event_replays_and_superseded_event_drops.
  • RC-3: recoverable clean-shutdown persistence is best-effort with a bounded lock-acquire timeout; the running+dead-boot path is the guaranteed fallback (documented + enforced).
  • RC-4: all file:line anchors re-grounded against current fork/main; extends the existing completion-queue crash-recovery checkpoint (no parallel mechanism).
  • Momus residual: observability counter when the record cap forces synchronous-fallback degradation.

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.

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).
@Kyzcreig
Kyzcreig enabled auto-merge (squash) July 11, 2026 01:30
@greptile-apps

greptile-apps Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR adds restart-survival for background delegate_task jobs: a durable per-profile JSON registry (async_delegation_store.py) persists each delegation at dispatch, a boot-scan on startup claims orphaned records (owned by a now-dead PID) and re-dispatches them with a continuation prompt, and a bounded outbox pattern replays terminal/restart events across boots. All three previously-flagged P1s are resolved: the _finalize shutdown-window early-return now lets completed results flow through, temporary injection failures are capped at 900 retries (~30 min), and interrupt_for_session uses a scoped (session_key, parent_session_id) pair to avoid cancelling prior /new-reset sessions' work.

  • Durable store (tools/async_delegation_store.py): file-locked JSON registry with SHA-256 record integrity, generation-scoped attempt IDs, a 2-attempt redispatch cap, and claim_recoveries implementing RC-1 (claimed-but-never-submitted boots do not consume retry budget).
  • Recovery orchestration (gateway/run.py + tools/async_delegation.py): _recover_async_delegations_once iterates all served profiles under the correct credential scope, rebuilds runners via build_recovered_delegation_runner, and drains the pending outbox before the watcher starts.
  • Fallback safety (tools/delegate_tool.py): if _build_durable_background_spec raises (missing boot ID, TUI session, registry cap), the delegation falls back to synchronous execution with an explanatory note rather than silently launching untracked work.

Confidence Score: 4/5

Safe to merge; the core restart-survival contract is correct and all three previously-flagged bugs are resolved. One narrow gap remains in _finalize where a runner returning "success" instead of "completed" during the shutdown window has its result silently discarded and the job re-dispatched.

The shutdown-window guard in _finalize uses status != "completed" while append_terminal explicitly treats both "completed" and "success" as terminal-success. For current gateway delegate_task runners this gap is inert (they always emit "completed"), but ACP-mode external runners could return "success", causing completed work to be re-executed on the next boot.

tools/async_delegation.py lines 361 and 640 (_finalize and _finalize_batch shutdown-window guards)

Important Files Changed

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
Loading
%%{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
Loading

Reviews (4): Last reviewed commit: "fix(delegation): restore either-selector..." | Re-trigger Greptile

Comment thread tools/async_delegation.py Outdated
Comment thread tools/async_delegation.py Outdated
Comment thread gateway/run.py
Comment thread gateway/run.py
Kyzcreig added 4 commits July 11, 2026 17:03
…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
@Kyzcreig
Kyzcreig merged commit 52b51f7 into main Jul 12, 2026
35 checks passed
@Kyzcreig
Kyzcreig deleted the feat/delegation-restart-survival branch July 12, 2026 01:45
Kyzcreig added a commit that referenced this pull request Jul 26, 2026
…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>
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