Skip to content

fix(runner): single durable activation ownership record owned by the orchestrator - #86

Open
wolfy-j wants to merge 10 commits into
masterfrom
fix/overseer-released-owner
Open

wolfy-j wants to merge 10 commits into
masterfrom
fix/overseer-released-owner

Conversation

@wolfy-j

@wolfy-j wolfy-j commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the overseer/orchestrator race: a released or dead orchestrator's name, EXIT and wake signals racing a new spawn.

Ownership model

  • Ownership record on the activation row:
    • fields: owner_token, owner_pid, owner_phase and owner_epoch (the runtime epoch);
    • added by migration 10 on sqlite and postgres; existing active rows are adopted on upgrade.
  • Admission is a compare-and-set under the workflow lock, before load_state. It refuses terminal or inactive activations, stale requests, and a running owner from the same runtime.
  • Every orchestrator write is fenced by the token in commit.execute. Terminal workflows and inactive requests are rejected inside the locked check. Passivate and complete are fenced by token, phase and generation.

Overseer

  • Reconciles from a single locked observation: row plus name.
  • EXIT only triggers a reconcile; it never decides ownership.
  • Live owners are woken once per durable generation.
  • Inactive activations are never stopped; STOP is only sent for terminal workflows.

Loop and restart handling

  • One deadline computation per loop iteration:
    • overdue wakes and retries are handled before blocking;
    • a single timer is armed for the next deadline, with an absolute 30s safety deadline;
    • a large backlog keeps draining without pauses.
  • Pre-admission restarts are bounded: 5 spawns per unchanged ownership state, spaced 1/2/4/8s, then orchestrator_start_failed.

Security

  • The orchestrator entry carries the epoch_reader group, which is merged into the caller's scope.
  • Existing consumer scopes need no change. The README has a caller-security section.

Data cleanup and fixes

  • DELETE_WORKFLOW deletes the activation row explicitly; SQLite does not enforce the FK cascade.
  • Migration 11 removes orphaned activation and wake rows left by earlier versions.
  • Yield wakes reject deadlines that are not RFC 3339.
  • On postgres, wake deadlines are now read at full precision.

Tests

  • Repo, commit, ops, workflow_state and orchestrator fences.
  • Full M.run loop tests with an injected clock.
  • Durable overseer tests against real rows.
  • restricted_caller_test with a realistic consumer scope.
  • runtime_failure_test now waits for durable admission.
  • Upgrade proof make test-upgrade-{sqlite,postgres} (UPGRADE_FROM=v0.7.19).

Verification

  • sqlite: 1074 tests.
  • postgres: 1032 tests.
  • Signal stress: 40/40.
  • Upgrade and restart proofs pass on both dialects.

Reviewed over five rounds by Codex Astra and Fable; both approve.

@wolfy-j
wolfy-j requested a review from msmakouz September 23, 2026 03:03
…ly a running token

verify_owner_tx rejects writes once the workflow is terminal or the request
inactive, so an orchestrator cannot overwrite a durable cancellation.
Admission re-admits the same token only while it is the running owner of an
active request; a released token is admitted as a new owner. Release results
no longer report ownership changes nobody consumes.
…rity

The synchronous path called a separate epoch function under the caller's
scope, which required a new funcs.call grant from existing consumers. The
orchestrator entry now carries the epoch reader group, merged into whatever
scope it runs under, and reads the epoch itself on every path. Admission and
state-creation failures give up the canonical name, a same-runtime owner that
lost its name is reported as such, and the unreachable ownership-change
projection branches are removed.
…ve holders, bound pre-admission restarts

- A monitored owner is woken once for every durable generation it has not
  been woken for, so a request whose COMMIT message was lost, or a deadline
  promoted before an overseer restart, still reaches the live owner.
- An inactive activation stops nobody: a released owner exits by itself and
  any other name holder is refused admission or admitted by a newer request,
  so a stale observation can no longer cancel an admitted successor.
- Spawns for one unchanged ownership state are bounded; an orchestrator that
  keeps exiting before admission fails the activation instead of respawning.
- runtime_failure_test kills the orchestrator only after its admission; the
  overseer is also exercised against real ownership rows.
- Remove the unused wake_repo, the pid_for helper and duplicated literals.
… due

A due wake that could not be promoted made the loop arm time.after(0),
which returns no channel, and crashed the overseer on every restart. The loop
now promotes a due wake immediately and arms a timer only for a future one.
Due promotion discards the wakes of a dataflow that no longer exists, which
SQLite leaves behind because it does not enforce the cascading foreign key;
tests remove their dependent rows explicitly for the same reason.
…greSQL

The PostgreSQL driver renders TIMESTAMPTZ without fractional seconds, so the
overseer saw a wake as due up to a second before the database did and armed no
timer for it; exact deadlines then waited for the safety scan. The deadline is
now formatted in SQL at the stored precision.
… wake

- Spawns for one unchanged ownership state wait 1s, 2s, 4s and 8s between
  attempts, so a transient outage recovers; after five the activation fails
  with orchestrator_start_failed. The exhausted budget is kept until that
  failure is durable and the observed state changes, so a failed write never
  reopens it.
- The service loop promotes due wakes and then arms a timer for the nearest
  wake that is not yet due, so a due wake that cannot be promoted never hides
  a later one and the loop does not spin on it.
- DELETE_WORKFLOW removes the activation row explicitly, as it already did for
  wakes, since SQLite does not enforce the cascading foreign keys; due
  promotion no longer discards wakes of a missing dataflow.
- Document the epoch_reader group for callers with explicit env.get denies.
Each iteration settles everything that is overdue, due wakes and deferred
start retries alike, repeating while it makes progress, and then arms one
timer for the earliest deadline still ahead. Deadlines are read from pending
rows rather than bounded by a freshly sampled now, so a wake or retry that
falls due during the pass is processed before the loop blocks. A due item that
makes no progress is skipped until the next event or safety pass instead of
hiding later deadlines. Start budgets are retired when the observed ownership
changes, including admission, and after a durable start failure; a retry
deadline exists only while a spawn is deferred.
…; prove upgrades from an older release

- Migration 11 deletes activation and wake rows without a workflow, which
  releases before 10_add_activation_ownership left on SQLite.
- restart-proof.sh takes DATAFLOW_RESTART_FROM: the first runtime runs that
  older release, the second upgrades its database in place. On SQLite the
  proof seeds rows of a deleted workflow and checks the upgrade removes them;
  it also checks the recovered owner record. Duplicates are counted against
  the workflows present before the restart, since the first runtime's probe
  can race its migrations.
- make test-upgrade-sqlite / test-upgrade-postgres (UPGRADE_FROM, default
  v0.7.19) run it outside the default test target.
…pass on an absolute schedule

- When settle exhausts its pass budget while still making progress, the next
  deadline is now, so draining continues on the next iteration after pending
  events instead of waiting for the safety pass.
- The safety deadline is absolute and advances only when the safety pass
  runs; it is part of the single deadline timer, so deadline events cannot
  postpone it. The safety pass clears the excluded items.
- A pending wake whose deadline cannot be read counts as progress, so a page
  of such rows cannot hide later valid wakes. register_yield_wake_tx rejects a
  deadline that is not an RFC 3339 time, since SQLite stores it as text.
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.

2 participants