@godspeedai/cognate-runtime-bun 0.3.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/README.md +7 -5
- package/package.json +13 -13
- package/src/core.ts +2 -0
- package/src/execution-slots.ts +37 -0
- package/src/executor.ts +18 -4
- package/src/index.ts +10 -1
- package/src/runtime.ts +181 -12
package/README.md
CHANGED
|
@@ -124,12 +124,14 @@ kept current automatically), `cognition` (index completed runs and recall them v
|
|
|
124
124
|
|
|
125
125
|
## Limitations
|
|
126
126
|
|
|
127
|
-
- **One
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
- **One runtime execution owner per store.** PostgreSQL supports multiple durable-store writers;
|
|
128
|
+
that does not establish distributed run ownership. Run execution defaults to serial. Opt-in
|
|
129
|
+
`execution.maxConcurrentRuns` admits bounded independent runs in one process and requires a
|
|
130
|
+
trusted `execution.resourceKeys` eligibility policy above one. Shared resource keys serialize;
|
|
131
|
+
callers must canonicalize aliases. Do not start competing runtimes against the same runnable work.
|
|
130
132
|
- **Steps are at-least-once.** A step that was in flight during a crash re-executes after restart.
|
|
131
|
-
Keep steps idempotent, and
|
|
132
|
-
|
|
133
|
+
Keep steps idempotent, and use native commitment receipts and explicit reconciliation for external
|
|
134
|
+
actions. A continuation or permission gate alone does not establish exactly-once physical effects.
|
|
133
135
|
- **Polling, not pushing, inside the process.** Background work (run execution, resume delivery,
|
|
134
136
|
continuation expiry) runs on a loop every `tickMs`.
|
|
135
137
|
- **Erasure has copies.** `runtime.erase` removes an event's payload from the log, derived views,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godspeedai/cognate-runtime-bun",
|
|
3
|
-
"version": "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.
|
|
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.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>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Admission inside the native executor, shared by start/recovery/resume paths. This is a
|
|
2
|
+
* process-local limit and resource exclusion, not durable ownership of an execution. */
|
|
3
|
+
export class ExecutionSlots {
|
|
4
|
+
private active = 0;
|
|
5
|
+
private readonly held = new Set<string>();
|
|
6
|
+
private readonly waiting: {keys: readonly string[]; resolve: (release: () => void) => void}[] = [];
|
|
7
|
+
constructor(readonly capacity: number) {}
|
|
8
|
+
|
|
9
|
+
acquire(raw: readonly string[]): Promise<() => void> {
|
|
10
|
+
if (!Array.isArray(raw) || raw.length > 100 || raw.some(key=>typeof key!=="string" || !key || key.length>500)) {
|
|
11
|
+
return Promise.reject(new Error("execution.resourceKeys must return at most 100 nonempty resource identities"));
|
|
12
|
+
}
|
|
13
|
+
return new Promise(resolve=>{
|
|
14
|
+
this.waiting.push({keys:[...new Set(raw)].sort(),resolve});
|
|
15
|
+
this.pump();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
private pump(): void {
|
|
19
|
+
const earlier=new Set<string>();
|
|
20
|
+
for(let i=0;i<this.waiting.length && this.active<this.capacity;){
|
|
21
|
+
const item=this.waiting[i]!;
|
|
22
|
+
if(item.keys.some(key=>this.held.has(key) || earlier.has(key))){
|
|
23
|
+
for(const key of item.keys)earlier.add(key);
|
|
24
|
+
i++;continue;
|
|
25
|
+
}
|
|
26
|
+
this.waiting.splice(i,1);this.active++;
|
|
27
|
+
for(const key of item.keys)this.held.add(key);
|
|
28
|
+
let released=false;
|
|
29
|
+
item.resolve(()=>{
|
|
30
|
+
if(released)return;
|
|
31
|
+
released=true;this.active--;
|
|
32
|
+
for(const key of item.keys)this.held.delete(key);
|
|
33
|
+
this.pump();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
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
|
@@ -2,7 +2,7 @@ import type { CognitiveIndex, Embedder, IndexDocument } from "@godspeedai/cognat
|
|
|
2
2
|
import type { DurableStore, Json, NewEvent, OutboxIntent, StoredEvent } from "@godspeedai/cognate-events";
|
|
3
3
|
import type { Component, Kernel, Policy, Principal } from "@godspeedai/cognate-kernel-api";
|
|
4
4
|
import type { Projection } from "@godspeedai/cognate-projections";
|
|
5
|
-
import type { AgentDefinition, Caller, RuntimeService } from "@godspeedai/cognate-runtime-api";
|
|
5
|
+
import type { AgentDefinition, Caller, RuntimeService, RunView } from "@godspeedai/cognate-runtime-api";
|
|
6
6
|
import type { GovernanceOptions } from "./governed-operation.ts";
|
|
7
7
|
import { startRuntime } from "./runtime.ts";
|
|
8
8
|
|
|
@@ -66,6 +66,15 @@ export interface RuntimeOptions {
|
|
|
66
66
|
readonly outboxFor?: (event: PendingEvent) => readonly OutboxIntent[];
|
|
67
67
|
/** Background work loop interval (run execution, resume delivery, expiry). Default 10 ms. */
|
|
68
68
|
readonly tickMs?: number;
|
|
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.
|
|
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[]>;
|
|
77
|
+
};
|
|
69
78
|
readonly system?: Principal;
|
|
70
79
|
}
|
|
71
80
|
|
package/src/runtime.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
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";
|
|
10
12
|
import { COGNITION_PROJECTION, cognitionProjection, recallFor } from "./cognition.ts";
|
|
11
13
|
import type { Core } from "./core.ts";
|
|
12
14
|
import { executeOnce } from "./executor.ts";
|
|
15
|
+
import { ExecutionSlots } from "./execution-slots.ts";
|
|
13
16
|
import { foldRun, isTerminal, RESUME_HANDLER, RUN_EXECUTE_TOPIC, RUNS, runStream } from "./fold.ts";
|
|
14
17
|
import type { Runtime, RuntimeOptions } from "./index.ts";
|
|
15
18
|
import { inspectCorrelation } from "./inspect.ts";
|
|
@@ -22,6 +25,28 @@ const DELIVERY = { baseMs: 50, maxMs: 5_000, maxAttempts: 10 };
|
|
|
22
25
|
const TOPICS = [RUN_EXECUTE_TOPIC, "continuation.resume"];
|
|
23
26
|
|
|
24
27
|
export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
28
|
+
const capacity=options.execution?.maxConcurrentRuns ?? 1;
|
|
29
|
+
if(!Number.isInteger(capacity) || capacity<1 || capacity>64)throw new Error("execution.maxConcurrentRuns must be an integer between 1 and 64");
|
|
30
|
+
if(capacity>1 && typeof options.execution?.resourceKeys!=="function")throw new Error("Concurrent execution requires an explicit execution.resourceKeys eligibility policy");
|
|
31
|
+
if(capacity>1 && typeof options.store!=="string" && "store" in options.store){
|
|
32
|
+
const injected=options.store.store;
|
|
33
|
+
if(typeof injected.claimOutboxLeased!=="function" || typeof injected.renewOutboxLease!=="function" || typeof injected.settleOutboxLease!=="function"){
|
|
34
|
+
throw new Error("Concurrent execution requires a store with complete fenced outbox lease support");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
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);
|
|
41
|
+
// Run and resume topics share one pre-claim budget. ExecutionSlots separately enforces
|
|
42
|
+
// run-local/resource exclusion for every execution path.
|
|
43
|
+
let admittedDeliveries = 0;
|
|
44
|
+
const deliveryAdmission = { tryAcquire(): (() => void) | undefined {
|
|
45
|
+
if (admittedDeliveries >= capacity) return undefined;
|
|
46
|
+
admittedDeliveries++;
|
|
47
|
+
let released = false;
|
|
48
|
+
return () => { if (!released) { released = true; admittedDeliveries--; } };
|
|
49
|
+
} };
|
|
25
50
|
// The runtime closes only stores it opened itself; an injected store is owned by its creator.
|
|
26
51
|
// (The PostgreSQL store's close() ends its pool and LISTEN connection, so one call covers both.)
|
|
27
52
|
const config = options.store;
|
|
@@ -47,14 +72,99 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
47
72
|
await invoker.attach(kernel, options.components ?? []);
|
|
48
73
|
|
|
49
74
|
// Authorization happens in the service against `actions`; the continuation layer trusts its caller.
|
|
50
|
-
const continuations = new ContinuationService(store, { policy: { authorize: () => ({ allow: true, reason: "authorized by runtime service" }) }, delivery: DELIVERY });
|
|
75
|
+
const continuations = new ContinuationService(store, { policy: { authorize: () => ({ allow: true, reason: "authorized by runtime service" }) }, delivery: DELIVERY, deliveryConcurrency: capacity, deliveryAdmission });
|
|
51
76
|
|
|
52
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
|
+
};
|
|
53
144
|
const chains = new Map<string, Promise<void>>();
|
|
54
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>();
|
|
153
|
+
const deliveries = new Set<Promise<void>>();
|
|
154
|
+
const trackDelivery=(job:Promise<unknown>):void=>{
|
|
155
|
+
const tracked=job.then(()=>{},()=>{}).finally(()=>deliveries.delete(tracked));
|
|
156
|
+
deliveries.add(tracked);
|
|
157
|
+
};
|
|
55
158
|
const execute = (runId: string): Promise<void> => {
|
|
56
159
|
const next = (chains.get(runId) ?? Promise.resolve()).then(async () => {
|
|
57
|
-
|
|
160
|
+
const view=foldRun(runId,await store.readStream(runStream(runId)));
|
|
161
|
+
if(closed || !view || isTerminal(view))return;
|
|
162
|
+
const release=await acquireExecutionSlot(runId, view);
|
|
163
|
+
if (!release) return;
|
|
164
|
+
try{
|
|
165
|
+
if (closed) return;
|
|
166
|
+
for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId)) === "done") return;
|
|
167
|
+
}finally{release();}
|
|
58
168
|
});
|
|
59
169
|
const tracked = next.finally(() => {
|
|
60
170
|
if (chains.get(runId) === tracked) chains.delete(runId);
|
|
@@ -74,7 +184,8 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
74
184
|
scopeId: kernel.root.id,
|
|
75
185
|
recall: recallFor(store, options.cognition),
|
|
76
186
|
outboxFor: options.outboxFor ?? (() => []),
|
|
77
|
-
controllers:
|
|
187
|
+
controllers: executionControllers,
|
|
188
|
+
shutdownSignal: shutdown.signal,
|
|
78
189
|
remoteOffers: new RemoteOfferRegistry(),
|
|
79
190
|
execute,
|
|
80
191
|
kick: () => kick(),
|
|
@@ -88,21 +199,74 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
88
199
|
|
|
89
200
|
const runDispatcher = new OutboxDispatcher(store, {
|
|
90
201
|
topic: RUN_EXECUTE_TOPIC,
|
|
202
|
+
concurrency: capacity,
|
|
203
|
+
admission: deliveryAdmission,
|
|
91
204
|
backoff: DELIVERY,
|
|
92
205
|
deliver: async (item) => {
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
215
|
},
|
|
96
216
|
});
|
|
97
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
|
+
|
|
254
|
+
let resumeFirst = false;
|
|
98
255
|
async function tick(): Promise<void> {
|
|
99
256
|
for (const record of await continuations.expireDue()) pending.add(record.runId);
|
|
100
|
-
await
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
257
|
+
await persistRecoveryIntents();
|
|
258
|
+
// Dispatchers reserve a bounded set of slots across overlapping drains. A new runnable
|
|
259
|
+
// arrival can fill a free slot while an earlier long-running delivery is still executing.
|
|
260
|
+
// Rotate priority: even an empty topic briefly reserves capacity while its claim is
|
|
261
|
+
// in flight. A fixed first topic could otherwise starve the other at capacity one.
|
|
262
|
+
if (resumeFirst) {
|
|
263
|
+
trackDelivery(continuations.dispatchResumes());
|
|
264
|
+
trackDelivery(runDispatcher.drain());
|
|
265
|
+
} else {
|
|
266
|
+
trackDelivery(runDispatcher.drain());
|
|
267
|
+
trackDelivery(continuations.dispatchResumes());
|
|
105
268
|
}
|
|
269
|
+
resumeFirst = !resumeFirst;
|
|
106
270
|
}
|
|
107
271
|
|
|
108
272
|
let ticking: Promise<void> | null = null;
|
|
@@ -148,7 +312,10 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
148
312
|
if (view.status === "running") pending.add(id);
|
|
149
313
|
for (const continuationId of view.waitingOn) {
|
|
150
314
|
const record = await continuations.get(continuationId);
|
|
151
|
-
|
|
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);
|
|
152
319
|
}
|
|
153
320
|
}
|
|
154
321
|
|
|
@@ -290,7 +457,7 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
290
457
|
}
|
|
291
458
|
|
|
292
459
|
async function quiescent(): Promise<boolean> {
|
|
293
|
-
if (ticking || pending.size > 0 || chains.size > 0) return false;
|
|
460
|
+
if (ticking || pending.size > 0 || chains.size > 0 || deliveries.size>0) return false;
|
|
294
461
|
const now = Date.now();
|
|
295
462
|
for (const topic of TOPICS) {
|
|
296
463
|
const items = await store.listOutbox({ topic, status: "pending" });
|
|
@@ -339,9 +506,11 @@ export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
|
339
506
|
async close() {
|
|
340
507
|
if (closed) return;
|
|
341
508
|
closed = true;
|
|
509
|
+
shutdown.abort();
|
|
342
510
|
clearInterval(timer);
|
|
343
511
|
if (approvalTimer) clearInterval(approvalTimer);
|
|
344
512
|
await ticking;
|
|
513
|
+
await Promise.allSettled([...deliveries]);
|
|
345
514
|
await Promise.allSettled([...chains.values()]);
|
|
346
515
|
for (const runner of runners) await runner.stop();
|
|
347
516
|
unregister();
|