@godspeedai/cognate-runtime-bun 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godspeedai/cognate-runtime-bun",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Bun reference runtime: composes kernel, durable store, runs, continuations, commitments, artifacts, and cognition behind RuntimeService.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,18 +22,18 @@
22
22
  ".": "./src/index.ts"
23
23
  },
24
24
  "dependencies": {
25
- "@godspeedai/cognate-artifacts": "^0.4.0",
26
- "@godspeedai/cognate-capabilities": "^0.4.0",
27
- "@godspeedai/cognate-cognitive-api": "^0.4.0",
28
- "@godspeedai/cognate-continuations": "^0.4.0",
29
- "@godspeedai/cognate-events": "^0.4.0",
30
- "@godspeedai/cognate-kernel-api": "^0.4.0",
31
- "@godspeedai/cognate-kernel-cordis": "^0.4.0",
32
- "@godspeedai/cognate-projections": "^0.4.0",
33
- "@godspeedai/cognate-runtime-api": "^0.4.0",
34
- "@godspeedai/cognate-store-memory": "^0.4.0",
35
- "@godspeedai/cognate-store-postgres": "^0.4.0",
36
- "@godspeedai/cognate-store-sqlite": "^0.4.0"
25
+ "@godspeedai/cognate-artifacts": "^0.6.0",
26
+ "@godspeedai/cognate-capabilities": "^0.6.0",
27
+ "@godspeedai/cognate-cognitive-api": "^0.6.0",
28
+ "@godspeedai/cognate-continuations": "^0.6.0",
29
+ "@godspeedai/cognate-events": "^0.6.0",
30
+ "@godspeedai/cognate-kernel-api": "^0.6.0",
31
+ "@godspeedai/cognate-kernel-cordis": "^0.6.0",
32
+ "@godspeedai/cognate-projections": "^0.6.0",
33
+ "@godspeedai/cognate-runtime-api": "^0.6.0",
34
+ "@godspeedai/cognate-store-memory": "^0.6.0",
35
+ "@godspeedai/cognate-store-postgres": "^0.6.0",
36
+ "@godspeedai/cognate-store-sqlite": "^0.6.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@godspeedai/cognate-cognitive-memory": "workspace:*"
package/src/core.ts CHANGED
@@ -28,6 +28,8 @@ export interface Core {
28
28
  readonly outboxFor: (event: PendingEvent) => readonly OutboxIntent[];
29
29
  /** Abort controllers of executions in progress, by run id (cancellation aborts `RunContext.signal`). */
30
30
  readonly controllers: Map<string, AbortController>;
31
+ /** Stops cooperative execution without cancelling its durable objective. */
32
+ readonly shutdownSignal: AbortSignal;
31
33
  readonly remoteOffers: RemoteOfferRegistry;
32
34
  /** Serialize and (re-)run a run's execution; replay-safe and idempotent. */
33
35
  execute(runId: string): Promise<void>;
package/src/executor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Durable run execution by deterministic replay (ADR-0011, spec §11, §12).
2
2
  // Each execution re-reads the run stream, re-enters the agent from the top, and answers every
3
3
  // RunContext call that already has a recorded result from the stream instead of repeating it.
4
- import { ConcurrencyError, fingerprint, IdempotencyMismatchError, type Json, type NewEvent, type StoredEvent } from "@godspeedai/cognate-events";
4
+ import { ConcurrencyError, fingerprint, IdempotencyMismatchError, type Json, type NewEvent, StaleRunOwnerError, type StoredEvent } from "@godspeedai/cognate-events";
5
5
  import type { Principal, SemanticRef } from "@godspeedai/cognate-kernel-api";
6
6
  import { applyStateUpdate, type ArtifactInput, ContinuationClosedError, FrontendCapabilityError, foldState, InvocationFailedError, type RemoteCapabilityOffer, type RunContext, StatePatchError, type WaitSpec } from "@godspeedai/cognate-runtime-api";
7
7
  import type { Core, RecallResultJson } from "./core.ts";
@@ -14,9 +14,36 @@ class Halt {
14
14
 
15
15
  export type ExecutionResult = "done" | "retry";
16
16
 
17
- export async function executeOnce(core: Core, runId: string): Promise<ExecutionResult> {
17
+ /** The fenced run-write ownership a worker holds for one execution pass
18
+ * (docs/contracts/run-write-ownership.md). The credential fences every run-scoped append; renew
19
+ * refreshes the lease and reports whether ownership was lost. */
20
+ export interface RunExecutionOwnership {
21
+ readonly ownerId: string;
22
+ readonly generation: number;
23
+ readonly ambiguousSteps: "reexecute" | "wait";
24
+ readonly renew: () => Promise<boolean>;
25
+ }
26
+
27
+ export async function executeOnce(core: Core, runId: string, ownership?: RunExecutionOwnership): Promise<ExecutionResult> {
28
+ // Register before the first store await so cancellation and shutdown can reach this attempt
29
+ // while its initial snapshot is being read.
30
+ const controller = new AbortController();
31
+ core.controllers.set(runId, controller);
32
+ const stopForShutdown = () => controller.abort(new Error("runtime closed"));
33
+ if (core.shutdownSignal.aborted) stopForShutdown();
34
+ else core.shutdownSignal.addEventListener("abort", stopForShutdown, { once: true });
35
+ try {
36
+ return await executeRegistered(core, runId, controller, ownership);
37
+ } finally {
38
+ core.shutdownSignal.removeEventListener("abort", stopForShutdown);
39
+ if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
40
+ }
41
+ }
42
+
43
+ async function executeRegistered(core: Core, runId: string, controller: AbortController, ownership?: RunExecutionOwnership): Promise<ExecutionResult> {
18
44
  const streamId = runStream(runId);
19
45
  const events = await core.store.readStream(streamId);
46
+ if (controller.signal.aborted) return "done";
20
47
  const view = foldRun(runId, events);
21
48
  if (!view || isTerminal(view)) return "done";
22
49
  const agent = core.agents.get(view.agent);
@@ -31,8 +58,6 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
31
58
  }
32
59
  const find = (stepId: string, eventType?: string) => recorded.get(stepId)?.find((e) => eventType === undefined || e.eventType === eventType);
33
60
 
34
- const controller = new AbortController();
35
- core.controllers.set(runId, controller);
36
61
  let halted: Halt | undefined;
37
62
  const halt = (retry: boolean): never => {
38
63
  halted ??= new Halt(retry);
@@ -66,13 +91,23 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
66
91
  try {
67
92
  // Pre-assign the id so hooks can derive intents that commit atomically with the event (spec §13.1).
68
93
  const pending = { ...envelope, eventId: crypto.randomUUID(), streamId, recordedAt: envelope.occurredAt };
69
- const outcome = await core.store.commit({ appends: [{ streamId, expectedVersion: version, events: [{ ...envelope, eventId: pending.eventId }] }], outbox: core.outboxFor(pending) });
94
+ const outcome = await core.store.commit({
95
+ appends: [{
96
+ streamId,
97
+ expectedVersion: version,
98
+ events: [{ ...envelope, eventId: pending.eventId }],
99
+ ...(ownership ? { runOwnership: { runId, ownerId: ownership.ownerId, generation: ownership.generation } } : {}),
100
+ }],
101
+ outbox: core.outboxFor(pending),
102
+ });
70
103
  const stored = outcome.events[0]!;
71
104
  version += 1;
72
105
  lastEventId = stored.eventId;
73
106
  return stored;
74
107
  } catch (error) {
75
108
  if (error instanceof ConcurrencyError) halt(true);
109
+ // Ownership was reclaimed or released: this worker is stale and must never retry the pass.
110
+ if (error instanceof StaleRunOwnerError) halt(false);
76
111
  throw error;
77
112
  }
78
113
  });
@@ -134,17 +169,49 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
134
169
  // recorded event) but are a distinct event type, so the completion check must name it explicitly.
135
170
  let done = find(stepId, "run.invocation");
136
171
  if (!done) {
172
+ // Refresh the ownership lease before any external effect: a lost lease must be discovered
173
+ // before dispatch, not after (docs/contracts/run-write-ownership.md).
174
+ if (ownership && !(await ownership.renew())) halt(false);
175
+ const priorProgress = (recorded.get(stepId) ?? []).filter((e) => e.eventType === "run.invocation.progress");
176
+ // A previous owner's generation started this invocation and never recorded its outcome.
177
+ // Under `ambiguousSteps: "wait"` the recovering owner does not re-perform the effect: it
178
+ // records the ambiguity and waits for explicit reconciliation.
179
+ if (ownership && ownership.ambiguousSteps === "wait") {
180
+ const priorGenerations = priorProgress
181
+ .map((e) => field<number>(meta(e), "ownerGeneration"))
182
+ .filter((generation): generation is number => typeof generation === "number" && generation < ownership.generation);
183
+ if (priorGenerations.length > 0) {
184
+ const priorGeneration = Math.max(...priorGenerations);
185
+ const ambiguousStepId = `${stepId}:ambiguous`;
186
+ if (!find(ambiguousStepId, "run.invocation.ambiguous")) {
187
+ await append({ eventType: "run.invocation.ambiguous", payload: { capability, priorGeneration }, metadata: { stepId: ambiguousStepId, capability, priorGeneration } });
188
+ }
189
+ const resolution = await context.waitFor(`ambiguous:${stepId}`, {
190
+ type: "ambiguous-invocation",
191
+ condition: { kind: "ambiguous_invocation", capability, priorGeneration },
192
+ payload: { capability, stepId, priorGeneration },
193
+ });
194
+ const reconciled = (resolution ?? null) as { readonly result?: Json; readonly failure?: { readonly kind: string; readonly message?: string } } | null;
195
+ if (reconciled?.failure) throw new InvocationFailedError(capability, reconciled.failure);
196
+ return reconciled?.result ?? null;
197
+ }
198
+ }
137
199
  // A crash between a progress call and the final run.invocation event leaves partial progress
138
200
  // recorded for this stepId with no completion; re-executing calls the handler again under a
139
201
  // fresh attempt number so consumers can discard the earlier partial attempt.
140
- const priorAttempts = (recorded.get(stepId) ?? [])
141
- .filter((e) => e.eventType === "run.invocation.progress")
142
- .map((e) => field<number>(meta(e), "attempt") ?? 0);
202
+ const priorAttempts = priorProgress.map((e) => field<number>(meta(e), "attempt") ?? 0);
143
203
  const attempt = priorAttempts.length ? Math.max(...priorAttempts) + 1 : 0;
144
204
  let seq = 0;
145
205
  const progress = async (payload: Json): Promise<void> => {
146
206
  guard();
147
- await append({ eventType: "run.invocation.progress", payload: toJson(payload), metadata: { stepId, capability, attempt, seq: seq++ } });
207
+ await append({
208
+ eventType: "run.invocation.progress",
209
+ payload: toJson(payload),
210
+ metadata: {
211
+ stepId, capability, attempt, seq: seq++,
212
+ ...(ownership ? { ownerGeneration: ownership.generation } : {}),
213
+ },
214
+ });
148
215
  };
149
216
  const call = (approval?: { readonly approvalId: string }) =>
150
217
  core.invoker.invoke({
@@ -343,12 +410,14 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
343
410
  payload: toJson(update),
344
411
  metadata: { scope: "thread", threadId: view.correlationId, source: "agent", actor: context.actor.id, stepId: `shared-state.update:${id}` },
345
412
  }],
413
+ ...(ownership ? { runOwnership: { runId, ownerId: ownership.ownerId, generation: ownership.generation } } : {}),
346
414
  }],
347
415
  result: { state, version: events.length + 1 },
348
416
  });
349
417
  return outcome.result as { readonly state: Json; readonly version: number };
350
418
  } catch (error) {
351
419
  if (error instanceof ConcurrencyError) continue;
420
+ if (error instanceof StaleRunOwnerError) halt(false);
352
421
  if (error instanceof IdempotencyMismatchError) throw new Error(`thread state update ${id} was replayed with different input`);
353
422
  throw error;
354
423
  }
@@ -403,6 +472,7 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
403
472
  if (!agent) outcome = { ok: false, error: new Error(`agent ${view.agent} is not registered`) };
404
473
  else {
405
474
  try {
475
+ guard();
406
476
  outcome = { ok: true, value: toJson(await agent.run(field(started.payload, "input"), context)) };
407
477
  } catch (error) {
408
478
  outcome = { ok: false, error };
@@ -417,7 +487,5 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
417
487
  } catch (error) {
418
488
  if (error instanceof Halt) return error.retry ? "retry" : "done";
419
489
  throw error;
420
- } finally {
421
- if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
422
490
  }
423
491
  }
package/src/index.ts CHANGED
@@ -70,9 +70,26 @@ export interface RuntimeOptions {
70
70
  readonly execution?: {
71
71
  readonly maxConcurrentRuns?: number;
72
72
  /** Required above capacity one. Returning [] explicitly declares independent eligibility.
73
- * Trusted resource identities derived from frozen run inputs, never model-granted authority.
74
- * Runs sharing a key serialize in this process; this is not a distributed lock or OS isolation. */
75
- readonly resourceKeys?: (run: RunView) => readonly string[];
73
+ readonly execution?: {
74
+ readonly maxConcurrentRuns?: number;
75
+ /** Required above capacity one. Returning [] explicitly declares independent eligibility.
76
+ * This trusted, side-effect-free policy may asynchronously resolve canonical resource identities
77
+ * from authoritative application metadata. Its signal is aborted when the run is cancelled or the runtime closes.
78
+ * Runs sharing a key serialize in this process; this is not distributed ownership or OS isolation. */
79
+ readonly resourceKeys?: (run: RunView, signal?: AbortSignal) => readonly string[] | Promise<readonly string[]>;
80
+ /** Fenced run-write ownership tuning (docs/contracts/run-write-ownership.md). Claims happen
81
+ * automatically whenever the store implements the extension; SQLite/memory stay legacy. */
82
+ readonly runOwnership?: {
83
+ /** Stable worker identity for claims; defaults to a per-runtime random id. */
84
+ readonly ownerId?: string;
85
+ /** Lease duration in milliseconds; default 30_000. Expiry is reclaim eligibility, not
86
+ * ownership loss — a matching generation stays writable until someone reclaims. */
87
+ readonly leaseMs?: number;
88
+ /** What a recovering owner does with a previous generation's started-but-unrecorded
89
+ * invocation: "reexecute" (historical behavior, default) or "wait" — record an ambiguity
90
+ * and require explicit reconciliation before the external effect may repeat. */
91
+ readonly ambiguousSteps?: "reexecute" | "wait";
92
+ };
76
93
  };
77
94
  readonly system?: Principal;
78
95
  }
package/src/runtime.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  // Composition root: store, kernel, continuations, artifacts, projections, cognition, and the background loop.
2
+ import { randomUUID } from "node:crypto";
2
3
  import { ArtifactService } from "@godspeedai/cognate-artifacts";
3
4
  import { ContinuationService } from "@godspeedai/cognate-continuations";
4
- import { type DurableStore, type Json, NotFoundError, OutboxDispatcher } from "@godspeedai/cognate-events";
5
+ import { type DurableStore, type Json, NotFoundError, OutboxDispatcher, RunOwnershipHeldError } from "@godspeedai/cognate-events";
5
6
  import { createCordisKernel } from "@godspeedai/cognate-kernel-cordis";
6
7
  import type { Principal } from "@godspeedai/cognate-kernel-api";
8
+ import type { RunView } from "@godspeedai/cognate-runtime-api";
7
9
  import { ProjectionRunner } from "@godspeedai/cognate-projections";
8
10
  import { openPostgresStore, resolvePg0Url } from "@godspeedai/cognate-store-postgres";
9
11
  import { openSqliteStore } from "@godspeedai/cognate-store-sqlite";
10
12
  import { COGNITION_PROJECTION, cognitionProjection, recallFor } from "./cognition.ts";
11
13
  import type { Core } from "./core.ts";
12
- import { executeOnce } from "./executor.ts";
14
+ import { executeOnce, type RunExecutionOwnership } from "./executor.ts";
13
15
  import { ExecutionSlots } from "./execution-slots.ts";
14
16
  import { foldRun, isTerminal, RESUME_HANDLER, RUN_EXECUTE_TOPIC, RUNS, runStream } from "./fold.ts";
15
17
  import type { Runtime, RuntimeOptions } from "./index.ts";
@@ -33,8 +35,15 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
33
35
  }
34
36
  }
35
37
  const slots=new ExecutionSlots(capacity);
38
+ // Fenced run-write ownership worker identity (docs/contracts/run-write-ownership.md). Per
39
+ // runtime instance: a restarted process reclaims with a fresh generation.
40
+ const ownerId=options.execution?.runOwnership?.ownerId ?? `worker-${crypto.randomUUID()}`;
41
+ const leaseMs=options.execution?.runOwnership?.leaseMs ?? 30_000;
42
+ // Recovery can enqueue many runs at once. Bound authoritative eligibility lookups with the
43
+ // same primitive/capacity so metadata resolution cannot fan out without limit.
44
+ const eligibilitySlots = new ExecutionSlots(capacity);
36
45
  // Run and resume topics share one pre-claim budget. ExecutionSlots separately enforces
37
- // run-local/resource exclusion for every execution path, including direct recovery.
46
+ // run-local/resource exclusion for every execution path.
38
47
  let admittedDeliveries = 0;
39
48
  const deliveryAdmission = { tryAcquire(): (() => void) | undefined {
40
49
  if (admittedDeliveries >= capacity) return undefined;
@@ -70,8 +79,81 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
70
79
  const continuations = new ContinuationService(store, { policy: { authorize: () => ({ allow: true, reason: "authorized by runtime service" }) }, delivery: DELIVERY, deliveryConcurrency: capacity, deliveryAdmission });
71
80
 
72
81
  let closed = false;
82
+ const shutdown = new AbortController();
83
+ const executionControllers = new Map<string, AbortController>();
84
+ const acquireExecutionSlot = async (runId: string, view: RunView): Promise<(() => void) | undefined> => {
85
+ const resolveKeys = options.execution?.resourceKeys;
86
+ const controller = new AbortController();
87
+ const signal = controller.signal;
88
+ executionControllers.set(runId, controller);
89
+ const abortForShutdown = () => controller.abort(new Error("runtime closed"));
90
+ if (shutdown.signal.aborted) abortForShutdown();
91
+ else shutdown.signal.addEventListener("abort", abortForShutdown, { once: true });
92
+ let onAbort!: () => void;
93
+ const aborted = new Promise<{ readonly kind: "aborted" }>((resolve) => {
94
+ onAbort = () => resolve({ kind: "aborted" });
95
+ if (signal.aborted) onAbort();
96
+ else signal.addEventListener("abort", onAbort, { once: true });
97
+ });
98
+ let releaseEligibility: (() => void) | undefined;
99
+ try {
100
+ const current = foldRun(runId, await store.readStream(runStream(runId)));
101
+ if (closed || signal.aborted || !current || isTerminal(current)) return undefined;
102
+ let keys: readonly string[] = [];
103
+ if (resolveKeys) {
104
+ // A cancelled waiter may still be in this semaphore queue. Attach a release handler now so
105
+ // its eventual permit is returned even when the abort race lets execute() stop waiting.
106
+ const acquisition = eligibilitySlots.acquire([]).then((release) => {
107
+ if (closed || signal.aborted) { release(); return undefined; }
108
+ releaseEligibility = release;
109
+ return release;
110
+ });
111
+ const admitted = await Promise.race([acquisition, aborted]);
112
+ if (!admitted || closed || signal.aborted) return undefined;
113
+ // A callback that ignores cancellation may settle later, but its eventual rejection remains
114
+ // observed by this handler after the abort race has returned.
115
+ const lookup = Promise.resolve().then(async () => {
116
+ if (closed || signal.aborted) return { kind: "aborted" as const };
117
+ try { return { kind: "keys" as const, keys: await resolveKeys(view, signal) }; }
118
+ catch (error) { return { kind: "error" as const, error }; }
119
+ });
120
+ const result = await Promise.race([lookup, aborted]);
121
+ if (result.kind === "aborted") {
122
+ // Cancellation releases the caller immediately, but an uncancellable policy callback
123
+ // still occupies a real lookup slot until it settles. The outcome union observes late
124
+ // rejection, and the queued release preserves the configured concurrency bound.
125
+ const release = releaseEligibility;
126
+ releaseEligibility = undefined;
127
+ void lookup.then(() => release?.());
128
+ return undefined;
129
+ }
130
+ if (result.kind === "error") throw result.error;
131
+ if (!Array.isArray(result.keys)) throw new Error("execution.resourceKeys must resolve to an array");
132
+ keys = result.keys;
133
+ }
134
+ // Keep the run controller registered until both policy and execution-slot admission finish.
135
+ // If cancellation wins while queued, a later grant is immediately returned by this handler.
136
+ const acquisition = slots.acquire(keys).then((release) => {
137
+ if (closed || signal.aborted) { release(); return undefined; }
138
+ return release;
139
+ });
140
+ return await Promise.race([acquisition, aborted.then(() => undefined)]);
141
+ } finally {
142
+ signal.removeEventListener("abort", onAbort);
143
+ shutdown.signal.removeEventListener("abort", abortForShutdown);
144
+ releaseEligibility?.();
145
+ if (executionControllers.get(runId) === controller) executionControllers.delete(runId);
146
+ }
147
+ };
73
148
  const chains = new Map<string, Promise<void>>();
74
149
  const pending = new Set<string>();
150
+ // A runtime epoch gives recovery attempts fresh durable intent IDs after prior intents have
151
+ // already been delivered. Existing pending run.execute intents retain their persisted backoff.
152
+ const recoveryEpoch = randomUUID();
153
+ let recoverySequence = 0;
154
+ const recoveryReconciliationIntervalMs = 250;
155
+ let nextRecoveryReconciliationAt = 0;
156
+ const recoveryOutboxByRun = new Map<string, string>();
75
157
  const deliveries = new Set<Promise<void>>();
76
158
  const trackDelivery=(job:Promise<unknown>):void=>{
77
159
  const tracked=job.then(()=>{},()=>{}).finally(()=>deliveries.delete(tracked));
@@ -81,9 +163,34 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
81
163
  const next = (chains.get(runId) ?? Promise.resolve()).then(async () => {
82
164
  const view=foldRun(runId,await store.readStream(runStream(runId)));
83
165
  if(closed || !view || isTerminal(view))return;
84
- const release=await slots.acquire(options.execution?.resourceKeys?.(view) ?? []);
166
+ const release=await acquireExecutionSlot(runId, view);
167
+ if (!release) return;
85
168
  try{
86
- for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId)) === "done") return;
169
+ if (closed) return;
170
+ // Fenced run-write ownership (docs/contracts/run-write-ownership.md): claim before
171
+ // executing. A held lease means another live worker is executing this run — back off;
172
+ // its completion, suspension or death releases the run for a later pass to reclaim.
173
+ let ownership: RunExecutionOwnership | undefined;
174
+ if (store.claimRunOwnership) {
175
+ try {
176
+ const lease = await store.claimRunOwnership({ runId, ownerId, now: Date.now(), leaseMs });
177
+ ownership = {
178
+ ownerId,
179
+ generation: lease.generation,
180
+ ambiguousSteps: options.execution?.runOwnership?.ambiguousSteps ?? "reexecute",
181
+ renew: () => store.renewRunOwnership!({ runId, ownerId, generation: lease.generation, now: Date.now(), leaseMs }).then((ok) => ok === true),
182
+ };
183
+ } catch (error) {
184
+ if (error instanceof RunOwnershipHeldError) return;
185
+ throw error;
186
+ }
187
+ }
188
+ try{
189
+ for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId, ownership)) === "done") return;
190
+ }finally{
191
+ // Best-effort release: a reclaimed or already-released row simply refuses.
192
+ if (ownership) await store.releaseRunOwnership!({ runId, ownerId, generation: ownership.generation }).catch(() => {});
193
+ }
87
194
  }finally{release();}
88
195
  });
89
196
  const tracked = next.finally(() => {
@@ -104,7 +211,8 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
104
211
  scopeId: kernel.root.id,
105
212
  recall: recallFor(store, options.cognition),
106
213
  outboxFor: options.outboxFor ?? (() => []),
107
- controllers: new Map(),
214
+ controllers: executionControllers,
215
+ shutdownSignal: shutdown.signal,
108
216
  remoteOffers: new RemoteOfferRegistry(),
109
217
  execute,
110
218
  kick: () => kick(),
@@ -122,14 +230,58 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
122
230
  admission: deliveryAdmission,
123
231
  backoff: DELIVERY,
124
232
  deliver: async (item) => {
125
- await execute((item.payload as { runId: string }).runId);
126
- return { executed: true };
233
+ const runId = (item.payload as { runId: string }).runId;
234
+ try {
235
+ await execute(runId);
236
+ return { executed: true };
237
+ } finally {
238
+ // The store retains pending retry/poison state; this map only correlates this process's
239
+ // currently delivered recovery intent and must not retain failed or exhausted attempts.
240
+ if (recoveryOutboxByRun.get(runId) === item.id) recoveryOutboxByRun.delete(runId);
241
+ }
127
242
  },
128
243
  });
129
244
 
245
+ async function persistRecoveryIntents(): Promise<void> {
246
+ // Reconcile only when a new recovery candidate exists. A tracked intent already has a
247
+ // durable retry schedule; rescanning the complete pending topic every timer tick is needless.
248
+ if (pending.size === 0) return;
249
+ const now = performance.now();
250
+ if (now < nextRecoveryReconciliationAt) return;
251
+ // Set the deadline before awaiting: a slow or failing store query must not hot-loop scans.
252
+ nextRecoveryReconciliationAt = now + recoveryReconciliationIntervalMs;
253
+ const pendingItems = await store.listOutbox({ topic: RUN_EXECUTE_TOPIC, status: "pending" });
254
+ const pendingItemIds = new Set(pendingItems.map((item) => item.id));
255
+ for (const [runId, id] of recoveryOutboxByRun) {
256
+ if (pendingItemIds.has(id)) pending.delete(runId);
257
+ else recoveryOutboxByRun.delete(runId);
258
+ }
259
+ const candidates = [...pending].filter((runId) => !recoveryOutboxByRun.has(runId)).slice(0, 100);
260
+ if (candidates.length === 0) return;
261
+ const pendingRunItems = new Map<string, string>();
262
+ for (const item of pendingItems) {
263
+ const runId = (item.payload as { runId?: unknown } | null)?.runId;
264
+ if (typeof runId === "string" && !pendingRunItems.has(runId)) pendingRunItems.set(runId, item.id);
265
+ }
266
+ const intents: { id: string; topic: string; payload: Json }[] = [];
267
+ const selected = new Map<string, string>();
268
+ for (const runId of candidates) {
269
+ const existing = pendingRunItems.get(runId);
270
+ const id = existing ?? `${RUN_EXECUTE_TOPIC}:recovery:${recoveryEpoch}:${++recoverySequence}:${runId}`;
271
+ selected.set(runId, id);
272
+ if (!existing) intents.push({ id, topic: RUN_EXECUTE_TOPIC, payload: { runId } });
273
+ }
274
+ if (intents.length > 0) await store.commit({ appends: [], outbox: intents });
275
+ for (const [runId, id] of selected) {
276
+ recoveryOutboxByRun.set(runId, id);
277
+ pending.delete(runId);
278
+ }
279
+ }
280
+
130
281
  let resumeFirst = false;
131
282
  async function tick(): Promise<void> {
132
283
  for (const record of await continuations.expireDue()) pending.add(record.runId);
284
+ await persistRecoveryIntents();
133
285
  // Dispatchers reserve a bounded set of slots across overlapping drains. A new runnable
134
286
  // arrival can fill a free slot while an earlier long-running delivery is still executing.
135
287
  // Rotate priority: even an empty topic briefly reserves capacity while its claim is
@@ -142,9 +294,6 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
142
294
  trackDelivery(continuations.dispatchResumes());
143
295
  }
144
296
  resumeFirst = !resumeFirst;
145
- const recover=[...pending];
146
- for(const runId of recover)pending.delete(runId);
147
- for(const runId of recover)trackDelivery(execute(runId));
148
297
  }
149
298
 
150
299
  let ticking: Promise<void> | null = null;
@@ -190,7 +339,10 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
190
339
  if (view.status === "running") pending.add(id);
191
340
  for (const continuationId of view.waitingOn) {
192
341
  const record = await continuations.get(continuationId);
193
- if (record && !["OPEN", "BLOCKED", "RESUMED"].includes(record.status)) pending.add(id);
342
+ // A resume delivery can be acknowledged during shutdown before run.resumed is appended.
343
+ // Re-enter closed waits, including RESUMED, even when their outbox receipt is delivered.
344
+ // The run-local execution chain and recorded wait result make duplicate delivery harmless.
345
+ if (record && !["OPEN", "BLOCKED"].includes(record.status)) pending.add(id);
194
346
  }
195
347
  }
196
348
 
@@ -381,6 +533,7 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
381
533
  async close() {
382
534
  if (closed) return;
383
535
  closed = true;
536
+ shutdown.abort();
384
537
  clearInterval(timer);
385
538
  if (approvalTimer) clearInterval(approvalTimer);
386
539
  await ticking;