@godspeedai/cognate-runtime-bun 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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.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"
25
+ "@godspeedai/cognate-artifacts": "^0.6.1",
26
+ "@godspeedai/cognate-capabilities": "^0.6.1",
27
+ "@godspeedai/cognate-cognitive-api": "^0.6.1",
28
+ "@godspeedai/cognate-continuations": "^0.6.1",
29
+ "@godspeedai/cognate-events": "^0.6.1",
30
+ "@godspeedai/cognate-kernel-api": "^0.6.1",
31
+ "@godspeedai/cognate-kernel-cordis": "^0.6.1",
32
+ "@godspeedai/cognate-projections": "^0.6.1",
33
+ "@godspeedai/cognate-runtime-api": "^0.6.1",
34
+ "@godspeedai/cognate-store-memory": "^0.6.1",
35
+ "@godspeedai/cognate-store-postgres": "^0.6.1",
36
+ "@godspeedai/cognate-store-sqlite": "^0.6.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@godspeedai/cognate-cognitive-memory": "workspace:*"
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,7 +14,17 @@ 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> {
18
28
  // Register before the first store await so cancellation and shutdown can reach this attempt
19
29
  // while its initial snapshot is being read.
20
30
  const controller = new AbortController();
@@ -23,14 +33,14 @@ export async function executeOnce(core: Core, runId: string): Promise<ExecutionR
23
33
  if (core.shutdownSignal.aborted) stopForShutdown();
24
34
  else core.shutdownSignal.addEventListener("abort", stopForShutdown, { once: true });
25
35
  try {
26
- return await executeRegistered(core, runId, controller);
36
+ return await executeRegistered(core, runId, controller, ownership);
27
37
  } finally {
28
38
  core.shutdownSignal.removeEventListener("abort", stopForShutdown);
29
39
  if (core.controllers.get(runId) === controller) core.controllers.delete(runId);
30
40
  }
31
41
  }
32
42
 
33
- async function executeRegistered(core: Core, runId: string, controller: AbortController): Promise<ExecutionResult> {
43
+ async function executeRegistered(core: Core, runId: string, controller: AbortController, ownership?: RunExecutionOwnership): Promise<ExecutionResult> {
34
44
  const streamId = runStream(runId);
35
45
  const events = await core.store.readStream(streamId);
36
46
  if (controller.signal.aborted) return "done";
@@ -81,13 +91,23 @@ async function executeRegistered(core: Core, runId: string, controller: AbortCon
81
91
  try {
82
92
  // Pre-assign the id so hooks can derive intents that commit atomically with the event (spec §13.1).
83
93
  const pending = { ...envelope, eventId: crypto.randomUUID(), streamId, recordedAt: envelope.occurredAt };
84
- 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
+ });
85
103
  const stored = outcome.events[0]!;
86
104
  version += 1;
87
105
  lastEventId = stored.eventId;
88
106
  return stored;
89
107
  } catch (error) {
90
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);
91
111
  throw error;
92
112
  }
93
113
  });
@@ -149,17 +169,49 @@ async function executeRegistered(core: Core, runId: string, controller: AbortCon
149
169
  // recorded event) but are a distinct event type, so the completion check must name it explicitly.
150
170
  let done = find(stepId, "run.invocation");
151
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
+ }
152
199
  // A crash between a progress call and the final run.invocation event leaves partial progress
153
200
  // recorded for this stepId with no completion; re-executing calls the handler again under a
154
201
  // fresh attempt number so consumers can discard the earlier partial attempt.
155
- const priorAttempts = (recorded.get(stepId) ?? [])
156
- .filter((e) => e.eventType === "run.invocation.progress")
157
- .map((e) => field<number>(meta(e), "attempt") ?? 0);
202
+ const priorAttempts = priorProgress.map((e) => field<number>(meta(e), "attempt") ?? 0);
158
203
  const attempt = priorAttempts.length ? Math.max(...priorAttempts) + 1 : 0;
159
204
  let seq = 0;
160
205
  const progress = async (payload: Json): Promise<void> => {
161
206
  guard();
162
- 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
+ });
163
215
  };
164
216
  const call = (approval?: { readonly approvalId: string }) =>
165
217
  core.invoker.invoke({
@@ -358,12 +410,14 @@ async function executeRegistered(core: Core, runId: string, controller: AbortCon
358
410
  payload: toJson(update),
359
411
  metadata: { scope: "thread", threadId: view.correlationId, source: "agent", actor: context.actor.id, stepId: `shared-state.update:${id}` },
360
412
  }],
413
+ ...(ownership ? { runOwnership: { runId, ownerId: ownership.ownerId, generation: ownership.generation } } : {}),
361
414
  }],
362
415
  result: { state, version: events.length + 1 },
363
416
  });
364
417
  return outcome.result as { readonly state: Json; readonly version: number };
365
418
  } catch (error) {
366
419
  if (error instanceof ConcurrencyError) continue;
420
+ if (error instanceof StaleRunOwnerError) halt(false);
367
421
  if (error instanceof IdempotencyMismatchError) throw new Error(`thread state update ${id} was replayed with different input`);
368
422
  throw error;
369
423
  }
package/src/index.ts CHANGED
@@ -67,6 +67,9 @@ export interface RuntimeOptions {
67
67
  /** Background work loop interval (run execution, resume delivery, expiry). Default 10 ms. */
68
68
  readonly tickMs?: number;
69
69
  /** Explicit single-process execution admission; defaults to serial compatibility. */
70
+ readonly execution?: {
71
+ readonly maxConcurrentRuns?: number;
72
+ /** Required above capacity one. Returning [] explicitly declares independent eligibility.
70
73
  readonly execution?: {
71
74
  readonly maxConcurrentRuns?: number;
72
75
  /** Required above capacity one. Returning [] explicitly declares independent eligibility.
@@ -74,6 +77,19 @@ export interface RuntimeOptions {
74
77
  * from authoritative application metadata. Its signal is aborted when the run is cancelled or the runtime closes.
75
78
  * Runs sharing a key serialize in this process; this is not distributed ownership or OS isolation. */
76
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
+ };
77
93
  };
78
94
  readonly system?: Principal;
79
95
  }
package/src/runtime.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { ArtifactService } from "@godspeedai/cognate-artifacts";
4
4
  import { ContinuationService } from "@godspeedai/cognate-continuations";
5
- import { type DurableStore, type Json, NotFoundError, OutboxDispatcher } from "@godspeedai/cognate-events";
5
+ import { type DurableStore, type Json, NotFoundError, OutboxDispatcher, RunOwnershipHeldError } from "@godspeedai/cognate-events";
6
6
  import { createCordisKernel } from "@godspeedai/cognate-kernel-cordis";
7
7
  import type { Principal } from "@godspeedai/cognate-kernel-api";
8
8
  import type { RunView } from "@godspeedai/cognate-runtime-api";
@@ -11,7 +11,7 @@ import { openPostgresStore, resolvePg0Url } from "@godspeedai/cognate-store-post
11
11
  import { openSqliteStore } from "@godspeedai/cognate-store-sqlite";
12
12
  import { COGNITION_PROJECTION, cognitionProjection, recallFor } from "./cognition.ts";
13
13
  import type { Core } from "./core.ts";
14
- import { executeOnce } from "./executor.ts";
14
+ import { executeOnce, type RunExecutionOwnership } from "./executor.ts";
15
15
  import { ExecutionSlots } from "./execution-slots.ts";
16
16
  import { foldRun, isTerminal, RESUME_HANDLER, RUN_EXECUTE_TOPIC, RUNS, runStream } from "./fold.ts";
17
17
  import type { Runtime, RuntimeOptions } from "./index.ts";
@@ -35,6 +35,10 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
35
35
  }
36
36
  }
37
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;
38
42
  // Recovery can enqueue many runs at once. Bound authoritative eligibility lookups with the
39
43
  // same primitive/capacity so metadata resolution cannot fan out without limit.
40
44
  const eligibilitySlots = new ExecutionSlots(capacity);
@@ -163,7 +167,42 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
163
167
  if (!release) return;
164
168
  try{
165
169
  if (closed) return;
166
- for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId)) === "done") 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) {
185
+ // Another live worker owns this run. Its lease expiry is reclaim eligibility, so
186
+ // re-arm this run for recovery once that lease can have lapsed; the durable
187
+ // intent machinery re-dispatches it on a later pass. Without this, a held-lease
188
+ // back-off would consume the only recovery trigger and strand the run.
189
+ const retryAt = error.lease.expiresAt - Date.now() + 250;
190
+ setTimeout(() => {
191
+ if (closed) return;
192
+ pending.add(runId);
193
+ kick();
194
+ }, Math.max(250, retryAt));
195
+ return;
196
+ }
197
+ throw error;
198
+ }
199
+ }
200
+ try{
201
+ for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId, ownership)) === "done") return;
202
+ }finally{
203
+ // Best-effort release: a reclaimed or already-released row simply refuses.
204
+ if (ownership) await store.releaseRunOwnership!({ runId, ownerId, generation: ownership.generation }).catch(() => {});
205
+ }
167
206
  }finally{release();}
168
207
  });
169
208
  const tracked = next.finally(() => {