Skip to content

fix(daemon): normalize bare-vs-qualified subject-id in orphan-reconciliation (REQ-052 node fan-out) - #339

Open
Shooksie wants to merge 15 commits into
release/0.7from
fix/v0.7.0/reconcile-subject-id-normalize
Open

fix(daemon): normalize bare-vs-qualified subject-id in orphan-reconciliation (REQ-052 node fan-out)#339
Shooksie wants to merge 15 commits into
release/0.7from
fix/v0.7.0/reconcile-subject-id-normalize

Conversation

@Shooksie

Copy link
Copy Markdown
Contributor

Root-caused this session: one coding dispatch fanned out to N ephemeral nodes because the orphan-reconciliation exclusion checks compared a delegating runner's QUALIFIED active_subject_ids (task:TASK-632) against a remote node's BARE upstream-journaled subject (TASK-632) — never matched → the node's own Running row was re-dispatched every ~90s tick → duplicate node each time.

Fix: bare_subject_id() helper + normalize both sides before the exclusion checks in recover_orphaned_running_workflows and resumable_orphans_for_redispatch (incl. the live-orphan guard). Unit test added. Released as tag v0.7.0-rc.21 for the portal deploy.

Verified: cargo check/fmt/clippy clean, reconciliation tests pass.

Shooksie added 13 commits July 14, 2026 21:11
…down

Environment plugins are stateful across the three-call contract: prepare
registers an in-memory run (e.g. a live WS relay to a remote node) that
exec/exec_stream/teardown reuse by handle. The previous per-call
resident-host lease dropped between calls, letting the shared registry
LRU-evict or ping-reap the process between prepare and exec — losing that
state and surfacing as "no live relay connection for handle".

EnvironmentClient now holds ONE ResidentHostLease for its lifetime
(acquired lazily on the first RPC via pinned_host), so every RPC lands on
the same pinned process. The client is now non-Clone (callers Arc it) so
the single lease is not duplicated.

Death handling splits by call kind: exec/exec_stream stay at-most-once
(no retry — side effects + lost state), while the idempotent control ops
prepare/teardown retry once. Either way a dead lease is reaped
generation-scoped (drop from the client + invalidate the exact generation
in the registry) so the next run spawns a live process instead of
re-leasing the corpse, and a concurrent call's fresh lease is never
evicted by a late failure of an older generation.

Removes the now-unused with_resident_host / with_resident_host_once /
ping_is_dead helpers and their ping tests.
…eout (REQUIREMENT-049)

prepare shared the 60s ENVIRONMENT_RPC_TIMEOUT, but materializing a real
execution context is slow + fail-prone: the Railway environment plugin creates a
service, waits for deploy, and blocks up to its 300s dial timeout for the
container to dial home, plus workspace clone time. A 60s host-side timeout fired
mid-create and control_rpc's retry-once spun up a SECOND node while the first was
still deploying (leak + failure on slow deploys). Dedicated ENVIRONMENT_PREPARE_TIMEOUT
of 360s covers dial + clone + margin. teardown keeps the 60s control bound.
Move ephemeral node lifecycle from the per-phase runner into the daemon so
all phases of ONE workflow run share ONE node, torn down once at run end.

- New `dispatch::environment_broker::EnvironmentBroker`: a `run_id -> lease`
  map with single-flight `acquire` (per-run mutex; first call resolves a
  daemon-resident `EnvironmentClient` + `prepare`s the node once, later calls
  return the same handle), `exec_stream` proxy, and idempotent `teardown`.
  Resolving the client daemon-side keeps ONE plugin process (one relay = one
  node) pinned across prepare/exec/teardown for the whole run.
- Private local-socket server (interprocess::local_socket tokio, newline-JSON)
  implementing the Acquire + Exec wire contract, per-daemon bearer auth,
  owner-only socket under the scoped state dir.
- Durable JSON lease records (`workflow-environments/<run_id>.json`, temp+rename
  before prepare); startup reaper cold-tears-down records owned by a prior
  daemon instance (dead relay), then deletes them.
- ProcessManager: when a dispatch routes to a NON-local environment (config
  routing rules/default) and a broker is wired, set the four
  ANIMUS_ENVIRONMENT_BROKER_* env vars on the runner and inject
  metadata.animus_run_id into the prepared spec. run_id is stable across a
  run's phases (resume dispatches reuse the persisted workflow id; a fresh
  first phase mints one the runner adopts).
- Terminal-state teardown wired into completed-process reconciliation
  (Completed/Failed/Cancelled/Escalated => teardown; Running/Paused => keep).
- Broker constructed + threaded into ProcessManager in the daemon composition
  path (bind failure is non-fatal: runners fall back to per-phase preparation).
…workflow id (REQUIREMENT-049)

The broker run_id must be stable across every phase of a run so all phases
acquire the SAME node. The original derivation minted wf-<uuid> for a fresh
phase and used resume_workflow_id for later phases — but the runner mints its
OWN workflow id (the daemon can't hand one down: --workflow-id means 'resume an
EXISTING workflow'), so phase 1's minted id never matched phase 2's resume id
=> a different node per phase, no sharing. Key on dispatch.subject_id() instead
(identical across every phase's dispatch, known before phase 1). Subjectless
adhoc runs fall back to the resume-id / fresh mint (single-phase only).
The daemon's EnvironmentBroker gate (configure_environment_broker) resolved the
run environment from kind-level routing rules only, passing workflow_env=None. On
deployments whose only environment config is workflow-level (workflows[].environment),
resolve_environment returned None -> broker disengaged -> each per-phase runner
prepared its OWN ephemeral node -> code-implement's edit never reached code-open-pr's
workspace (no diff -> no PR).

Thread a workflow-id -> environment map into ProcessManager (populated in daemon_run
alongside environment_routing) and feed the dispatch's workflow-level environment as
workflow_env, mirroring how the runner resolves each phase. A workflow with an
environment: now engages the broker (keyed per subject) so every phase shares one node.
Workflows without an environment stay local.

Bump orchestrator-cli 0.7.0-rc.16. TASK-431 / REQUIREMENT-051.
… list_all

reconcile_stale_in_progress_tasks ran every heartbeat and, whenever any task
was in-progress, fetched EVERY workflow run with its full opaque blob
(hub.workflows().list() -> list_all -> journal/list with no filter) just to
cross-reference task_id + terminal status. On a board with ~1200 runs that is a
~6s all-runs scan that monopolized the shared journal host every ~8s — the
portal /workflows intermittent multi-second stalls (steady state was fine).

Add WorkflowServiceApi::list_summaries + WorkflowStateManager::list_all_summaries
backed by the journal's existing no-blob projection (journal/list {summary:true},
now carrying the indexed subject_id). The reconcile leg consumes summaries
(subject_id + status + terminal timestamp) — exact same cross-reference logic,
but ~40ms instead of ~6s and no blob fetch/deserialize. Pairs with
animus-postgres exposing subject_id in the summary projection.

Bumps orchestrator-cli to v0.7.0-rc.17 (on the rc.16 / REQ-048 broker lineage).
…ctive-only

rc.17 fixed the stale-in-progress reconcile but the ~6s /workflows spikes
persisted: OTHER per-tick legs still fetched every run with full blobs
(hub.workflows().list() -> list_all -> unbounded journal/list). Convert them all:

- DaemonTickMetrics::collect (runs EVERY tick — the ~8s spike cadence): count by
  status via list_summaries instead of the all-blobs list.
- reconcile orphan-recovery / journal-resume / manual-timeout: these act only on
  non-terminal runs, so list_active() (pending/running/paused, few rows) not list.
- load_workflow_ref_index (rebuilt every housekeeping tick by the budget scan):
  id + workflow_ref are indexed columns, so use the summary projection.

Adds WorkflowServiceApi::list_active + workflow_ref to WorkflowRunSummary. The
remaining list_all callers are non-tick (CLI history/cost, resume-on-startup,
dev SQLite fallback). Works with the already-deployed animus-postgres bundle
(summary carries subject_id + workflow_ref). Bumps orchestrator-cli to rc.18.
…(rc.19)

Two portal-facing features:

1. `output read --workflow-id X --phase Y` resolves that phase's run_id from the
   phase session checkpoint (runs/<wf>/phases/*.session.json) and reads ITS
   transcript — so the UI can load logs for a specific/earlier phase, not just
   the workflow's latest run.

2. Workflow list gains a type filter + total, for numbered pagination. Repins
   control-protocol v0.7.0-rc.6 -> rc.7 (adds WorkflowListRequest.workflow_ref +
   WorkflowListResponse.total, both additive/optional). The workflow/list control
   handler now forwards workflow_ref into the query filter and returns
   page.total; workflow_ref becomes wire-expressible.

Bumps orchestrator-cli to v0.7.0-rc.19.
The numbered-pager total came from query_ids -> ids.len(), and journal_client
sent limit: None, which the reference backend defaults to 1000 — so a board with
>1000 runs reported total 1000 and the pager capped at 20 pages. Request the
backend's max (10_000) for the id/count query (ids are cheap, no blobs) so the
total is accurate up to that ceiling. Bumps orchestrator-cli to rc.20.
Filtering the /workflows list by type was slow: workflow_query_can_use_db_page
excluded workflow_ref, so a filtered query fell back to list_all() — fetching
every run with full blobs and filtering in memory (~6s). The journal backend's
list/query_ids already support a workflow_ref filter, so thread it through
list_page + query_ids and let workflow_ref stay on the bounded DB-page path.
Filtered lists are now server-side bounded + accurate-total, same as unfiltered.
Folded into rc.20.
…liation

A remote-animus (REQ-052) node runs the delegated workflow standalone and
journals its run's subject UPSTREAM in the BARE form (TASK-1), while the
delegating runner registers that subject QUALIFIED (task:TASK-1) in
active_subject_ids. The orphan-reconciliation exclusion checks compared the
two raw forms, never matched, and misclassified the node's own Running row as
a resumable orphan mid-session -> re-dispatched it every tick -> spawned a
duplicate ephemeral node until dispatch was paused (one enqueue -> N nodes).

Add bare_subject_id() and normalize both sides before the exclusion checks in
recover_orphaned_running_workflows and resumable_orphans_for_redispatch
(including the live-orphan guard). Adds a unit test.
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
animus-docs Ready Ready Preview, Comment Jul 17, 2026 5:45pm

Request Review

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