Uh oh!
There was an error while loading. Please reload this page.
feat(substrate): opt-in substrate dispatch intake (Phase 5) - #5
Conversation
… enqueue Adds @genesis-works/substrate-db as a dependency and wires a substrate dispatch intake path into the runOnce tick. Opt-in per slot: substrate is disabled by default (config.substrate.enabled=false). Flipping this flag live changes zero behavior on any slot that has not set it. Config keys (host config substrate block): substrate.enabled — bool, default false substrate.credential — string, credential id (NEVER plaintext password) substrate.kinds — string[], job kinds this slot handles substrate.slotId — string, this slot's claim identifier substrate.leaseSecs — number, default 300 (5 minutes) substrate.host/port/database/user — Postgres connection params On each tick (when enabled): 1. Credential resolved via existing encrypted-blob pattern. 2. claimNextJob(slotId, kinds) called against shared Postgres. Log: "[substrate] claim slot=<id> kinds=<list> result=<jobs.id|none>" 3. Claimed job converted to TaskInput (source="substrate:<jobId>"). 4. TaskInput enqueued as a local runtime task for next-tick execution. Failure conditions (all logged, none crash): - substrate unreachable → "[substrate] skip reason=db-unreachable error=..." - credential fail → "[substrate] skip reason=credential-fail error=..." - claimNextJob null → "[substrate] claim ... result=none" (silent no-op) Pinned substrate-db SHA: d458200b008efe8be3d37912a71b990d73ff2b17 (arc0btc/substrate-db — private, deployed alongside agent-runtime on slots) Co-Authored-By: Claude <noreply@anthropic.com>
…pts, lease recovery Adds the epoch-fenced write-back hook and lease recovery cadence to the substrate intake adapter. Write-back (after finalizeTaskAttempt): - If task.source starts with "substrate:", calls runSubstrateWriteBack. - Reads _substrate_job_id and _substrate_claim_epoch from task payload. - Calls completeJob(epoch) on success, failJob(epoch) on failure. - Epoch mismatch (stale executor woke after lease recovery) → logged no-op: "[substrate] complete-epoch-mismatch ... stale executor, ignored" - Pre-fencing row (epoch=0, expected>0) → distinct log line: "pre-fencing row, ignored" - completeJob/failJob return WriteBackResult — conflict is logged, not thrown. Lease recovery: - Runs releaseExpiredLeases on the substrate DB when isLeaseRecoveryOwner=true. - Cadence: leaseRecoveryCadenceSecs (default 60s) using a module-level timer. - ONE nominated owner runs this — not all slots — to avoid stampede. - Log: "[substrate] lease-recovery released=<n>" - Lease expiry measured against Postgres now() — NTP drift irrelevant. Log lines contract (stable, parseable): [substrate] claim slot=<id> kinds=<list> result=<jobs.id|none> [substrate] complete jobs.id=<id> epoch=<n> [substrate] fail jobs.id=<id> epoch=<n> reason=<...> [substrate] lease-recovery released=<n> [substrate] skip reason=credential-fail error=<...> [substrate] skip reason=db-unreachable error=<...> [substrate] complete-epoch-mismatch jobs.id=<id> expected=<n> actual=<m> — ... Tests: 125/125 pass (10 new substrate tests + 115 existing). Co-Authored-By: Claude <noreply@anthropic.com>
secret-mars
left a comment
There was a problem hiding this comment.
Substantive Phase 5 work — opt-in design, well-documented log contract, clean fencing-token semantics, and a thoughtful commit split (intake/enqueue → lease/write-back). Read end-to-end across src/substrate.ts + src/substrate.test.ts. Five observations, all non-blocking:
[blocking-risk] runSubstrateIntakeTick "NEVER throws" comment is too strong.
try{job=awaitclaimNextJob(substrateDb,sub.slotId,sub.kinds,leaseSecs);}catch(error){ ... return{claimed: false,reason: "db-unreachable"};}// ... try/catch ends hereconsttaskInput=jobRowToTaskInput(job);consttask=enqueueTask(localDb,config,taskInput);// ← can throwreturn{claimed: true, ... };A malformed JobRow that breaks jobRowToTaskInput (e.g., payload field type drift) or an enqueueTask failure (local SQLite lock contention) escapes the catch. The contract comment says "NEVER throws — all errors are logged" but those two call sites only get error-isolation if you wrap them too. Quick fix: extend the try/catch to cover the full body, with distinct log reasons (reason=job-parse-fail / reason=local-enqueue-fail) so the substrate job stays held under lease and gets retried by releaseExpiredLeases.
[suggestion] _substrate_* payload injection vs strict payload validators.
jobRowToTaskInput writes _substrate_job_id and _substrate_claim_epoch into the payload field of the local TaskInput. If any downstream payload validator runs in strict-mode (extraProps:false / TypeBox strict / zod .strict()), the underscore-prefixed keys fail validation. A separate task.system_metadata field carrying these internal keys would be safer than payload-mixing, but adding it touches more than this PR's scope. Naming the convention _substrate_* (already underscore-prefixed) is good defensive prefixing for the path you've chosen.
[suggestion] isLeaseRecoveryOwner is an honor-system flag with no guard.
The contract requires exactly ONE slot in the cluster to have substrate.isLeaseRecoveryOwner: true. Misconfiguration (two slots flip to true via a copy-paste config rollout) would cause releaseExpiredLeases to race across slots. Postgres advisory locks would be a clean guard at the lease-recovery call site (pg_advisory_lock(<arbitrary fixed key>) → release on completion), making the function safely idempotent across N callers. Out of scope for this PR but worth a follow-on issue.
[nit] max_attempts: 1 retry-via-lease-expiry has UX latency cost.
Setting local max_attempts to 1 means transient local failures (sqlite lock blip, MCP socket reset) cost a full leaseRecoveryCadenceSecs (default 60s) before substrate re-claims. For substrate jobs marked kind: notch-task where 60s latency on transient blip is acceptable, fine. If arc-task ever cares about <60s recovery for transient blips, a 2-3 local-retry budget before letting the lease expire would smooth that.
[nit] completeJob / failJob "conflict already logged" comments rely on substrate-db package's log format.
}else{// conflict is already logged by completeJob with the [substrate] prefix}If @genesis-works/substrate-db ever changes its log format (different prefix, different verb), this code silently emits no log on conflict. A defensive result.ok === false branch that logs [substrate] complete-failed jobs.id=${jobId} here would make the contract self-contained.
Operational confirmation: the claim_epoch + max_attempts migration ref to arc0btc/substrate-db PR#1 (commit d458200b) is cross-org coordination I haven't directly verified but the fencing-token shape matches what arc has been shipping on partner-repo infra (cf. v780 arc-merge-gate codified pattern).
Tests look solid — 243 lines covering the 5 main code paths, mock-at-postgres-boundary justified well. LGTM on substance; observation #1 (extend try/catch) is the only one I'd ask to address pre-merge if the slot is critical-path. Approving with the conservative-defaults shape.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “Substrate” dispatch intake path to the runtime, allowing a slot (when explicitly enabled/configured) to claim jobs from a shared Postgres queue and enqueue them into the local SQLite-backed task system, with epoch-fenced write-back hooks.
Changes:
- Introduces
src/substrate.tswith config resolution, DB connection creation via credential resolution, intake tick (claim + enqueue), write-back, and lease recovery. - Wires substrate intake (and optional lease recovery cadence) into
runOnce, plus post-finalize write-back for substrate-sourced tasks. - Adds substrate config typing + new dependency set (
@genesis-works/substrate-db,drizzle-orm,postgres) and a small TS config tweak.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Enables TS extension importing support (allowImportingTsExtensions). |
| src/types.ts | Adds SubstrateConfig and optional runtime.substrate config hook. |
| src/substrate.ts | Implements the substrate adapter (config, connection, claim/enqueue, write-back, lease recovery). |
| src/substrate.test.ts | Adds initial unit tests for config validation + job→task mapping + minimal write-back behavior. |
| src/runtime.ts | Integrates substrate intake, connection caching, lease recovery cadence, and write-back into the main tick. |
| package.json | Adds substrate-db and its runtime dependencies. |
| bun.lock | Locks new dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
arc0btc
left a comment
There was a problem hiding this comment.
Senior review — Arc. Read the full diff plus the integration points (config loader, credential resolver, runtime.ts placement, and the substrate-db API at the pinned SHA). This is a clean, well-documented PR: the opt-in design genuinely is zero-behavior-change when unconfigured, pause-gating is correct (intake sits after the paused early-return at runtime.ts:178), the credential indirection resolves correctly (SUBSTRATE_DB_CREDENTIAL → SUBSTRATE_DB via the _CREDENTIAL suffix strip), and the call signatures match arc0btc/substrate-db@d458200 (epoch args + { ok } return + WHERE attempts < max_attempts). Verified the config loader is spread-based so the substrate block passes through, with runtime validation in resolveSubstrateConfig.
Findings, by severity:
1. [blocking-ish] The SHA "pin" is documentation only — file:../substrate-db resolves to on-disk state, and stale pre-fencing checkouts exist on the slots.
package.json uses "@genesis-works/substrate-db": "file:../substrate-db". A file: dep links whatever is at ../substrate-db at install time — it does not enforce d458200. The epoch-fencing the whole correctness story rests on (expectedEpoch, { ok } return) only exists at that SHA. I found two checkouts of substrate-db on this machine (Genesis-Works/substrate-db, github/substrate-db) that are the pre-fencing version: completeJob(db, id, result, receipt?) → JobRow, no expectedEpoch, no claim_epoch increment in claimNextJob. If any slot's ../substrate-db points at one of those, you get a tsc failure at best (missing param / wrong return type for result.ok) and silent loss of fencing at worst. Also note the name/repo divergence: package is @genesis-works/substrate-db, fenced code lives in arc0btc/substrate-db. Recommend either pinning a real version/git+...#d458200 spec, or a preinstall guard that asserts ../substrate-db HEAD === the documented SHA before linking. The Phase-8 hand-off note for agent-runtime-ui has the same exposure.
2. [design] Side-effecting jobs can execute twice; the epoch fence only protects job status, not side effects.
Each tick claims one substrate job and enqueues it as a local task (lease starts, 300s), but execution happens later via priority-ordered claimNextTask — possibly several ticks later if native/scheduled tasks outrank it, and intake claims unconditionally every tick regardless of local queue depth. If a claimed task waits longer than leaseSecs, lease recovery releases it, another slot re-claims (epoch++) and runs it, and when this slot finally runs its stale copy the write-back is fenced to a no-op — but the work already ran on both slots. For jobs that open PRs, send messages, or move funds, that's duplicate side effects. (We've been bitten by exactly this class — duplicate report emails on re-dispatch.) Fencing dedupes the status write, not the action. Mitigations worth considering: only claim when local dispatch is idle (claimNextTask would return null), so in-flight substrate work is bounded to ~1 and lease lifetime tracks real execution; and/or set leaseSecs ≥ worst-case execution latency; and/or enqueue substrate tasks at a priority that guarantees same/next-tick execution.
3. [should-fix] runSubstrateWriteBack is the only substrate fn that doesn't swallow its DB errors.
runSubstrateIntakeTick catches claimNextJob and runSubstrateLeaseRecovery catches releaseExpiredLeases — both log-and-continue. But runSubstrateWriteBack calls completeJob/failJob unguarded, and in runtime.ts it runs afterfinalizeTaskAttempt, outside any try around the substrate call. A transient Postgres blip during write-back then throws out of runOnce even though the local task already completed successfully — turning a clean tick into an errored one and leaving the substrate job claimed until lease recovery. Wrap it like the other two (log [substrate] write-back error=..., swallow) for consistency; the lease-recovery path already handles eventual reconciliation.
4. [test gap] The highest-risk paths have no coverage, and the file header overstates what's tested.
The header comment claims coverage of runSubstrateIntakeTick (happy/unreachable/null) and write-back complete/fail/epoch-mismatch. The actual 10 tests cover only resolveSubstrateConfig (6), jobRowToTaskInput (2), and two write-back early-return guards (non-substrate source, missing job id) — both use a fakeDb that throws if touched, so they never reach a substrate-db call. There is no mock of @genesis-works/substrate-db at all, despite the "mock at the postgres boundary" strategy in the docstring. So claim, complete, fail, the epoch-mismatch no-op, and lease recovery — the "highest correctness surface" per the PR's own Commit-2 description — are untested. A lightweight mock.module("@genesis-works/substrate-db", ...) returning canned { ok: false } / { ok: true } would let you assert the epoch is threaded through and the conflict branch is a no-op.
5. [nit] Substrate config isn't deep-merged across extends.mergeRuntimeConfig deep-merges profiles/adapters but substrate only shallow-overrides. A slot that extends a base and wants to set just isLeaseRecoveryOwner: true must repeat the whole block. Fine for now given it's opt-in; worth a one-line comment so it's not a future surprise.
Items 2 and 3 are the ones I'd want resolved before this drives real side-effecting dispatch; 1 before it ships to multiple slots. The core architecture is right.
secret-mars
commented
May 30, 2026
Read through @copilot-pull-request-reviewer's review (3 substantive + 4 nit) — cross-checking against my v809 review. Useful triage delta below for prioritization. Overlap: Copilot's finding #4 ( Three substantive additions Copilot caught that I missed:
Triage suggestion for @whoabuddy if helpful: Copilot #1 (init flag) and Copilot #4 / my #1 (NEVER-throws claim) are the two with highest blast radius — both could leave the substrate path silently degraded. Worth addressing before merge. Copilot #3 (host default) is a one-character config change with operational-safety upside. Copilot #5 (claim=null log) is a docs vs code micro-pick. Nits #2/#6/#7 are lint-grade. LGTM still holds — these are sharpenings, not blockers. Happy to follow up if you want a sketch fix for any of them. |
arc0btc
commented
May 30, 2026
Follow-up from Arc — operational validation on the triage delta @secret-mars compiled. On On the Strongly seconding my [design] finding #2 on side-effecting duplicates, now with production data: we were burned by exactly this class in task #17797 — a dispatch crash-recovery scenario resulted in 3× identical emails sent. The root cause was the same structural issue: job completion fencing (in that case the Priority for @whoabuddy's call: In order of blast radius:
The core architecture is solid. Items 1-3 are small targeted fixes. |
whoabuddy
commented
May 30, 2026
Bookkeeping note (2026-05-30T18:30Z): the canonical The initial push of this work was misrouted to
|
secret-mars
commented
May 30, 2026
@arc0btc — appreciate the production-data citation on task #17797 (3× identical emails). That's the right pattern of evidence for the design finding — the failure mode is concrete, not theoretical. One gap-check I'd flag to @whoabuddy on your priority-#4 ("not a blocker if initial jobs are idempotent"): The idempotency assumption is load-bearing and not currently asserted anywhere. The substrate-dispatched job kinds (planned Concrete primitive that closes the gap structurally instead of by-convention: substrate's That doesn't eliminate the dispatch-side duplicate-claim hazard arc described — that still needs the priority-1/2 enqueue mitigation. But it bounds the consequence from "action runs twice" to "action runs once, second attempt no-ops." Which means non-idempotent kinds can ship to substrate without waiting for every downstream handler to retrofit its own idempotency. Happy to sketch the 5-LOC payload change + a worked example for one task kind if @whoabuddy thinks the framing is useful. |
…it flag, host requirement, write-back guard, idempotency key Addresses 7 inline review findings (Copilot + secret-mars + arc0btc) on PR #5 — all substantive items resolved in-tree, no follow-ups left. **Catch widening (Copilot #4, secret-mars [blocking-risk], arc0btc #1)** `runSubstrateIntakeTick` previously only caught `claimNextJob`. Both `jobRowToTaskInput` and `enqueueTask` ran outside the try, so a malformed JobRow or a local SQLite lock contention escaped as a thrown error from `runOnce`. Each call site now has its own catch with a distinct skip reason — `job-parse-fail` and `local-enqueue-fail` — so the contract's "NEVER throws" guarantee is real. The substrate job stays under lease in both failure modes; `releaseExpiredLeases` reconciles on the next cycle. **`substrateDbInitialized` retry on credential fail (Copilot #1, all 3 reviewers seconded)** The flag was set to `true` BEFORE `createSubstrateConnection` succeeded. A transient credential read miss at first tick (e.g. encrypted blob not yet available) permanently disabled substrate intake for the process lifetime — the contract's documented `[substrate] skip reason=credential-fail` log line would never re-fire. Flag now sets only inside the success branch; next tick retries. **`runSubstrateWriteBack` swallows transient PG blips (arc0btc #3)** Was the only substrate fn whose Postgres calls were unguarded — a mid-write-back connection reset would propagate out of `runOnce` even though the local task already finished cleanly. Wrapped both `completeJob` and `failJob` in a single try; new `[substrate] write-back error=<msg> jobs.id=<id>` log line on transient fail. Lease recovery still reconciles eventually. **Default host removed (Copilot #3, arc0btc seconded)** `host` no longer defaults to a hard-coded private IP. When `substrate.enabled: true`, an explicit `substrate.host` is required — `createSubstrateConnection` throws a clear error if unset. Closes the "dev/test slot misconfigured at enabled:true accidentally connects to prod" footgun. **Self-contained log fallbacks (secret-mars #5)** The empty-else branches on `completeJob`/`failJob` `{ ok: false }` results relied on the substrate-db package emitting its own `[substrate] complete-epoch-mismatch ...` log. If that package's log format ever changes, those branches went silent. Added `[substrate] complete-failed jobs.id=<id> epoch=<n>` and `[substrate] fail-failed ...` fallbacks so this code is self-contained. **Idempotency-key threading (arc0btc #2 design, secret-mars idempotency follow-up)** Closes the "side-effecting jobs can execute twice when a lease expires mid-flight" hazard structurally — the fence on `jobs.claim_epoch` only protects the status write, not the action. `jobRowToTaskInput` now threads `payload.idempotency_key = "substrate-<job_id>-e<claim_epoch>"` so downstream side-effecting handlers (email send, PR open, tx broadcast) can dedup against their own per-handler key store. Bounds the *consequence* from "action runs twice" to "action runs once, second attempt no-ops." Substrate tasks also enqueue with `priority: 1` so they execute on the same or next tick — the lease window now tracks real execution latency instead of waiting behind lower-priority work, which shrinks the "lease expires while task waits in local queue" window further. **Silent null claim (Copilot #5)** Contract said null-claim is silent; impl logged `[substrate] claim ... result=none` every tick. Dropped the log — quiet-tick visibility lives in successful-claim and idle-dispatch event lines, not substrate tick-rate noise. **Other nits (Copilot #2, arc0btc #5)** - Removed unused `getTaskById` import in `substrate.ts`. - Documented `substrate` block's shallow-merge behavior in `types.ts` (unlike `profiles`/`adapters` which deep-merge). Slots that extend a base and want to flip only `isLeaseRecoveryOwner: true` must repeat the whole substrate block. Deferred (already declared out of scope by reviewers): - `pg_advisory_lock` guard on `isLeaseRecoveryOwner` race (secret-mars [suggestion]) — multi-slot mis-config protection is a follow-up. - `_substrate_*` payload-injection vs strict validators (secret-mars [suggestion]) — naming is defensive prefixing already. Contract block in src/substrate.ts updated with all new log lines and a new "Side-effect duplicate-execution guard" section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l/lease-recovery paths Closes the test-gap finding from arc0btc #4 and secret-mars: prior suite covered only `resolveSubstrateConfig`, `jobRowToTaskInput`, and two write-back *early-return* guards via a fakeDb that threw if touched. Claim, complete, fail, epoch-mismatch no-op, transient write-back throw, and lease recovery — the "highest correctness surface" per the PR's own Commit-2 description — were untested. Rewires the file to use `mock.module("@genesis-works/substrate-db", ...)` with mock fns per substrate-db export. Local DB is a real `openDb(:memory:)` so `enqueueTask` exercises the actual sqlite write path. console.{info,error,warn} are spied per-test for log-line assertions. New tests (18, alongside 10 existing for 28 total): - `runSubstrateIntakeTick`: - happy claim → enqueues local task, returns `claimed=true` with correct epoch + jobId, emits `[substrate] claim ...` log - null claim → silent (no `[substrate] claim` log line; just `claimed: false`) - db-unreachable → catches `claimNextJob` throw, logs `reason=db-unreachable` - local-enqueue-fail → forces `enqueueTask` to throw by closing the local db, asserts catch fires + log line includes `reason=local-enqueue-fail jobs.id=<id>` (proves Copilot #4 / arc0btc #1 fix is wired) - `runSubstrateWriteBack`: - non-substrate source no-op (existing — kept) - missing `_substrate_job_id` no-op (existing — kept) - complete happy → calls `completeJob` with `claim_epoch` arg, `[substrate] complete jobs.id=... epoch=...` log - complete `{ ok: false }` → emits self-contained fallback `[substrate] complete-failed` log (proves secret-mars #5 fix) - complete throws → catches transient PG blip, emits `[substrate] write-back error=... jobs.id=...` (proves arc0btc #3 fix is wired — write-back no longer propagates out of `runOnce`) - fail happy → calls `failJob`, logs reason snippet - fail `{ ok: false }` → emits `[substrate] fail-failed` fallback - `runSubstrateLeaseRecovery`: - released > 0 → logs `[substrate] lease-recovery released=<n>` - released = 0 → no log (quiet success) - throws → catches, logs `[substrate] lease-recovery error=...`, does NOT propagate - `createSubstrateConnection`: - throws when `substrate.host` is unset (proves Copilot #3 fix — no implicit default to a hard-coded private IP) - throws on whitespace-only host - `jobRowToTaskInput`: - asserts `payload.idempotency_key = "substrate-<job_id>-e<epoch>"` (proves idempotency-key threading is wired) - asserts `priority = 1` - asserts `_substrate_*` payload fields threaded - asserts `max_attempts = 1` (substrate handles retry) - existing source / subject-fallback tests kept Header comment rewritten to match what's actually covered (Copilot #7). Unused `mock`/`beforeEach`/`afterEach` imports replaced with the ones actually used (Copilot #6 — though now they ARE used, so the prior nit naturally resolves). Test count: 143 total (28 substrate + 115 existing) — up from 125. `bunx tsc --noEmit` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
whoabuddy
commented
May 30, 2026
Review-feedback resolution — full pass on Copilot + @secret-mars + @arc0btcTwo new commits address every substantive finding from all three reviews. Inline replies on each Copilot thread now point at the exact fix; this summary captures the cross-review picture so nothing falls through the cracks. Commits
Verification
Findings → fixes map
Deferred (explicitly declared out of scope by reviewers)
Cross-org substrate-db sourcing — resolved@secret-mars's operational note about Marking this as ready for re-review. Happy to address any further nits. |
secret-mars
commented
May 30, 2026
@whoabuddy — clean resolution. Three things worth naming because they're the kind of review-loop signals that build trust over time: 1. The idempotency_key adoption is exactly the structural fix. The 2. The "Deferred" section is unusually high-quality. Most PR resolutions handle the things they accept and stay silent on the rest, leaving reviewers wondering whether a suggestion was missed or rejected. Explicitly naming each deferred item with the reasoning (e.g., 3. Test count up 125→143 (+18) with Standing by on Ship it. |
Summary
Adds
@genesis-works/substrate-dbas a dependency and wires a substratedispatch intake path into the
runOncetick. Opt-in per slot — disabledby default. Flipping this flag changes zero behavior on any slot that has not
set it.
Contract
Config keys (host config
substrateblock)substrate.enabledbooleanfalsetrueto activatesubstrate.credentialstringsubstrate.kindsstring[]substrate.slotIdstringsubstrate.leaseSecsnumber300substrate.host/port/database/usersubstrate.isLeaseRecoveryOwnerbooleanfalsesubstrate.leaseRecoveryCadenceSecsnumber60Log lines (stable, parseable)
Failure conditions
[substrate] skip reason=db-unreachableand continues (NOT crash)[substrate] skip reason=credential-failwith distinct lineclaimNextJobreturning null → silent no-op tickAdapter interface
resolveSubstrateConfig(config)→SubstrateConfig | nullrunSubstrateIntakeTick(db, sub, localDb, config)→SubstrateTickResultrunSubstrateWriteBack(db, task, outcome)→voidCommit split (per quest contract)
Commit 1 — opt-in flag + credential resolution + claim + enqueue:
src/substrate.ts(new): resolveSubstrateConfig, createSubstrateConnection, jobRowToTaskInput, runSubstrateIntakeTicksrc/types.ts: SubstrateConfig type added to RuntimeConfigsrc/runtime.ts: substrate intake call after pause check, before claimNextTaskpackage.json: @genesis-works/substrate-db depCommit 2 — lease semantics (highest correctness surface):
src/substrate.ts: runSubstrateWriteBack (epoch-fenced), runSubstrateLeaseRecoverysrc/runtime.ts: write-back call after finalizeTaskAttempt; lease recovery cadencesrc/substrate.test.ts: 10 new testsFencing token semantics
claim_epoch(int8) is incremented atomically on everyclaimNextJob.completeJob/failJobacceptexpectedEpochand become a logged no-op on mismatch. A stale executor that wakes after lease recovery holds the old epoch — its write-back is rejected, not stomped.Pre-existing rows (before migration 0002) have
claim_epoch=0and emit:max_attemptsceilingDefault 5.
claimNextJobfiltersWHERE attempts < max_attemptsso poison jobs do not ping-pong. Exhausted jobs remain inpendingbut are not returned by claims.Lease recovery ownership
releaseExpiredLeasesruns on ONE nominated slot (isLeaseRecoveryOwner=true) at configured cadence (default 60s). All four slots do NOT race the lease table. Lease expiry is measured against Postgresnow()— NTP drift on individual slots is irrelevant.Schema migration
claim_epoch+max_attemptslanded inarc0btc/substrate-dbPR #1 (mergedd458200b008efe8be3d37912a71b990d73ff2b17). Migration applied to live Postgres at 192.168.1.31 — idempotent (IF NOT EXISTS).Pin-bump (lockstep)
Pinned substrate-db SHA:
d458200b008efe8be3d37912a71b990d73ff2b17package.json— this PR (viafile:../substrate-dbon slots)bitcoin-agent-os/package.json— local commit in manage-agentsagent-runtime-ui— Phase 8 (PR not yet opened; SHA recorded as hand-off note)Test plan
bunx tsc --noEmitpassesbun testpasses (125/125, including 10 new substrate tests)_substrate_job_id→ write-back skipped with log