@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 +13 -13
- package/src/core.ts +2 -0
- package/src/executor.ts +79 -11
- package/src/index.ts +20 -3
- package/src/runtime.ts +165 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godspeedai/cognate-runtime-bun",
|
|
3
|
-
"version": "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.
|
|
26
|
-
"@godspeedai/cognate-capabilities": "^0.
|
|
27
|
-
"@godspeedai/cognate-cognitive-api": "^0.
|
|
28
|
-
"@godspeedai/cognate-continuations": "^0.
|
|
29
|
-
"@godspeedai/cognate-events": "^0.
|
|
30
|
-
"@godspeedai/cognate-kernel-api": "^0.
|
|
31
|
-
"@godspeedai/cognate-kernel-cordis": "^0.
|
|
32
|
-
"@godspeedai/cognate-projections": "^0.
|
|
33
|
-
"@godspeedai/cognate-runtime-api": "^0.
|
|
34
|
-
"@godspeedai/cognate-store-memory": "^0.
|
|
35
|
-
"@godspeedai/cognate-store-postgres": "^0.
|
|
36
|
-
"@godspeedai/cognate-store-sqlite": "^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
|
-
|
|
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({
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
166
|
+
const release=await acquireExecutionSlot(runId, view);
|
|
167
|
+
if (!release) return;
|
|
85
168
|
try{
|
|
86
|
-
|
|
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:
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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;
|