@earendil-works/pi-durable 0.87.1 → 0.99.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/README.md +46 -4
- package/dist/documents.d.ts +66 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +98 -0
- package/dist/documents.js.map +1 -0
- package/dist/entries.d.ts +13 -0
- package/dist/entries.d.ts.map +1 -0
- package/dist/entries.js +18 -0
- package/dist/entries.js.map +1 -0
- package/dist/env/index.d.ts +131 -0
- package/dist/env/index.d.ts.map +1 -0
- package/dist/env/index.js +45 -0
- package/dist/env/index.js.map +1 -0
- package/dist/env/node.d.ts +45 -0
- package/dist/env/node.d.ts.map +1 -0
- package/dist/env/node.js +908 -0
- package/dist/env/node.js.map +1 -0
- package/dist/env/utils/adaptive-publisher.d.ts +24 -0
- package/dist/env/utils/adaptive-publisher.d.ts.map +1 -0
- package/dist/env/utils/adaptive-publisher.js +79 -0
- package/dist/env/utils/adaptive-publisher.js.map +1 -0
- package/dist/env/utils/output-capture.d.ts +31 -0
- package/dist/env/utils/output-capture.d.ts.map +1 -0
- package/dist/env/utils/output-capture.js +238 -0
- package/dist/env/utils/output-capture.js.map +1 -0
- package/dist/env/utils/truncate.d.ts +71 -0
- package/dist/env/utils/truncate.d.ts.map +1 -0
- package/dist/env/utils/truncate.js +294 -0
- package/dist/env/utils/truncate.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/harness/config.d.ts +20 -0
- package/dist/harness/config.d.ts.map +1 -0
- package/dist/harness/config.js +18 -0
- package/dist/harness/config.js.map +1 -0
- package/dist/harness/context.d.ts +27 -0
- package/dist/harness/context.d.ts.map +1 -0
- package/dist/harness/context.js +109 -0
- package/dist/harness/context.js.map +1 -0
- package/dist/harness/generation.d.ts +36 -0
- package/dist/harness/generation.d.ts.map +1 -0
- package/dist/harness/generation.js +266 -0
- package/dist/harness/generation.js.map +1 -0
- package/dist/harness/harness.d.ts +10 -0
- package/dist/harness/harness.d.ts.map +1 -0
- package/dist/harness/harness.js +266 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/live.d.ts +40 -0
- package/dist/harness/live.d.ts.map +1 -0
- package/dist/harness/live.js +41 -0
- package/dist/harness/live.js.map +1 -0
- package/dist/harness/prompt.d.ts +24 -0
- package/dist/harness/prompt.d.ts.map +1 -0
- package/dist/harness/prompt.js +88 -0
- package/dist/harness/prompt.js.map +1 -0
- package/dist/harness/registry.d.ts +7 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +309 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/scheduler.d.ts +63 -0
- package/dist/harness/scheduler.d.ts.map +1 -0
- package/dist/harness/scheduler.js +605 -0
- package/dist/harness/scheduler.js.map +1 -0
- package/dist/harness/submissions.d.ts +24 -0
- package/dist/harness/submissions.d.ts.map +1 -0
- package/dist/harness/submissions.js +133 -0
- package/dist/harness/submissions.js.map +1 -0
- package/dist/harness/types.d.ts +358 -0
- package/dist/harness/types.d.ts.map +1 -0
- package/dist/harness/types.js +2 -0
- package/dist/harness/types.js.map +1 -0
- package/dist/harness/util.d.ts +14 -0
- package/dist/harness/util.d.ts.map +1 -0
- package/dist/harness/util.js +55 -0
- package/dist/harness/util.js.map +1 -0
- package/dist/ids.d.ts +6 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +9 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/session/forks.d.ts +10 -0
- package/dist/session/forks.d.ts.map +1 -0
- package/dist/session/forks.js +42 -0
- package/dist/session/forks.js.map +1 -0
- package/dist/session/observation.d.ts +28 -0
- package/dist/session/observation.d.ts.map +1 -0
- package/dist/session/observation.js +280 -0
- package/dist/session/observation.js.map +1 -0
- package/dist/session/session.d.ts +59 -0
- package/dist/session/session.d.ts.map +1 -0
- package/dist/session/session.js +380 -0
- package/dist/session/session.js.map +1 -0
- package/dist/session/transaction.d.ts +105 -0
- package/dist/session/transaction.d.ts.map +1 -0
- package/dist/session/transaction.js +718 -0
- package/dist/session/transaction.js.map +1 -0
- package/dist/storage/jsonl/index.d.ts +2 -0
- package/dist/storage/jsonl/index.d.ts.map +1 -0
- package/dist/storage/jsonl/index.js +2 -0
- package/dist/storage/jsonl/index.js.map +1 -0
- package/dist/storage/jsonl/node.d.ts +6 -0
- package/dist/storage/jsonl/node.d.ts.map +1 -0
- package/dist/storage/jsonl/node.js +8 -0
- package/dist/storage/jsonl/node.js.map +1 -0
- package/dist/storage/jsonl/storage.d.ts +115 -0
- package/dist/storage/jsonl/storage.d.ts.map +1 -0
- package/dist/storage/jsonl/storage.js +681 -0
- package/dist/storage/jsonl/storage.js.map +1 -0
- package/dist/storage/memory.d.ts +31 -14
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +208 -61
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/database.d.ts +5 -1
- package/dist/storage/sqlite/database.d.ts.map +1 -1
- package/dist/storage/sqlite/database.js.map +1 -1
- package/dist/storage/sqlite/index.d.ts.map +1 -1
- package/dist/storage/sqlite/migrations.d.ts +1 -1
- package/dist/storage/sqlite/migrations.d.ts.map +1 -1
- package/dist/storage/sqlite/migrations.js +8 -1
- package/dist/storage/sqlite/migrations.js.map +1 -1
- package/dist/storage/sqlite/node.d.ts.map +1 -1
- package/dist/storage/sqlite/node.js +2 -2
- package/dist/storage/sqlite/node.js.map +1 -1
- package/dist/storage/sqlite/storage.d.ts +20 -14
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +159 -55
- package/dist/storage/sqlite/storage.js.map +1 -1
- package/dist/tasks.d.ts +6 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +5 -0
- package/dist/tasks.js.map +1 -0
- package/dist/testing/assertions.d.ts +5 -0
- package/dist/testing/assertions.d.ts.map +1 -0
- package/dist/testing/assertions.js +25 -0
- package/dist/testing/assertions.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/runner.d.ts +10 -0
- package/dist/testing/runner.d.ts.map +1 -0
- package/dist/testing/runner.js +14 -0
- package/dist/testing/runner.js.map +1 -0
- package/dist/testing/storage-benchmark.d.ts +41 -0
- package/dist/testing/storage-benchmark.d.ts.map +1 -0
- package/dist/testing/storage-benchmark.js +351 -0
- package/dist/testing/storage-benchmark.js.map +1 -0
- package/dist/testing/storage-conformance.d.ts +4 -0
- package/dist/testing/storage-conformance.d.ts.map +1 -0
- package/dist/testing/storage-conformance.js +1122 -0
- package/dist/testing/storage-conformance.js.map +1 -0
- package/dist/testing/types.d.ts +19 -0
- package/dist/testing/types.d.ts.map +1 -0
- package/dist/testing/types.js +2 -0
- package/dist/testing/types.js.map +1 -0
- package/dist/types.d.ts +422 -54
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/docs/chord-delta-findings.md +1 -1
- package/docs/pico-v5-chord-usage.md +47 -60
- package/docs/pico-v5-handoff.md +469 -264
- package/docs/pico-v5.md +1523 -639
- package/package.json +30 -5
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { copyJson } from "@earendil-works/chord";
|
|
2
|
+
import { awaitWithContext, withAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
+
import { readContext } from "./context.js";
|
|
4
|
+
import { closedError, scanAll, Waiters } from "./util.js";
|
|
5
|
+
const SCAN_PAGE_SIZE = 256;
|
|
6
|
+
/** Longest delay `setTimeout` supports; longer sleeps wait in several steps. */
|
|
7
|
+
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
8
|
+
/**
|
|
9
|
+
* Durable task scheduler of one Harness.
|
|
10
|
+
*
|
|
11
|
+
* `#live` mirrors every committed pending/running task record. The synchronous commit listener updates it on the
|
|
12
|
+
* Session line, so code running on the line reads exactly the committed state from it.
|
|
13
|
+
*
|
|
14
|
+
* Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers
|
|
15
|
+
* reservation, marks, runtime commits, and the synchronous step before each phase, which applies the precedence rules
|
|
16
|
+
* and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the step that decides its end,
|
|
17
|
+
* so a runtime commit it queued either lands before that decision or is rejected.
|
|
18
|
+
*/
|
|
19
|
+
export class TaskScheduler {
|
|
20
|
+
#session;
|
|
21
|
+
#storage;
|
|
22
|
+
#registry;
|
|
23
|
+
#models;
|
|
24
|
+
#now;
|
|
25
|
+
#report;
|
|
26
|
+
#settleOutcome;
|
|
27
|
+
#context;
|
|
28
|
+
#live = new Map();
|
|
29
|
+
#invocations = new Map();
|
|
30
|
+
#taskWaiters = new Waiters();
|
|
31
|
+
/** Idle waiters by conversation; `undefined` waits for the whole Harness. */
|
|
32
|
+
#idleWaiters = new Waiters();
|
|
33
|
+
/** Definition whose migration failed per task; retried only once the registry resolves another definition. */
|
|
34
|
+
#failedMigrations = new Map();
|
|
35
|
+
#unsubscribeRegistry = () => { };
|
|
36
|
+
#enabled = false;
|
|
37
|
+
#closing = false;
|
|
38
|
+
#dirty = false;
|
|
39
|
+
#draining = false;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.#session = options.session;
|
|
42
|
+
this.#storage = options.storage;
|
|
43
|
+
this.#registry = options.registry;
|
|
44
|
+
this.#models = options.models;
|
|
45
|
+
this.#now = options.now;
|
|
46
|
+
this.#report = options.report;
|
|
47
|
+
this.#settleOutcome = options.settleOutcome;
|
|
48
|
+
this.#context = options.context;
|
|
49
|
+
}
|
|
50
|
+
/** Load live tasks and change surviving `running` tasks back to `pending`. Dispatches nothing. */
|
|
51
|
+
async open(context) {
|
|
52
|
+
this.#session.subscribeCommits((publication) => this.#observe(publication));
|
|
53
|
+
this.#session.subscribeClose(() => this.#seal());
|
|
54
|
+
this.#unsubscribeRegistry = this.#registry.subscribe(() => this.#kick());
|
|
55
|
+
await this.#session.commitWith(async (tx) => {
|
|
56
|
+
const scan = (status) => scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor));
|
|
57
|
+
const pending = await scan("pending");
|
|
58
|
+
const running = await scan("running");
|
|
59
|
+
for (const record of [...pending, ...running])
|
|
60
|
+
this.#live.set(record.id, record);
|
|
61
|
+
for (const record of running) {
|
|
62
|
+
tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
|
|
63
|
+
}
|
|
64
|
+
}, context);
|
|
65
|
+
}
|
|
66
|
+
/** Enable scheduling. Idempotent; the kick does nothing once closing. */
|
|
67
|
+
resume() {
|
|
68
|
+
this.#enabled = true;
|
|
69
|
+
this.#kick();
|
|
70
|
+
}
|
|
71
|
+
/** Wait for every invocation signalled by `#seal()`. Writes nothing. */
|
|
72
|
+
async join() {
|
|
73
|
+
await Promise.allSettled([...this.#invocations.values()].map((invocation) => invocation.done));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Commit the abort mark, or settle a task that no registered definition can take as `orphaned`, then signal and
|
|
77
|
+
* join the run invocation seen on the line. The next drain starts the abort invocation.
|
|
78
|
+
*/
|
|
79
|
+
async abort(id, context) {
|
|
80
|
+
const marked = await this.#session.commitWith(async (tx) => {
|
|
81
|
+
const current = await tx.task(id);
|
|
82
|
+
if (current === undefined)
|
|
83
|
+
throw new Error(`Task ${id} does not exist`);
|
|
84
|
+
if (current.state.status === "terminal")
|
|
85
|
+
return { result: "terminal" };
|
|
86
|
+
const live = current;
|
|
87
|
+
const invocation = this.#invocations.get(id);
|
|
88
|
+
if (invocation === undefined) {
|
|
89
|
+
const resolution = this.#resolve(live, this.#registry.snapshot());
|
|
90
|
+
if (resolution.kind === "blocked") {
|
|
91
|
+
await this.#terminate(tx, live, { status: "orphaned", reason: resolution.reason });
|
|
92
|
+
return { result: "marked" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!live.abortRequested)
|
|
96
|
+
tx.setTask({ ...live, abortRequested: true });
|
|
97
|
+
return { result: "marked", run: invocation?.mode === "run" ? invocation : undefined };
|
|
98
|
+
}, context);
|
|
99
|
+
if (marked.run !== undefined) {
|
|
100
|
+
marked.run.controller.abort();
|
|
101
|
+
await awaitWithContext(marked.run.done, context);
|
|
102
|
+
}
|
|
103
|
+
return marked.result;
|
|
104
|
+
}
|
|
105
|
+
async waitForTask(id, context) {
|
|
106
|
+
// Check and register on the line so no terminal publication falls between them.
|
|
107
|
+
const found = await this.#session.readOnLine(async () => {
|
|
108
|
+
if (this.#closing)
|
|
109
|
+
throw closedError();
|
|
110
|
+
if (this.#live.has(id))
|
|
111
|
+
return { promise: this.#taskWaiters.add(id, context) };
|
|
112
|
+
const record = await this.#storage.task(id, context);
|
|
113
|
+
if (record === undefined)
|
|
114
|
+
throw new Error(`Task ${id} does not exist`);
|
|
115
|
+
return { promise: Promise.resolve(record) };
|
|
116
|
+
});
|
|
117
|
+
return found.promise;
|
|
118
|
+
}
|
|
119
|
+
/** Resolve when no live non-background task exists, optionally within one conversation. */
|
|
120
|
+
waitForIdle(conversationId, context) {
|
|
121
|
+
if (this.#closing)
|
|
122
|
+
return Promise.reject(closedError());
|
|
123
|
+
if (this.#idle(conversationId))
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
return this.#idleWaiters.add(conversationId, context);
|
|
126
|
+
}
|
|
127
|
+
// ─── Scheduling ────────────────────────────────────────────────────────
|
|
128
|
+
#observe(publication) {
|
|
129
|
+
let changed = false;
|
|
130
|
+
for (const change of publication.changes) {
|
|
131
|
+
if (change.type !== "task")
|
|
132
|
+
continue;
|
|
133
|
+
changed = true;
|
|
134
|
+
const record = change.value;
|
|
135
|
+
if (record.state.status !== "terminal") {
|
|
136
|
+
this.#live.set(record.id, record);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
this.#live.delete(record.id);
|
|
140
|
+
this.#failedMigrations.delete(record.id);
|
|
141
|
+
this.#taskWaiters.resolve(record.id, record);
|
|
142
|
+
}
|
|
143
|
+
if (!changed)
|
|
144
|
+
return;
|
|
145
|
+
for (const conversationId of this.#idleWaiters.keys()) {
|
|
146
|
+
if (this.#idle(conversationId))
|
|
147
|
+
this.#idleWaiters.resolve(conversationId);
|
|
148
|
+
}
|
|
149
|
+
this.#kick();
|
|
150
|
+
}
|
|
151
|
+
/** Close listener: runs synchronously once admission is sealed, before `join()`. */
|
|
152
|
+
#seal() {
|
|
153
|
+
this.#closing = true;
|
|
154
|
+
this.#unsubscribeRegistry();
|
|
155
|
+
const error = closedError();
|
|
156
|
+
this.#taskWaiters.rejectAll(error);
|
|
157
|
+
this.#idleWaiters.rejectAll(error);
|
|
158
|
+
for (const invocation of this.#invocations.values())
|
|
159
|
+
invocation.controller.abort();
|
|
160
|
+
}
|
|
161
|
+
#kick() {
|
|
162
|
+
this.#dirty = true;
|
|
163
|
+
if (this.#draining || !this.#enabled || this.#closing)
|
|
164
|
+
return;
|
|
165
|
+
this.#draining = true;
|
|
166
|
+
// Never commit synchronously from a commit or registry listener.
|
|
167
|
+
queueMicrotask(() => void this.#drain());
|
|
168
|
+
}
|
|
169
|
+
async #drain() {
|
|
170
|
+
try {
|
|
171
|
+
while (this.#dirty && this.#enabled && !this.#closing) {
|
|
172
|
+
this.#dirty = false;
|
|
173
|
+
for (const reservation of await this.#reserve())
|
|
174
|
+
this.#start(reservation);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (!this.#closing)
|
|
179
|
+
this.#report(error);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
this.#draining = false;
|
|
183
|
+
// A wakeup that arrived during a failed pass still needs its pass.
|
|
184
|
+
if (this.#dirty)
|
|
185
|
+
this.#kick();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Reserve every eligible task in one commit; orphan abort-marked tasks no definition can take. */
|
|
189
|
+
async #reserve() {
|
|
190
|
+
const reservations = [];
|
|
191
|
+
try {
|
|
192
|
+
await this.#session.commitWith(async (tx) => {
|
|
193
|
+
if (!this.#enabled || this.#closing)
|
|
194
|
+
return;
|
|
195
|
+
// Taken once per pass, and only when some task is a candidate.
|
|
196
|
+
let snapshot;
|
|
197
|
+
for (const record of [...this.#live.values()]) {
|
|
198
|
+
if (this.#invocations.has(record.id) || this.#waitingOn(record).length > 0)
|
|
199
|
+
continue;
|
|
200
|
+
const mode = record.abortRequested ? "abort" : "run";
|
|
201
|
+
snapshot ??= this.#registry.snapshot();
|
|
202
|
+
const resolution = this.#resolve(record, snapshot);
|
|
203
|
+
if (resolution.kind === "blocked") {
|
|
204
|
+
if (mode === "abort")
|
|
205
|
+
await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (resolution.record !== record || record.state.status !== "running") {
|
|
209
|
+
tx.setTask(withState(resolution.record, {
|
|
210
|
+
status: "running",
|
|
211
|
+
checkpoint: resolution.record.state.checkpoint,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
// Registered on the line, so marks and later reservations see it and close joins it.
|
|
215
|
+
const invocation = this.#createInvocation(record, mode);
|
|
216
|
+
reservations.push({ invocation, task: resolution.task, snapshot });
|
|
217
|
+
}
|
|
218
|
+
}, this.#context);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
for (const { invocation } of reservations) {
|
|
222
|
+
this.#invocations.delete(invocation.taskId);
|
|
223
|
+
invocation.finish();
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
return reservations;
|
|
228
|
+
}
|
|
229
|
+
/** Resolve the record's definition by kind, migrating an older stored version. */
|
|
230
|
+
#resolve(record, snapshot) {
|
|
231
|
+
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
232
|
+
if ("reason" in fit)
|
|
233
|
+
return { kind: "blocked", reason: fit.reason };
|
|
234
|
+
if (!fit.migrates)
|
|
235
|
+
return { kind: "ready", task: fit.task, record };
|
|
236
|
+
const definition = erased(fit.task);
|
|
237
|
+
try {
|
|
238
|
+
if (definition.migrate === undefined)
|
|
239
|
+
throw missingMigration(record, definition);
|
|
240
|
+
const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);
|
|
241
|
+
const state = { status: record.state.status, checkpoint: copyJson(migrated.checkpoint) };
|
|
242
|
+
const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };
|
|
243
|
+
return { kind: "ready", task: fit.task, record: migratedRecord };
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
this.#failedMigrations.set(record.id, { task: fit.task, error });
|
|
247
|
+
this.#report(error);
|
|
248
|
+
return { kind: "blocked", reason: "migration_failed" };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
#fit(record, task) {
|
|
252
|
+
if (task === undefined)
|
|
253
|
+
return { reason: "missing_task" };
|
|
254
|
+
const version = task.definition.version;
|
|
255
|
+
if (version === record.version)
|
|
256
|
+
return { task, migrates: false };
|
|
257
|
+
if (version < record.version)
|
|
258
|
+
return { reason: "task_too_old" };
|
|
259
|
+
const failed = this.#failedMigrations.get(record.id);
|
|
260
|
+
if (failed?.task === task)
|
|
261
|
+
return { reason: "migration_failed", error: failed.error };
|
|
262
|
+
return { task, migrates: true };
|
|
263
|
+
}
|
|
264
|
+
/** Live dependencies a run waits for; an abort mark bypasses them and a running task has passed them. */
|
|
265
|
+
#waitingOn(record) {
|
|
266
|
+
if (record.abortRequested || record.state.status !== "pending")
|
|
267
|
+
return [];
|
|
268
|
+
return record.after.filter((id) => this.#live.has(id));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a
|
|
272
|
+
* pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that
|
|
273
|
+
* cannot exist, shows as failed.
|
|
274
|
+
*/
|
|
275
|
+
inspect(snapshot) {
|
|
276
|
+
const tasks = [];
|
|
277
|
+
for (const record of this.#live.values())
|
|
278
|
+
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
|
279
|
+
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
|
280
|
+
return { scheduling, tasks };
|
|
281
|
+
}
|
|
282
|
+
#inspectTask(record, snapshot) {
|
|
283
|
+
if (this.#invocations.has(record.id))
|
|
284
|
+
return { kind: "running" };
|
|
285
|
+
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
286
|
+
if ("reason" in fit)
|
|
287
|
+
return { kind: "blocked", ...fit };
|
|
288
|
+
if (fit.migrates && fit.task.definition.migrate === undefined) {
|
|
289
|
+
return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
|
|
290
|
+
}
|
|
291
|
+
const on = this.#waitingOn(record);
|
|
292
|
+
return on.length > 0 ? { kind: "waiting", on } : { kind: "ready", migrates: fit.migrates };
|
|
293
|
+
}
|
|
294
|
+
#createInvocation(record, mode) {
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const { promise: done, resolve: finish } = Promise.withResolvers();
|
|
297
|
+
const invocation = {
|
|
298
|
+
taskId: record.id,
|
|
299
|
+
conversationId: record.conversationId,
|
|
300
|
+
mode,
|
|
301
|
+
controller,
|
|
302
|
+
context: withAbortSignal(controller.signal, this.#context),
|
|
303
|
+
watches: new Set(),
|
|
304
|
+
ended: false,
|
|
305
|
+
done,
|
|
306
|
+
finish: () => finish(),
|
|
307
|
+
};
|
|
308
|
+
this.#invocations.set(record.id, invocation);
|
|
309
|
+
return invocation;
|
|
310
|
+
}
|
|
311
|
+
#start(reservation) {
|
|
312
|
+
const invocation = reservation.invocation;
|
|
313
|
+
void (async () => {
|
|
314
|
+
try {
|
|
315
|
+
if (invocation.mode === "run")
|
|
316
|
+
await this.#run(reservation);
|
|
317
|
+
else
|
|
318
|
+
await this.#runAbort(reservation);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
this.#report(error);
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
this.#end(invocation);
|
|
325
|
+
invocation.finish();
|
|
326
|
+
this.#kick();
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
}
|
|
330
|
+
/** Run phase handlers, each preceded by a step that decides on the line whether the invocation continues. */
|
|
331
|
+
async #run(reservation) {
|
|
332
|
+
const invocation = reservation.invocation;
|
|
333
|
+
const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
|
|
334
|
+
const runtime = this.#runtime(invocation, () => state.snapshot);
|
|
335
|
+
let previous;
|
|
336
|
+
for (;;) {
|
|
337
|
+
const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state));
|
|
338
|
+
// Close may seal between the decision and dispatch.
|
|
339
|
+
if (current === undefined || this.#closing)
|
|
340
|
+
return;
|
|
341
|
+
const checkpoint = current.state.checkpoint;
|
|
342
|
+
try {
|
|
343
|
+
await erased(state.task).phases[checkpoint.phase](current, runtime, invocation.context);
|
|
344
|
+
previous = { checkpoint };
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
previous = { checkpoint, failure: { error } };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Precedence rules for a run invocation, on the line. Rules 1 (terminal) and 2 (closing) are applied by `#step`.
|
|
353
|
+
* Returns whether the invocation continues with the next phase.
|
|
354
|
+
*/
|
|
355
|
+
#decide(tx, current, previous, state) {
|
|
356
|
+
// 3. abort mark: end; the next drain starts a fresh abort invocation.
|
|
357
|
+
if (current.abortRequested)
|
|
358
|
+
return false;
|
|
359
|
+
if (previous === undefined)
|
|
360
|
+
return true;
|
|
361
|
+
// 4. uncaught error.
|
|
362
|
+
if (previous.failure !== undefined)
|
|
363
|
+
return { fault: previous.failure.error };
|
|
364
|
+
// 6. no durable progress.
|
|
365
|
+
if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {
|
|
366
|
+
const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;
|
|
367
|
+
return { fault: new Error(message) };
|
|
368
|
+
}
|
|
369
|
+
// 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.
|
|
370
|
+
state.snapshot = this.#registry.snapshot();
|
|
371
|
+
const next = state.snapshot.task(current.kind);
|
|
372
|
+
if (next !== state.task) {
|
|
373
|
+
if (next !== undefined && canReserve(next, current)) {
|
|
374
|
+
tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
if (state.reported === undefined || state.reported.task !== next) {
|
|
378
|
+
state.reported = { task: next };
|
|
379
|
+
const cause = next === undefined ? "missing_task" : "incompatible_task";
|
|
380
|
+
this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`, { cause }));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/** Run the abort handler once; rules 1, 2, and 4 apply, and returning without an outcome faults. */
|
|
386
|
+
async #runAbort(reservation) {
|
|
387
|
+
const invocation = reservation.invocation;
|
|
388
|
+
const current = this.#live.get(invocation.taskId);
|
|
389
|
+
if (current === undefined || this.#closing)
|
|
390
|
+
return;
|
|
391
|
+
let failure;
|
|
392
|
+
try {
|
|
393
|
+
const runtime = this.#runtime(invocation, () => reservation.snapshot);
|
|
394
|
+
await erased(reservation.task).abort(current, runtime, invocation.context);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
failure = { error };
|
|
398
|
+
}
|
|
399
|
+
const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
|
|
400
|
+
await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* One synchronous decision on the Session line. A terminal task (rule 1) or a closing Harness (rule 2) ends the
|
|
404
|
+
* invocation without a write; otherwise `decide` may stage a write and returns whether the invocation continues.
|
|
405
|
+
* Ending happens inside the callback, before a fault's Harness cleanup. A rejected step, such as admission after
|
|
406
|
+
* close, also ends the invocation.
|
|
407
|
+
*/
|
|
408
|
+
async #step(invocation, decide) {
|
|
409
|
+
try {
|
|
410
|
+
return await this.#session.commitWith(async (tx) => {
|
|
411
|
+
const current = this.#live.get(invocation.taskId);
|
|
412
|
+
const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
|
|
413
|
+
if (decision === true)
|
|
414
|
+
return current;
|
|
415
|
+
this.#end(invocation);
|
|
416
|
+
if (decision !== false) {
|
|
417
|
+
const message = decision.fault instanceof Error ? decision.fault.message : String(decision.fault);
|
|
418
|
+
await this.#terminate(tx, current, { status: "faulted", error: { message } });
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}, this.#context);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
this.#end(invocation);
|
|
425
|
+
if (!this.#closing)
|
|
426
|
+
this.#report(error);
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Write a scheduler-decided terminal outcome together with its Harness cleanup. */
|
|
431
|
+
#terminate(tx, record, outcome) {
|
|
432
|
+
tx.setTask(withState(record, { status: "terminal", outcome }));
|
|
433
|
+
return this.#settleOutcome(tx, record, outcome);
|
|
434
|
+
}
|
|
435
|
+
/** End an invocation: its runtime operations reject from now on, its watches stop, and its task is free. */
|
|
436
|
+
#end(invocation) {
|
|
437
|
+
if (invocation.ended)
|
|
438
|
+
return;
|
|
439
|
+
invocation.ended = true;
|
|
440
|
+
if (this.#invocations.get(invocation.taskId) === invocation)
|
|
441
|
+
this.#invocations.delete(invocation.taskId);
|
|
442
|
+
for (const watch of invocation.watches)
|
|
443
|
+
void watch.stop();
|
|
444
|
+
}
|
|
445
|
+
#idle(conversationId) {
|
|
446
|
+
for (const record of this.#live.values()) {
|
|
447
|
+
if (record.background)
|
|
448
|
+
continue;
|
|
449
|
+
if (conversationId === undefined || record.conversationId === conversationId)
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
// ─── Invocation runtime ──────────────────────────────────────────────────
|
|
455
|
+
#runtime(invocation, snapshot) {
|
|
456
|
+
return {
|
|
457
|
+
taskId: invocation.taskId,
|
|
458
|
+
conversationId: invocation.conversationId,
|
|
459
|
+
signal: invocation.controller.signal,
|
|
460
|
+
models: this.#models,
|
|
461
|
+
get registry() {
|
|
462
|
+
return snapshot();
|
|
463
|
+
},
|
|
464
|
+
commit: (change, context) => this.#gated(invocation, async (tx, current) => {
|
|
465
|
+
const next = await change(tx, current);
|
|
466
|
+
if (next !== undefined)
|
|
467
|
+
tx.setTask(withState(current, next));
|
|
468
|
+
}, context),
|
|
469
|
+
memo: ((name, ...rest) => {
|
|
470
|
+
if (rest.length === 1) {
|
|
471
|
+
return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));
|
|
472
|
+
}
|
|
473
|
+
const candidate = rest[0];
|
|
474
|
+
return this.#gated(invocation, (tx, current) => {
|
|
475
|
+
const winner = memoOf(current, name);
|
|
476
|
+
if (winner !== undefined)
|
|
477
|
+
return winner;
|
|
478
|
+
tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } });
|
|
479
|
+
return candidate;
|
|
480
|
+
}, rest[1]);
|
|
481
|
+
}),
|
|
482
|
+
sleep: (until, context) => this.#sleep(invocation, until, context),
|
|
483
|
+
watchDoc: ((...args) => this.#watchDoc(invocation, args)),
|
|
484
|
+
snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
|
|
485
|
+
snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
|
|
486
|
+
context: (conversationId, context, at) => this.#read(invocation, () => readContext(this.#session, this.#storage, conversationId, context, at)),
|
|
487
|
+
now: () => this.#now(),
|
|
488
|
+
report: (error) => this.#report(error),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/** Run a committed-state read unless the invocation has ended. */
|
|
492
|
+
async #read(invocation, read) {
|
|
493
|
+
if (invocation.ended)
|
|
494
|
+
throw endedError(invocation);
|
|
495
|
+
return read();
|
|
496
|
+
}
|
|
497
|
+
/** Commit after rereading the task on the line and gating the invocation. */
|
|
498
|
+
#gated(invocation, change, context) {
|
|
499
|
+
if (invocation.ended)
|
|
500
|
+
return Promise.reject(endedError(invocation));
|
|
501
|
+
return this.#session.commitWith(async (tx) => {
|
|
502
|
+
if (invocation.ended)
|
|
503
|
+
throw endedError(invocation);
|
|
504
|
+
if (this.#closing)
|
|
505
|
+
throw closedError();
|
|
506
|
+
const current = this.#live.get(invocation.taskId);
|
|
507
|
+
if (current === undefined)
|
|
508
|
+
throw new Error(`Task ${invocation.taskId} is terminal`);
|
|
509
|
+
if (invocation.mode === "run" && current.abortRequested) {
|
|
510
|
+
throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
|
|
511
|
+
}
|
|
512
|
+
return change(tx, current);
|
|
513
|
+
}, context, { conversationId: invocation.conversationId, taskId: invocation.taskId });
|
|
514
|
+
}
|
|
515
|
+
/** Wait until the Harness clock reaches `until`, rechecking it after every timer. */
|
|
516
|
+
async #sleep(invocation, until, context) {
|
|
517
|
+
if (invocation.ended)
|
|
518
|
+
throw endedError(invocation);
|
|
519
|
+
const signals = [invocation.controller.signal];
|
|
520
|
+
if (context.abortSignal !== undefined)
|
|
521
|
+
signals.push(context.abortSignal);
|
|
522
|
+
const signal = AbortSignal.any(signals);
|
|
523
|
+
for (;;) {
|
|
524
|
+
signal.throwIfAborted();
|
|
525
|
+
const remaining = until - this.#now();
|
|
526
|
+
if (remaining <= 0)
|
|
527
|
+
return;
|
|
528
|
+
await delay(Math.min(remaining, MAX_TIMER_DELAY), signal);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async #watchDoc(invocation, args) {
|
|
532
|
+
if (invocation.ended)
|
|
533
|
+
throw endedError(invocation);
|
|
534
|
+
const watch = (await sessionMethod(this.#session, "watchDoc")(...args));
|
|
535
|
+
if (watch === undefined)
|
|
536
|
+
return undefined;
|
|
537
|
+
if (invocation.ended) {
|
|
538
|
+
void watch.stop();
|
|
539
|
+
throw endedError(invocation);
|
|
540
|
+
}
|
|
541
|
+
invocation.watches.add(watch);
|
|
542
|
+
void watch.closed.then(() => invocation.watches.delete(watch));
|
|
543
|
+
return watch;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Own memo entry only; memo names such as `toString` must not resolve to inherited properties. */
|
|
547
|
+
function memoOf(record, name) {
|
|
548
|
+
const memos = record?.memos;
|
|
549
|
+
return memos !== undefined && Object.hasOwn(memos, name) ? memos[name] : undefined;
|
|
550
|
+
}
|
|
551
|
+
/** Overloaded Session method bound for forwarding an argument list unchanged. */
|
|
552
|
+
function sessionMethod(session, name) {
|
|
553
|
+
return session[name].bind(session);
|
|
554
|
+
}
|
|
555
|
+
function missingMigration(record, definition) {
|
|
556
|
+
return new Error(`Task ${record.kind} version ${definition.version} has no migration from ${record.version}`);
|
|
557
|
+
}
|
|
558
|
+
function erased(task) {
|
|
559
|
+
return task.definition;
|
|
560
|
+
}
|
|
561
|
+
function endedError(invocation) {
|
|
562
|
+
return new Error(`Task ${invocation.taskId} invocation has ended`);
|
|
563
|
+
}
|
|
564
|
+
/** Replace a live record's state; memos disappear in the terminal replacement. */
|
|
565
|
+
function withState(record, state) {
|
|
566
|
+
if (state.status !== "terminal")
|
|
567
|
+
return { ...record, state };
|
|
568
|
+
const { memos: _memos, ...rest } = record;
|
|
569
|
+
return { ...rest, state };
|
|
570
|
+
}
|
|
571
|
+
/** Whether a definition can take the task at reservation: same version, or newer with a migration. */
|
|
572
|
+
function canReserve(task, record) {
|
|
573
|
+
const definition = task.definition;
|
|
574
|
+
return (definition.version === record.version || (definition.version > record.version && definition.migrate !== undefined));
|
|
575
|
+
}
|
|
576
|
+
function delay(ms, signal) {
|
|
577
|
+
return new Promise((resolve, reject) => {
|
|
578
|
+
const onAbort = () => {
|
|
579
|
+
clearTimeout(timer);
|
|
580
|
+
reject(signal.reason);
|
|
581
|
+
};
|
|
582
|
+
const timer = setTimeout(() => {
|
|
583
|
+
signal.removeEventListener("abort", onAbort);
|
|
584
|
+
resolve();
|
|
585
|
+
}, ms);
|
|
586
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/** Structural equality of two JSON values; object key order is ignored. */
|
|
590
|
+
function jsonEqual(left, right) {
|
|
591
|
+
if (left === right)
|
|
592
|
+
return true;
|
|
593
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
|
|
594
|
+
return false;
|
|
595
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
596
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
|
597
|
+
return false;
|
|
598
|
+
return left.every((value, index) => jsonEqual(value, right[index]));
|
|
599
|
+
}
|
|
600
|
+
const keys = Object.keys(left);
|
|
601
|
+
if (keys.length !== Object.keys(right).length)
|
|
602
|
+
return false;
|
|
603
|
+
return keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]));
|
|
604
|
+
}
|
|
605
|
+
//# sourceMappingURL=scheduler.js.map
|