@godspeedai/cognate-runtime-bun 0.4.0 → 0.5.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.5.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.5.0",
26
+ "@godspeedai/cognate-capabilities": "^0.5.0",
27
+ "@godspeedai/cognate-cognitive-api": "^0.5.0",
28
+ "@godspeedai/cognate-continuations": "^0.5.0",
29
+ "@godspeedai/cognate-events": "^0.5.0",
30
+ "@godspeedai/cognate-kernel-api": "^0.5.0",
31
+ "@godspeedai/cognate-kernel-cordis": "^0.5.0",
32
+ "@godspeedai/cognate-projections": "^0.5.0",
33
+ "@godspeedai/cognate-runtime-api": "^0.5.0",
34
+ "@godspeedai/cognate-store-memory": "^0.5.0",
35
+ "@godspeedai/cognate-store-postgres": "^0.5.0",
36
+ "@godspeedai/cognate-store-sqlite": "^0.5.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
@@ -15,8 +15,25 @@ class Halt {
15
15
  export type ExecutionResult = "done" | "retry";
16
16
 
17
17
  export async function executeOnce(core: Core, runId: string): Promise<ExecutionResult> {
18
+ // Register before the first store await so cancellation and shutdown can reach this attempt
19
+ // while its initial snapshot is being read.
20
+ const controller = new AbortController();
21
+ core.controllers.set(runId, controller);
22
+ const stopForShutdown = () => controller.abort(new Error("runtime closed"));
23
+ if (core.shutdownSignal.aborted) stopForShutdown();
24
+ else core.shutdownSignal.addEventListener("abort", stopForShutdown, { once: true });
25
+ try {
26
+ return await executeRegistered(core, runId, controller);
27
+ } finally {
28
+ core.shutdownSignal.removeEventListener("abort", stopForShutdown);
29
+ if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
30
+ }
31
+ }
32
+
33
+ async function executeRegistered(core: Core, runId: string, controller: AbortController): Promise<ExecutionResult> {
18
34
  const streamId = runStream(runId);
19
35
  const events = await core.store.readStream(streamId);
36
+ if (controller.signal.aborted) return "done";
20
37
  const view = foldRun(runId, events);
21
38
  if (!view || isTerminal(view)) return "done";
22
39
  const agent = core.agents.get(view.agent);
@@ -31,8 +48,6 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
31
48
  }
32
49
  const find = (stepId: string, eventType?: string) => recorded.get(stepId)?.find((e) => eventType === undefined || e.eventType === eventType);
33
50
 
34
- const controller = new AbortController();
35
- core.controllers.set(runId, controller);
36
51
  let halted: Halt | undefined;
37
52
  const halt = (retry: boolean): never => {
38
53
  halted ??= new Halt(retry);
@@ -403,6 +418,7 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
403
418
  if (!agent) outcome = { ok: false, error: new Error(`agent ${view.agent} is not registered`) };
404
419
  else {
405
420
  try {
421
+ guard();
406
422
  outcome = { ok: true, value: toJson(await agent.run(field(started.payload, "input"), context)) };
407
423
  } catch (error) {
408
424
  outcome = { ok: false, error };
@@ -417,7 +433,5 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
417
433
  } catch (error) {
418
434
  if (error instanceof Halt) return error.retry ? "retry" : "done";
419
435
  throw error;
420
- } finally {
421
- if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
422
436
  }
423
437
  }
package/src/index.ts CHANGED
@@ -70,9 +70,10 @@ 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
+ * This trusted, side-effect-free policy may asynchronously resolve canonical resource identities
74
+ * from authoritative application metadata. Its signal is aborted when the run is cancelled or the runtime closes.
75
+ * Runs sharing a key serialize in this process; this is not distributed ownership or OS isolation. */
76
+ readonly resourceKeys?: (run: RunView, signal?: AbortSignal) => readonly string[] | Promise<readonly string[]>;
76
77
  };
77
78
  readonly system?: Principal;
78
79
  }
package/src/runtime.ts CHANGED
@@ -1,9 +1,11 @@
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
5
  import { type DurableStore, type Json, NotFoundError, OutboxDispatcher } 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";
@@ -33,8 +35,11 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
33
35
  }
34
36
  }
35
37
  const slots=new ExecutionSlots(capacity);
38
+ // Recovery can enqueue many runs at once. Bound authoritative eligibility lookups with the
39
+ // same primitive/capacity so metadata resolution cannot fan out without limit.
40
+ const eligibilitySlots = new ExecutionSlots(capacity);
36
41
  // Run and resume topics share one pre-claim budget. ExecutionSlots separately enforces
37
- // run-local/resource exclusion for every execution path, including direct recovery.
42
+ // run-local/resource exclusion for every execution path.
38
43
  let admittedDeliveries = 0;
39
44
  const deliveryAdmission = { tryAcquire(): (() => void) | undefined {
40
45
  if (admittedDeliveries >= capacity) return undefined;
@@ -70,8 +75,81 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
70
75
  const continuations = new ContinuationService(store, { policy: { authorize: () => ({ allow: true, reason: "authorized by runtime service" }) }, delivery: DELIVERY, deliveryConcurrency: capacity, deliveryAdmission });
71
76
 
72
77
  let closed = false;
78
+ const shutdown = new AbortController();
79
+ const executionControllers = new Map<string, AbortController>();
80
+ const acquireExecutionSlot = async (runId: string, view: RunView): Promise<(() => void) | undefined> => {
81
+ const resolveKeys = options.execution?.resourceKeys;
82
+ const controller = new AbortController();
83
+ const signal = controller.signal;
84
+ executionControllers.set(runId, controller);
85
+ const abortForShutdown = () => controller.abort(new Error("runtime closed"));
86
+ if (shutdown.signal.aborted) abortForShutdown();
87
+ else shutdown.signal.addEventListener("abort", abortForShutdown, { once: true });
88
+ let onAbort!: () => void;
89
+ const aborted = new Promise<{ readonly kind: "aborted" }>((resolve) => {
90
+ onAbort = () => resolve({ kind: "aborted" });
91
+ if (signal.aborted) onAbort();
92
+ else signal.addEventListener("abort", onAbort, { once: true });
93
+ });
94
+ let releaseEligibility: (() => void) | undefined;
95
+ try {
96
+ const current = foldRun(runId, await store.readStream(runStream(runId)));
97
+ if (closed || signal.aborted || !current || isTerminal(current)) return undefined;
98
+ let keys: readonly string[] = [];
99
+ if (resolveKeys) {
100
+ // A cancelled waiter may still be in this semaphore queue. Attach a release handler now so
101
+ // its eventual permit is returned even when the abort race lets execute() stop waiting.
102
+ const acquisition = eligibilitySlots.acquire([]).then((release) => {
103
+ if (closed || signal.aborted) { release(); return undefined; }
104
+ releaseEligibility = release;
105
+ return release;
106
+ });
107
+ const admitted = await Promise.race([acquisition, aborted]);
108
+ if (!admitted || closed || signal.aborted) return undefined;
109
+ // A callback that ignores cancellation may settle later, but its eventual rejection remains
110
+ // observed by this handler after the abort race has returned.
111
+ const lookup = Promise.resolve().then(async () => {
112
+ if (closed || signal.aborted) return { kind: "aborted" as const };
113
+ try { return { kind: "keys" as const, keys: await resolveKeys(view, signal) }; }
114
+ catch (error) { return { kind: "error" as const, error }; }
115
+ });
116
+ const result = await Promise.race([lookup, aborted]);
117
+ if (result.kind === "aborted") {
118
+ // Cancellation releases the caller immediately, but an uncancellable policy callback
119
+ // still occupies a real lookup slot until it settles. The outcome union observes late
120
+ // rejection, and the queued release preserves the configured concurrency bound.
121
+ const release = releaseEligibility;
122
+ releaseEligibility = undefined;
123
+ void lookup.then(() => release?.());
124
+ return undefined;
125
+ }
126
+ if (result.kind === "error") throw result.error;
127
+ if (!Array.isArray(result.keys)) throw new Error("execution.resourceKeys must resolve to an array");
128
+ keys = result.keys;
129
+ }
130
+ // Keep the run controller registered until both policy and execution-slot admission finish.
131
+ // If cancellation wins while queued, a later grant is immediately returned by this handler.
132
+ const acquisition = slots.acquire(keys).then((release) => {
133
+ if (closed || signal.aborted) { release(); return undefined; }
134
+ return release;
135
+ });
136
+ return await Promise.race([acquisition, aborted.then(() => undefined)]);
137
+ } finally {
138
+ signal.removeEventListener("abort", onAbort);
139
+ shutdown.signal.removeEventListener("abort", abortForShutdown);
140
+ releaseEligibility?.();
141
+ if (executionControllers.get(runId) === controller) executionControllers.delete(runId);
142
+ }
143
+ };
73
144
  const chains = new Map<string, Promise<void>>();
74
145
  const pending = new Set<string>();
146
+ // A runtime epoch gives recovery attempts fresh durable intent IDs after prior intents have
147
+ // already been delivered. Existing pending run.execute intents retain their persisted backoff.
148
+ const recoveryEpoch = randomUUID();
149
+ let recoverySequence = 0;
150
+ const recoveryReconciliationIntervalMs = 250;
151
+ let nextRecoveryReconciliationAt = 0;
152
+ const recoveryOutboxByRun = new Map<string, string>();
75
153
  const deliveries = new Set<Promise<void>>();
76
154
  const trackDelivery=(job:Promise<unknown>):void=>{
77
155
  const tracked=job.then(()=>{},()=>{}).finally(()=>deliveries.delete(tracked));
@@ -81,8 +159,10 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
81
159
  const next = (chains.get(runId) ?? Promise.resolve()).then(async () => {
82
160
  const view=foldRun(runId,await store.readStream(runStream(runId)));
83
161
  if(closed || !view || isTerminal(view))return;
84
- const release=await slots.acquire(options.execution?.resourceKeys?.(view) ?? []);
162
+ const release=await acquireExecutionSlot(runId, view);
163
+ if (!release) return;
85
164
  try{
165
+ if (closed) return;
86
166
  for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId)) === "done") return;
87
167
  }finally{release();}
88
168
  });
@@ -104,7 +184,8 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
104
184
  scopeId: kernel.root.id,
105
185
  recall: recallFor(store, options.cognition),
106
186
  outboxFor: options.outboxFor ?? (() => []),
107
- controllers: new Map(),
187
+ controllers: executionControllers,
188
+ shutdownSignal: shutdown.signal,
108
189
  remoteOffers: new RemoteOfferRegistry(),
109
190
  execute,
110
191
  kick: () => kick(),
@@ -122,14 +203,58 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
122
203
  admission: deliveryAdmission,
123
204
  backoff: DELIVERY,
124
205
  deliver: async (item) => {
125
- await execute((item.payload as { runId: string }).runId);
126
- return { executed: true };
206
+ const runId = (item.payload as { runId: string }).runId;
207
+ try {
208
+ await execute(runId);
209
+ return { executed: true };
210
+ } finally {
211
+ // The store retains pending retry/poison state; this map only correlates this process's
212
+ // currently delivered recovery intent and must not retain failed or exhausted attempts.
213
+ if (recoveryOutboxByRun.get(runId) === item.id) recoveryOutboxByRun.delete(runId);
214
+ }
127
215
  },
128
216
  });
129
217
 
218
+ async function persistRecoveryIntents(): Promise<void> {
219
+ // Reconcile only when a new recovery candidate exists. A tracked intent already has a
220
+ // durable retry schedule; rescanning the complete pending topic every timer tick is needless.
221
+ if (pending.size === 0) return;
222
+ const now = performance.now();
223
+ if (now < nextRecoveryReconciliationAt) return;
224
+ // Set the deadline before awaiting: a slow or failing store query must not hot-loop scans.
225
+ nextRecoveryReconciliationAt = now + recoveryReconciliationIntervalMs;
226
+ const pendingItems = await store.listOutbox({ topic: RUN_EXECUTE_TOPIC, status: "pending" });
227
+ const pendingItemIds = new Set(pendingItems.map((item) => item.id));
228
+ for (const [runId, id] of recoveryOutboxByRun) {
229
+ if (pendingItemIds.has(id)) pending.delete(runId);
230
+ else recoveryOutboxByRun.delete(runId);
231
+ }
232
+ const candidates = [...pending].filter((runId) => !recoveryOutboxByRun.has(runId)).slice(0, 100);
233
+ if (candidates.length === 0) return;
234
+ const pendingRunItems = new Map<string, string>();
235
+ for (const item of pendingItems) {
236
+ const runId = (item.payload as { runId?: unknown } | null)?.runId;
237
+ if (typeof runId === "string" && !pendingRunItems.has(runId)) pendingRunItems.set(runId, item.id);
238
+ }
239
+ const intents: { id: string; topic: string; payload: Json }[] = [];
240
+ const selected = new Map<string, string>();
241
+ for (const runId of candidates) {
242
+ const existing = pendingRunItems.get(runId);
243
+ const id = existing ?? `${RUN_EXECUTE_TOPIC}:recovery:${recoveryEpoch}:${++recoverySequence}:${runId}`;
244
+ selected.set(runId, id);
245
+ if (!existing) intents.push({ id, topic: RUN_EXECUTE_TOPIC, payload: { runId } });
246
+ }
247
+ if (intents.length > 0) await store.commit({ appends: [], outbox: intents });
248
+ for (const [runId, id] of selected) {
249
+ recoveryOutboxByRun.set(runId, id);
250
+ pending.delete(runId);
251
+ }
252
+ }
253
+
130
254
  let resumeFirst = false;
131
255
  async function tick(): Promise<void> {
132
256
  for (const record of await continuations.expireDue()) pending.add(record.runId);
257
+ await persistRecoveryIntents();
133
258
  // Dispatchers reserve a bounded set of slots across overlapping drains. A new runnable
134
259
  // arrival can fill a free slot while an earlier long-running delivery is still executing.
135
260
  // Rotate priority: even an empty topic briefly reserves capacity while its claim is
@@ -142,9 +267,6 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
142
267
  trackDelivery(continuations.dispatchResumes());
143
268
  }
144
269
  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
270
  }
149
271
 
150
272
  let ticking: Promise<void> | null = null;
@@ -190,7 +312,10 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
190
312
  if (view.status === "running") pending.add(id);
191
313
  for (const continuationId of view.waitingOn) {
192
314
  const record = await continuations.get(continuationId);
193
- if (record && !["OPEN", "BLOCKED", "RESUMED"].includes(record.status)) pending.add(id);
315
+ // A resume delivery can be acknowledged during shutdown before run.resumed is appended.
316
+ // Re-enter closed waits, including RESUMED, even when their outbox receipt is delivered.
317
+ // The run-local execution chain and recorded wait result make duplicate delivery harmless.
318
+ if (record && !["OPEN", "BLOCKED"].includes(record.status)) pending.add(id);
194
319
  }
195
320
  }
196
321
 
@@ -381,6 +506,7 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
381
506
  async close() {
382
507
  if (closed) return;
383
508
  closed = true;
509
+ shutdown.abort();
384
510
  clearInterval(timer);
385
511
  if (approvalTimer) clearInterval(approvalTimer);
386
512
  await ticking;