@earendil-works/pi-durable 0.99.1 → 1.0.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/CHANGELOG.md +7 -0
- package/README.md +557 -46
- package/dist/entries.d.ts +20 -2
- package/dist/entries.d.ts.map +1 -1
- package/dist/entries.js +14 -1
- package/dist/entries.js.map +1 -1
- package/dist/env/index.d.ts +18 -38
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js +2 -0
- package/dist/env/index.js.map +1 -1
- package/dist/env/node.d.ts +2 -0
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +53 -58
- package/dist/env/node.js.map +1 -1
- package/dist/harness/agent.d.ts +31 -0
- package/dist/harness/agent.d.ts.map +1 -0
- package/dist/harness/agent.js +209 -0
- package/dist/harness/agent.js.map +1 -0
- package/dist/harness/compaction.d.ts +56 -0
- package/dist/harness/compaction.d.ts.map +1 -0
- package/dist/harness/compaction.js +382 -0
- package/dist/harness/compaction.js.map +1 -0
- package/dist/harness/context.d.ts +8 -0
- package/dist/harness/context.d.ts.map +1 -1
- package/dist/harness/context.js +32 -21
- package/dist/harness/context.js.map +1 -1
- package/dist/harness/define.d.ts +18 -0
- package/dist/harness/define.d.ts.map +1 -0
- package/dist/harness/define.js +25 -0
- package/dist/harness/define.js.map +1 -0
- package/dist/harness/events.d.ts +166 -0
- package/dist/harness/events.d.ts.map +1 -0
- package/dist/harness/events.js +319 -0
- package/dist/harness/events.js.map +1 -0
- package/dist/harness/generation.d.ts +36 -7
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +356 -82
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +141 -106
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +59 -0
- package/dist/harness/inbox.d.ts.map +1 -0
- package/dist/harness/inbox.js +97 -0
- package/dist/harness/inbox.js.map +1 -0
- package/dist/harness/json.d.ts +10 -0
- package/dist/harness/json.d.ts.map +1 -0
- package/dist/harness/json.js +33 -0
- package/dist/harness/json.js.map +1 -0
- package/dist/harness/live.d.ts +66 -7
- package/dist/harness/live.d.ts.map +1 -1
- package/dist/harness/live.js +71 -7
- package/dist/harness/live.js.map +1 -1
- package/dist/harness/output.d.ts +62 -0
- package/dist/harness/output.d.ts.map +1 -0
- package/dist/harness/output.js +261 -0
- package/dist/harness/output.js.map +1 -0
- package/dist/harness/prompt.d.ts +9 -6
- package/dist/harness/prompt.d.ts.map +1 -1
- package/dist/harness/prompt.js +57 -14
- package/dist/harness/prompt.js.map +1 -1
- package/dist/harness/registry.d.ts +2 -3
- package/dist/harness/registry.d.ts.map +1 -1
- package/dist/harness/registry.js +69 -277
- package/dist/harness/registry.js.map +1 -1
- package/dist/harness/scheduler.d.ts +42 -12
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +649 -75
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +14 -9
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +76 -34
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/task-graph.d.ts +49 -0
- package/dist/harness/task-graph.d.ts.map +1 -0
- package/dist/harness/task-graph.js +163 -0
- package/dist/harness/task-graph.js.map +1 -0
- package/dist/harness/tool.d.ts +36 -0
- package/dist/harness/tool.d.ts.map +1 -0
- package/dist/harness/tool.js +415 -0
- package/dist/harness/tool.js.map +1 -0
- package/dist/harness/types.d.ts +330 -125
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/usage.d.ts +18 -0
- package/dist/harness/usage.d.ts.map +1 -0
- package/dist/harness/usage.js +57 -0
- package/dist/harness/usage.js.map +1 -0
- package/dist/harness/view.d.ts +43 -0
- package/dist/harness/view.d.ts.map +1 -0
- package/dist/harness/view.js +189 -0
- package/dist/harness/view.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/session/observation.d.ts +18 -11
- package/dist/session/observation.d.ts.map +1 -1
- package/dist/session/observation.js +24 -13
- package/dist/session/observation.js.map +1 -1
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +23 -5
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +12 -3
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +90 -31
- package/dist/session/transaction.js.map +1 -1
- package/dist/storage/jsonl/storage.d.ts +22 -2
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/jsonl/storage.js +1 -1
- package/dist/storage/jsonl/storage.js.map +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/database.d.ts +26 -19
- 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 +1 -1
- package/dist/storage/sqlite/index.d.ts.map +1 -1
- package/dist/storage/sqlite/index.js.map +1 -1
- package/dist/storage/sqlite/migrations.js +7 -7
- package/dist/storage/sqlite/migrations.js.map +1 -1
- package/dist/storage/sqlite/node.d.ts +23 -7
- package/dist/storage/sqlite/node.d.ts.map +1 -1
- package/dist/storage/sqlite/node.js +129 -47
- package/dist/storage/sqlite/node.js.map +1 -1
- package/dist/storage/sqlite/storage.d.ts +12 -0
- package/dist/storage/sqlite/storage.d.ts.map +1 -1
- package/dist/storage/sqlite/storage.js +121 -129
- package/dist/storage/sqlite/storage.js.map +1 -1
- package/dist/testing/storage-benchmark.d.ts.map +1 -1
- package/dist/testing/storage-benchmark.js +0 -1
- package/dist/testing/storage-benchmark.js.map +1 -1
- package/dist/testing/storage-conformance.d.ts.map +1 -1
- package/dist/testing/storage-conformance.js +34 -2
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/tools/bash.d.ts +28 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +70 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit-diff.d.ts +89 -0
- package/dist/tools/edit-diff.d.ts.map +1 -0
- package/dist/tools/edit-diff.js +386 -0
- package/dist/tools/edit-diff.js.map +1 -0
- package/dist/tools/edit.d.ts +18 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +117 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/env.d.ts +5 -0
- package/dist/tools/env.d.ts.map +1 -0
- package/dist/tools/env.js +7 -0
- package/dist/tools/env.js.map +1 -0
- package/dist/tools/file-mutation-queue.d.ts +9 -0
- package/dist/tools/file-mutation-queue.d.ts.map +1 -0
- package/dist/tools/file-mutation-queue.js +52 -0
- package/dist/tools/file-mutation-queue.js.map +1 -0
- package/dist/tools/image.d.ts +2 -0
- package/dist/tools/image.d.ts.map +1 -0
- package/dist/tools/image.js +92 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/index.d.ts +23 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +15 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/path-utils.d.ts +5 -0
- package/dist/tools/path-utils.d.ts.map +1 -0
- package/dist/tools/path-utils.js +26 -0
- package/dist/tools/path-utils.js.map +1 -0
- package/dist/tools/read.d.ts +17 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +101 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +31 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/{env/utils/truncate.d.ts → truncate.d.ts} +1 -17
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +146 -0
- package/dist/truncate.js.map +1 -0
- package/dist/types.d.ts +97 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +32 -14
- package/dist/env/utils/adaptive-publisher.d.ts +0 -24
- package/dist/env/utils/adaptive-publisher.d.ts.map +0 -1
- package/dist/env/utils/adaptive-publisher.js +0 -79
- package/dist/env/utils/adaptive-publisher.js.map +0 -1
- package/dist/env/utils/output-capture.d.ts +0 -31
- package/dist/env/utils/output-capture.d.ts.map +0 -1
- package/dist/env/utils/output-capture.js +0 -238
- package/dist/env/utils/output-capture.js.map +0 -1
- package/dist/env/utils/truncate.d.ts.map +0 -1
- package/dist/env/utils/truncate.js +0 -294
- package/dist/env/utils/truncate.js.map +0 -1
- package/dist/harness/config.d.ts +0 -20
- package/dist/harness/config.d.ts.map +0 -1
- package/dist/harness/config.js +0 -18
- package/dist/harness/config.js.map +0 -1
- package/docs/chord-delta-findings.md +0 -358
- package/docs/pico-v5-chord-usage.md +0 -373
- package/docs/pico-v5-handoff.md +0 -581
- package/docs/pico-v5.md +0 -3084
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
import { copyJson } from "@earendil-works/chord";
|
|
2
2
|
import { awaitWithContext, withAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
+
import { agentHooks } from "./agent.js";
|
|
3
4
|
import { readContext } from "./context.js";
|
|
4
5
|
import { closedError, scanAll, Waiters } from "./util.js";
|
|
5
6
|
const SCAN_PAGE_SIZE = 256;
|
|
6
7
|
/** Longest delay `setTimeout` supports; longer sleeps wait in several steps. */
|
|
7
8
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
9
|
+
const LIVE_STATUSES = ["pending", "running", "waiting", "completing"];
|
|
8
10
|
/**
|
|
9
11
|
* Durable task scheduler of one Harness.
|
|
10
12
|
*
|
|
11
|
-
* `#live` mirrors every committed
|
|
12
|
-
* Session line, so code running on the line reads exactly the committed state from it.
|
|
13
|
+
* `#live` mirrors every committed non-terminal task record: pending, running, waiting, and completing. The synchronous
|
|
14
|
+
* commit listener updates it on the Session line, so code running on the line reads exactly the committed state from it.
|
|
15
|
+
*
|
|
16
|
+
* Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation;
|
|
17
|
+
* a conversation's parent is its owner task, if any. Walks up that tree decide cascades, idle scopes, and whether a
|
|
18
|
+
* task's ordinary owned work is live, which holds its outcome as `completing` and delays its abort handler.
|
|
13
19
|
*
|
|
14
20
|
* 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
|
|
16
|
-
* and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the
|
|
17
|
-
* so a runtime commit it queued either lands before that decision or is rejected.
|
|
21
|
+
* reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the
|
|
22
|
+
* precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the
|
|
23
|
+
* step that decides its end, so a runtime commit it queued either lands before that decision or is rejected.
|
|
18
24
|
*/
|
|
19
25
|
export class TaskScheduler {
|
|
20
26
|
#session;
|
|
21
27
|
#storage;
|
|
22
28
|
#registry;
|
|
23
29
|
#models;
|
|
30
|
+
#agent;
|
|
31
|
+
#settings;
|
|
32
|
+
#env;
|
|
24
33
|
#now;
|
|
25
34
|
#report;
|
|
26
35
|
#settleOutcome;
|
|
36
|
+
#withdrawInputs;
|
|
37
|
+
#conversation;
|
|
27
38
|
#context;
|
|
28
39
|
#live = new Map();
|
|
29
40
|
#invocations = new Map();
|
|
@@ -32,6 +43,19 @@ export class TaskScheduler {
|
|
|
32
43
|
#idleWaiters = new Waiters();
|
|
33
44
|
/** Definition whose migration failed per task; retried only once the registry resolves another definition. */
|
|
34
45
|
#failedMigrations = new Map();
|
|
46
|
+
/** Owner task of each loaded conversation, `null` when ownerless. */
|
|
47
|
+
#edges = new Map();
|
|
48
|
+
/** Tasks that own a loaded conversation; their nodes stay in `#settled` once terminal. */
|
|
49
|
+
#conversationOwners = new Set();
|
|
50
|
+
/** Ownership fields of terminal tasks that walks pass through. */
|
|
51
|
+
#settled = new Map();
|
|
52
|
+
/**
|
|
53
|
+
* `failFast` waiters the next reconcile checks for a failed task in `on`: at open, when they start waiting, and when
|
|
54
|
+
* one of their tasks fails.
|
|
55
|
+
*/
|
|
56
|
+
#failFastChecks = new Set();
|
|
57
|
+
#reconcileScheduled = false;
|
|
58
|
+
#cascadePending = false;
|
|
35
59
|
#unsubscribeRegistry = () => { };
|
|
36
60
|
#enabled = false;
|
|
37
61
|
#closing = false;
|
|
@@ -42,9 +66,14 @@ export class TaskScheduler {
|
|
|
42
66
|
this.#storage = options.storage;
|
|
43
67
|
this.#registry = options.registry;
|
|
44
68
|
this.#models = options.models;
|
|
69
|
+
this.#agent = options.agent;
|
|
70
|
+
this.#settings = options.settings;
|
|
71
|
+
this.#env = options.env;
|
|
45
72
|
this.#now = options.now;
|
|
46
73
|
this.#report = options.report;
|
|
47
74
|
this.#settleOutcome = options.settleOutcome;
|
|
75
|
+
this.#withdrawInputs = options.withdrawInputs;
|
|
76
|
+
this.#conversation = options.conversation;
|
|
48
77
|
this.#context = options.context;
|
|
49
78
|
}
|
|
50
79
|
/** Load live tasks and change surviving `running` tasks back to `pending`. Dispatches nothing. */
|
|
@@ -53,15 +82,26 @@ export class TaskScheduler {
|
|
|
53
82
|
this.#session.subscribeClose(() => this.#seal());
|
|
54
83
|
this.#unsubscribeRegistry = this.#registry.subscribe(() => this.#kick());
|
|
55
84
|
await this.#session.commitWith(async (tx) => {
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
for (const
|
|
62
|
-
|
|
85
|
+
// Every table read before the first write.
|
|
86
|
+
const scans = [];
|
|
87
|
+
for (const status of LIVE_STATUSES) {
|
|
88
|
+
scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)));
|
|
89
|
+
}
|
|
90
|
+
for (const records of scans) {
|
|
91
|
+
for (const record of records) {
|
|
92
|
+
this.#live.set(record.id, record);
|
|
93
|
+
if (record.state.status === "running") {
|
|
94
|
+
tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
|
|
95
|
+
}
|
|
96
|
+
if (record.state.status === "waiting" && record.state.policy === "failFast") {
|
|
97
|
+
this.#failFastChecks.add(record.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
63
100
|
}
|
|
64
101
|
}, context);
|
|
102
|
+
// Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.
|
|
103
|
+
this.#cascadePending = true;
|
|
104
|
+
this.#scheduleReconcile();
|
|
65
105
|
}
|
|
66
106
|
/** Enable scheduling. Idempotent; the kick does nothing once closing. */
|
|
67
107
|
resume() {
|
|
@@ -73,8 +113,9 @@ export class TaskScheduler {
|
|
|
73
113
|
await Promise.allSettled([...this.#invocations.values()].map((invocation) => invocation.done));
|
|
74
114
|
}
|
|
75
115
|
/**
|
|
76
|
-
* Commit the abort mark, or settle a task that no registered definition can take as `orphaned
|
|
77
|
-
* join the run invocation seen on the line
|
|
116
|
+
* Commit the abort mark, or settle a task that no registered definition can take as `orphaned` when nothing it owns
|
|
117
|
+
* is live, then join the run invocation seen on the line; the commit listener signalled it. The abort invocation
|
|
118
|
+
* starts once the task's ordinary owned work is gone. A `completing` task is only marked.
|
|
78
119
|
*/
|
|
79
120
|
async abort(id, context) {
|
|
80
121
|
const marked = await this.#session.commitWith(async (tx) => {
|
|
@@ -83,23 +124,24 @@ export class TaskScheduler {
|
|
|
83
124
|
throw new Error(`Task ${id} does not exist`);
|
|
84
125
|
if (current.state.status === "terminal")
|
|
85
126
|
return { result: "terminal" };
|
|
86
|
-
const live = current;
|
|
87
127
|
const invocation = this.#invocations.get(id);
|
|
88
|
-
if (invocation === undefined) {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
if (invocation === undefined && current.state.status !== "completing") {
|
|
129
|
+
await this.#loadScopes(false);
|
|
130
|
+
if (!this.#ownedLive().has(id)) {
|
|
131
|
+
const resolution = this.#resolve(current, this.#registry.snapshot());
|
|
132
|
+
if (resolution.kind === "blocked") {
|
|
133
|
+
await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason });
|
|
134
|
+
return { result: "marked" };
|
|
135
|
+
}
|
|
93
136
|
}
|
|
94
137
|
}
|
|
95
|
-
if (!
|
|
96
|
-
tx.setTask({ ...
|
|
138
|
+
if (!current.abortRequested)
|
|
139
|
+
tx.setTask({ ...current, abortRequested: true });
|
|
97
140
|
return { result: "marked", run: invocation?.mode === "run" ? invocation : undefined };
|
|
98
141
|
}, context);
|
|
99
|
-
|
|
100
|
-
|
|
142
|
+
// The commit listener signalled the run; join it.
|
|
143
|
+
if (marked.run !== undefined)
|
|
101
144
|
await awaitWithContext(marked.run.done, context);
|
|
102
|
-
}
|
|
103
145
|
return marked.result;
|
|
104
146
|
}
|
|
105
147
|
async waitForTask(id, context) {
|
|
@@ -116,37 +158,392 @@ export class TaskScheduler {
|
|
|
116
158
|
});
|
|
117
159
|
return found.promise;
|
|
118
160
|
}
|
|
119
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Resolve when ordinary traversal from the conversation, or from every ownerless conversation, reaches no live
|
|
163
|
+
* non-background task.
|
|
164
|
+
*/
|
|
120
165
|
waitForIdle(conversationId, context) {
|
|
121
166
|
if (this.#closing)
|
|
122
167
|
return Promise.reject(closedError());
|
|
123
168
|
if (this.#idle(conversationId))
|
|
124
169
|
return Promise.resolve();
|
|
170
|
+
this.#scheduleReconcile();
|
|
125
171
|
return this.#idleWaiters.add(conversationId, context);
|
|
126
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* `Conversation.abort()`: in one commit, withdraw the queued inputs and mark every live non-background task that
|
|
175
|
+
* ordinary traversal from the conversation reaches; resolves once the scope is idle. With `background`, traversal
|
|
176
|
+
* crosses background boundaries, and the wait also covers every task it reached.
|
|
177
|
+
*/
|
|
178
|
+
async abortConversation(conversationId, background, context) {
|
|
179
|
+
const reached = await this.#session.commitWith(async (tx) => {
|
|
180
|
+
const queued = await this.#loadScopes(true);
|
|
181
|
+
const scope = { conversation: conversationId };
|
|
182
|
+
const reached = [];
|
|
183
|
+
for (const record of this.#live.values()) {
|
|
184
|
+
if (record.background && !background)
|
|
185
|
+
continue;
|
|
186
|
+
if (this.#inScope(parentOf(record), scope, background) !== true)
|
|
187
|
+
continue;
|
|
188
|
+
reached.push(record.id);
|
|
189
|
+
if (!record.abortRequested)
|
|
190
|
+
tx.setTask({ ...record, abortRequested: true });
|
|
191
|
+
}
|
|
192
|
+
for (const id of queued) {
|
|
193
|
+
if (this.#inScope({ conversation: id }, scope, background) === true)
|
|
194
|
+
await this.#withdrawInputs(tx, id);
|
|
195
|
+
}
|
|
196
|
+
return reached;
|
|
197
|
+
}, context);
|
|
198
|
+
if (background)
|
|
199
|
+
for (const id of reached)
|
|
200
|
+
await this.waitForTask(id, context);
|
|
201
|
+
await this.waitForIdle(conversationId, context);
|
|
202
|
+
}
|
|
127
203
|
// ─── Scheduling ────────────────────────────────────────────────────────
|
|
128
204
|
#observe(publication) {
|
|
205
|
+
const updated = [];
|
|
206
|
+
const failed = [];
|
|
129
207
|
let changed = false;
|
|
130
208
|
for (const change of publication.changes) {
|
|
131
209
|
if (change.type !== "task")
|
|
132
210
|
continue;
|
|
133
211
|
changed = true;
|
|
134
212
|
const record = change.value;
|
|
135
|
-
|
|
136
|
-
|
|
213
|
+
const previous = this.#live.get(record.id);
|
|
214
|
+
if (failedOutcome(record) && (previous === undefined || !failedOutcome(previous)))
|
|
215
|
+
failed.push(record.id);
|
|
216
|
+
if (record.state.status === "terminal") {
|
|
217
|
+
this.#live.delete(record.id);
|
|
218
|
+
this.#failedMigrations.delete(record.id);
|
|
219
|
+
this.#failFastChecks.delete(record.id);
|
|
220
|
+
if (this.#conversationOwners.has(record.id))
|
|
221
|
+
this.#settled.set(record.id, nodeOf(record));
|
|
222
|
+
this.#taskWaiters.resolve(record.id, record);
|
|
223
|
+
// Its owner may finalize now.
|
|
224
|
+
this.#scheduleReconcile();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (record.abortRequested && previous?.abortRequested !== true) {
|
|
228
|
+
this.#cascadePending = true;
|
|
229
|
+
// Signal a run invocation of the newly marked task; its next step ends it.
|
|
230
|
+
const invocation = this.#invocations.get(record.id);
|
|
231
|
+
if (invocation?.mode === "run")
|
|
232
|
+
invocation.controller.abort();
|
|
233
|
+
}
|
|
234
|
+
const status = record.state.status;
|
|
235
|
+
if (status === "completing" && previous?.state.status !== "completing") {
|
|
236
|
+
if (cancellationIntent(record))
|
|
237
|
+
this.#cascadePending = true;
|
|
238
|
+
this.#scheduleReconcile();
|
|
239
|
+
}
|
|
240
|
+
if (status === "waiting" && record.state.policy === "failFast" && previous?.state.status !== "waiting") {
|
|
241
|
+
this.#failFastChecks.add(record.id);
|
|
242
|
+
this.#scheduleReconcile();
|
|
243
|
+
}
|
|
244
|
+
this.#live.set(record.id, record);
|
|
245
|
+
updated.push(record);
|
|
246
|
+
}
|
|
247
|
+
for (const id of failed) {
|
|
248
|
+
for (const record of this.#live.values()) {
|
|
249
|
+
const state = record.state;
|
|
250
|
+
if (state.status === "waiting" && state.policy === "failFast" && state.on.includes(id)) {
|
|
251
|
+
this.#failFastChecks.add(record.id);
|
|
252
|
+
this.#scheduleReconcile();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const change of publication.changes) {
|
|
257
|
+
if (change.type === "conversation" && !this.#edges.has(change.value.id)) {
|
|
258
|
+
this.#setEdge(change.value.id, change.value.owner?.taskId ?? null);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const change of publication.changes) {
|
|
262
|
+
// A queued input below a cancelled owner is withdrawn, even after its cascade.
|
|
263
|
+
if (change.type !== "submission" || change.value.status !== "queued" || change.value.type !== "input")
|
|
137
264
|
continue;
|
|
265
|
+
const up = { conversation: change.value.conversationId };
|
|
266
|
+
if (!this.#chainKnown(up) || this.#belowCancelled(up))
|
|
267
|
+
this.#cascadePending = true;
|
|
268
|
+
}
|
|
269
|
+
for (const record of updated) {
|
|
270
|
+
// Work created below a cancelled owner, even after its cascade, is aborted too.
|
|
271
|
+
if (!this.#chainKnown(parentOf(record)))
|
|
272
|
+
this.#scheduleReconcile();
|
|
273
|
+
else if (!record.background && !record.abortRequested && this.#belowCancelled(parentOf(record))) {
|
|
274
|
+
this.#cascadePending = true;
|
|
138
275
|
}
|
|
139
|
-
this.#live.delete(record.id);
|
|
140
|
-
this.#failedMigrations.delete(record.id);
|
|
141
|
-
this.#taskWaiters.resolve(record.id, record);
|
|
142
276
|
}
|
|
277
|
+
// Also retries, with the next commit of any kind, a cascade whose commit failed.
|
|
278
|
+
if (this.#cascadePending)
|
|
279
|
+
this.#scheduleReconcile();
|
|
143
280
|
if (!changed)
|
|
144
281
|
return;
|
|
282
|
+
this.#resolveIdleWaiters();
|
|
283
|
+
this.#kick();
|
|
284
|
+
}
|
|
285
|
+
#resolveIdleWaiters() {
|
|
145
286
|
for (const conversationId of this.#idleWaiters.keys()) {
|
|
146
287
|
if (this.#idle(conversationId))
|
|
147
288
|
this.#idleWaiters.resolve(conversationId);
|
|
148
289
|
}
|
|
149
|
-
|
|
290
|
+
}
|
|
291
|
+
// ─── Ownership ───────────────────────────────────────────────────────────
|
|
292
|
+
#scheduleReconcile() {
|
|
293
|
+
if (this.#reconcileScheduled || this.#closing)
|
|
294
|
+
return;
|
|
295
|
+
this.#reconcileScheduled = true;
|
|
296
|
+
queueMicrotask(() => void this.#reconcile());
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* One commit that applies what committed records imply: abort marks below live owners with cancellation intent
|
|
300
|
+
* (spec §5.4), `failFast` marks (spec §5.5), withdrawn queued inputs below cancelled owners, and the final terminal
|
|
301
|
+
* record of every `completing` task whose ordinary owned work is gone. The durable records are the intent, so this
|
|
302
|
+
* also repairs whatever a crash left unapplied. Resolves idle waiters that the loaded edges decide.
|
|
303
|
+
*/
|
|
304
|
+
async #reconcile() {
|
|
305
|
+
this.#reconcileScheduled = false;
|
|
306
|
+
const cascade = this.#cascadePending;
|
|
307
|
+
this.#cascadePending = false;
|
|
308
|
+
const checks = [...this.#failFastChecks];
|
|
309
|
+
this.#failFastChecks.clear();
|
|
310
|
+
try {
|
|
311
|
+
await this.#session.commitWith(async (tx) => {
|
|
312
|
+
if (this.#closing)
|
|
313
|
+
return;
|
|
314
|
+
const queued = await this.#loadScopes(cascade);
|
|
315
|
+
const marked = new Set();
|
|
316
|
+
const mark = (record) => {
|
|
317
|
+
if (record.abortRequested || marked.has(record.id))
|
|
318
|
+
return;
|
|
319
|
+
marked.add(record.id);
|
|
320
|
+
tx.setTask({ ...record, abortRequested: true });
|
|
321
|
+
};
|
|
322
|
+
// Loading edges can reveal a cancelled owner, so marks are derived on every pass.
|
|
323
|
+
for (const record of this.#live.values()) {
|
|
324
|
+
if (!record.background && this.#belowCancelled(parentOf(record)))
|
|
325
|
+
mark(record);
|
|
326
|
+
}
|
|
327
|
+
for (const id of checks) {
|
|
328
|
+
const waiter = this.#live.get(id);
|
|
329
|
+
if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on)))
|
|
330
|
+
continue;
|
|
331
|
+
// Every other live task: the failed one keeps its own outcome.
|
|
332
|
+
for (const member of waiter.state.on) {
|
|
333
|
+
const record = this.#live.get(member);
|
|
334
|
+
if (record !== undefined && !failedOutcome(record))
|
|
335
|
+
mark(record);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const id of queued)
|
|
339
|
+
if (this.#belowCancelled({ conversation: id }))
|
|
340
|
+
await this.#withdrawInputs(tx, id);
|
|
341
|
+
await this.#finalize(tx);
|
|
342
|
+
}, this.#context);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
// Any pass may have staged marks, so a failed one is retried with the next commit.
|
|
346
|
+
this.#cascadePending = true;
|
|
347
|
+
for (const id of checks)
|
|
348
|
+
this.#failFastChecks.add(id);
|
|
349
|
+
if (!this.#closing)
|
|
350
|
+
this.#report(error);
|
|
351
|
+
}
|
|
352
|
+
this.#resolveIdleWaiters();
|
|
353
|
+
}
|
|
354
|
+
/** Whether any of `ids` holds or ended with an outcome other than `completed`. */
|
|
355
|
+
async #anyFailed(ids) {
|
|
356
|
+
for (const id of ids) {
|
|
357
|
+
const record = this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
|
|
358
|
+
if (record !== undefined && failedOutcome(record))
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its
|
|
365
|
+
* owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its
|
|
366
|
+
* Harness cleanup here.
|
|
367
|
+
*/
|
|
368
|
+
async #finalize(tx) {
|
|
369
|
+
for (;;) {
|
|
370
|
+
const overlay = overlayOf(tx);
|
|
371
|
+
const owned = this.#ownedLive(overlay);
|
|
372
|
+
const done = [...this.#liveRecords(overlay)].filter((record) => record.state.status === "completing" && !owned.has(record.id));
|
|
373
|
+
if (done.length === 0)
|
|
374
|
+
return;
|
|
375
|
+
for (const record of done) {
|
|
376
|
+
const outcome = record.state.outcome;
|
|
377
|
+
tx.setTask(withState(record, { status: "terminal", outcome }));
|
|
378
|
+
// REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.
|
|
379
|
+
if (outcome.status === "faulted" || outcome.status === "orphaned") {
|
|
380
|
+
await this.#settleOutcome(tx, record, outcome);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Load the owner chains of every live task and, with `queued`, of every conversation with queued submissions, on the
|
|
387
|
+
* Session line; returns the latter. Reads committed Storage directly, so it may run inside a commit callback.
|
|
388
|
+
*/
|
|
389
|
+
async #loadScopes(queued) {
|
|
390
|
+
for (const record of [...this.#live.values()]) {
|
|
391
|
+
if (!this.#chainKnown(parentOf(record)))
|
|
392
|
+
await this.#loadChain(parentOf(record));
|
|
393
|
+
}
|
|
394
|
+
if (!queued)
|
|
395
|
+
return [];
|
|
396
|
+
const submissions = await scanAll((cursor) => this.#storage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE, cursor, this.#context));
|
|
397
|
+
const conversations = [...new Set(submissions.map((submission) => submission.conversationId))];
|
|
398
|
+
for (const id of conversations)
|
|
399
|
+
await this.#loadChain({ conversation: id });
|
|
400
|
+
return conversations;
|
|
401
|
+
}
|
|
402
|
+
/** Load the owner edges and task nodes from `start` up to its ownerless root. */
|
|
403
|
+
async #loadChain(start, overlay) {
|
|
404
|
+
let at = start;
|
|
405
|
+
while (at !== undefined) {
|
|
406
|
+
if ("task" in at) {
|
|
407
|
+
let node = this.#node(at.task, overlay);
|
|
408
|
+
if (node === undefined) {
|
|
409
|
+
const record = await this.#storage.task(at.task, this.#context);
|
|
410
|
+
if (record === undefined)
|
|
411
|
+
return;
|
|
412
|
+
node = nodeOf(record);
|
|
413
|
+
if (record.state.status === "terminal")
|
|
414
|
+
this.#settled.set(record.id, node);
|
|
415
|
+
}
|
|
416
|
+
at = parentOf(node);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
let edge = this.#edge(at.conversation, overlay);
|
|
420
|
+
if (edge === undefined) {
|
|
421
|
+
const record = await this.#storage.conversation(at.conversation, this.#context);
|
|
422
|
+
edge = record?.owner?.taskId ?? null;
|
|
423
|
+
this.#setEdge(at.conversation, edge);
|
|
424
|
+
}
|
|
425
|
+
at = edge === null ? undefined : { task: edge };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
#setEdge(conversationId, owner) {
|
|
430
|
+
this.#edges.set(conversationId, owner);
|
|
431
|
+
if (owner !== null)
|
|
432
|
+
this.#conversationOwners.add(owner);
|
|
433
|
+
}
|
|
434
|
+
/** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */
|
|
435
|
+
#edge(id, overlay) {
|
|
436
|
+
return overlay?.edges.has(id) === true ? overlay.edges.get(id) : this.#edges.get(id);
|
|
437
|
+
}
|
|
438
|
+
#node(id, overlay) {
|
|
439
|
+
return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id);
|
|
440
|
+
}
|
|
441
|
+
/** Walk up from `start`: owner tasks and conversations, ending at an ownerless root or an edge not loaded yet. */
|
|
442
|
+
*#above(start, overlay) {
|
|
443
|
+
let at = start;
|
|
444
|
+
while (at !== undefined) {
|
|
445
|
+
if ("task" in at) {
|
|
446
|
+
const node = this.#node(at.task, overlay);
|
|
447
|
+
if (node === undefined) {
|
|
448
|
+
yield { unknown: true };
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
yield { task: at.task, node };
|
|
452
|
+
at = parentOf(node);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
yield { conversation: at.conversation };
|
|
456
|
+
const edge = this.#edge(at.conversation, overlay);
|
|
457
|
+
if (edge === undefined) {
|
|
458
|
+
yield { unknown: true };
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
at = edge === null ? undefined : { task: edge };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Whether every owner above `start` is loaded. */
|
|
466
|
+
#chainKnown(start, overlay) {
|
|
467
|
+
for (const step of this.#above(start, overlay))
|
|
468
|
+
if ("unknown" in step)
|
|
469
|
+
return false;
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
/** Live records, with the overlay's candidates replacing committed ones; terminal candidates are gone. */
|
|
473
|
+
*#liveRecords(overlay) {
|
|
474
|
+
for (const record of this.#live.values()) {
|
|
475
|
+
const candidate = overlay?.tasks.get(record.id) ?? record;
|
|
476
|
+
if (candidate.state.status !== "terminal")
|
|
477
|
+
yield candidate;
|
|
478
|
+
}
|
|
479
|
+
if (overlay === undefined)
|
|
480
|
+
return;
|
|
481
|
+
for (const record of overlay.tasks.values()) {
|
|
482
|
+
if (!this.#live.has(record.id) && record.state.status !== "terminal")
|
|
483
|
+
yield record;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Every task with live ordinary owned work (spec §5.5), mapped to that work: each live non-background task counts for
|
|
488
|
+
* every owner task above it up to and including the first background one. Owner chains must be loaded.
|
|
489
|
+
*/
|
|
490
|
+
#ownedLive(overlay) {
|
|
491
|
+
const owned = new Map();
|
|
492
|
+
for (const record of this.#liveRecords(overlay)) {
|
|
493
|
+
if (record.background)
|
|
494
|
+
continue;
|
|
495
|
+
for (const step of this.#above(parentOf(record), overlay)) {
|
|
496
|
+
if ("unknown" in step)
|
|
497
|
+
break;
|
|
498
|
+
if (!("task" in step))
|
|
499
|
+
continue;
|
|
500
|
+
const below = owned.get(step.task);
|
|
501
|
+
if (below === undefined)
|
|
502
|
+
owned.set(step.task, [record.id]);
|
|
503
|
+
else
|
|
504
|
+
below.push(record.id);
|
|
505
|
+
if (step.node.background)
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return owned;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Whether ordinary traversal from `scope` reaches `start`: walking up reaches the scope's conversation, or an
|
|
513
|
+
* ownerless one for `roots`, without crossing a background owner, unless `crossBackground`. `undefined` while an
|
|
514
|
+
* edge is not loaded.
|
|
515
|
+
*/
|
|
516
|
+
#inScope(start, scope, crossBackground = false) {
|
|
517
|
+
for (const step of this.#above(start)) {
|
|
518
|
+
if ("unknown" in step)
|
|
519
|
+
return undefined;
|
|
520
|
+
if ("conversation" in step) {
|
|
521
|
+
if ("conversation" in scope && step.conversation === scope.conversation)
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
else if (step.node.background && !crossBackground) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return "roots" in scope;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Whether a live owner's cancellation intent reaches `start`: walking up finds an owner with intent before a
|
|
532
|
+
* background owner without it. Terminal owners never cascade (spec §5.4).
|
|
533
|
+
*/
|
|
534
|
+
#belowCancelled(start) {
|
|
535
|
+
for (const step of this.#above(start)) {
|
|
536
|
+
if ("unknown" in step)
|
|
537
|
+
return false;
|
|
538
|
+
if (!("task" in step))
|
|
539
|
+
continue;
|
|
540
|
+
const live = this.#live.get(step.task);
|
|
541
|
+
if (live !== undefined && cancellationIntent(live))
|
|
542
|
+
return true;
|
|
543
|
+
if (step.node.background)
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
return false;
|
|
150
547
|
}
|
|
151
548
|
/** Close listener: runs synchronously once admission is sealed, before `join()`. */
|
|
152
549
|
#seal() {
|
|
@@ -192,20 +589,26 @@ export class TaskScheduler {
|
|
|
192
589
|
await this.#session.commitWith(async (tx) => {
|
|
193
590
|
if (!this.#enabled || this.#closing)
|
|
194
591
|
return;
|
|
592
|
+
await this.#loadScopes(false);
|
|
593
|
+
const owned = this.#ownedLive();
|
|
195
594
|
// Taken once per pass, and only when some task is a candidate.
|
|
196
595
|
let snapshot;
|
|
197
596
|
for (const record of [...this.#live.values()]) {
|
|
198
|
-
if (this.#invocations.has(record.id) || this.#waitingOn(record).length > 0)
|
|
597
|
+
if (this.#invocations.has(record.id) || this.#waitingOn(record, owned).length > 0)
|
|
598
|
+
continue;
|
|
599
|
+
if (record.state.status === "completing")
|
|
199
600
|
continue;
|
|
601
|
+
const runnable = record;
|
|
200
602
|
const mode = record.abortRequested ? "abort" : "run";
|
|
201
603
|
snapshot ??= this.#registry.snapshot();
|
|
202
|
-
const resolution = this.#resolve(
|
|
604
|
+
const resolution = this.#resolve(runnable, snapshot);
|
|
203
605
|
if (resolution.kind === "blocked") {
|
|
204
|
-
if (mode === "abort")
|
|
606
|
+
if (mode === "abort") {
|
|
205
607
|
await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason });
|
|
608
|
+
}
|
|
206
609
|
continue;
|
|
207
610
|
}
|
|
208
|
-
if (resolution.record !==
|
|
611
|
+
if (resolution.record !== runnable || record.state.status !== "running") {
|
|
209
612
|
tx.setTask(withState(resolution.record, {
|
|
210
613
|
status: "running",
|
|
211
614
|
checkpoint: resolution.record.state.checkpoint,
|
|
@@ -226,6 +629,17 @@ export class TaskScheduler {
|
|
|
226
629
|
}
|
|
227
630
|
return reservations;
|
|
228
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since
|
|
634
|
+
* abort runs bottom-up, otherwise the live part of the `on` of a wait.
|
|
635
|
+
*/
|
|
636
|
+
#waitingOn(record, owned) {
|
|
637
|
+
if (record.abortRequested)
|
|
638
|
+
return owned.get(record.id) ?? [];
|
|
639
|
+
if (record.state.status !== "waiting")
|
|
640
|
+
return [];
|
|
641
|
+
return record.state.on.filter((id) => this.#live.has(id));
|
|
642
|
+
}
|
|
229
643
|
/** Resolve the record's definition by kind, migrating an older stored version. */
|
|
230
644
|
#resolve(record, snapshot) {
|
|
231
645
|
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
@@ -238,7 +652,7 @@ export class TaskScheduler {
|
|
|
238
652
|
if (definition.migrate === undefined)
|
|
239
653
|
throw missingMigration(record, definition);
|
|
240
654
|
const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);
|
|
241
|
-
const state = {
|
|
655
|
+
const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) };
|
|
242
656
|
const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };
|
|
243
657
|
return { kind: "ready", task: fit.task, record: migratedRecord };
|
|
244
658
|
}
|
|
@@ -261,35 +675,36 @@ export class TaskScheduler {
|
|
|
261
675
|
return { reason: "migration_failed", error: failed.error };
|
|
262
676
|
return { task, migrates: true };
|
|
263
677
|
}
|
|
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
678
|
/**
|
|
271
679
|
* Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a
|
|
272
680
|
* pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that
|
|
273
681
|
* cannot exist, shows as failed.
|
|
274
682
|
*/
|
|
275
|
-
inspect(snapshot) {
|
|
683
|
+
async inspect(snapshot) {
|
|
684
|
+
await this.#loadScopes(false);
|
|
685
|
+
const owned = this.#ownedLive();
|
|
276
686
|
const tasks = [];
|
|
277
|
-
for (const record of this.#live.values())
|
|
278
|
-
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
|
687
|
+
for (const record of this.#live.values()) {
|
|
688
|
+
tasks.push({ record, state: this.#inspectTask(record, snapshot, owned) });
|
|
689
|
+
}
|
|
279
690
|
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
|
280
691
|
return { scheduling, tasks };
|
|
281
692
|
}
|
|
282
|
-
#inspectTask(record, snapshot) {
|
|
693
|
+
#inspectTask(record, snapshot, owned) {
|
|
283
694
|
if (this.#invocations.has(record.id))
|
|
284
695
|
return { kind: "running" };
|
|
696
|
+
if (record.state.status === "completing")
|
|
697
|
+
return { kind: "completing" };
|
|
698
|
+
const on = this.#waitingOn(record, owned);
|
|
699
|
+
if (on.length > 0)
|
|
700
|
+
return { kind: "waiting", on };
|
|
285
701
|
const fit = this.#fit(record, snapshot.task(record.kind));
|
|
286
702
|
if ("reason" in fit)
|
|
287
703
|
return { kind: "blocked", ...fit };
|
|
288
704
|
if (fit.migrates && fit.task.definition.migrate === undefined) {
|
|
289
705
|
return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
|
|
290
706
|
}
|
|
291
|
-
|
|
292
|
-
return on.length > 0 ? { kind: "waiting", on } : { kind: "ready", migrates: fit.migrates };
|
|
707
|
+
return { kind: "ready", migrates: fit.migrates };
|
|
293
708
|
}
|
|
294
709
|
#createInvocation(record, mode) {
|
|
295
710
|
const controller = new AbortController();
|
|
@@ -331,7 +746,8 @@ export class TaskScheduler {
|
|
|
331
746
|
async #run(reservation) {
|
|
332
747
|
const invocation = reservation.invocation;
|
|
333
748
|
const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined };
|
|
334
|
-
const
|
|
749
|
+
const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };
|
|
750
|
+
const runtime = this.#runtime(invocation, phase);
|
|
335
751
|
let previous;
|
|
336
752
|
for (;;) {
|
|
337
753
|
const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state));
|
|
@@ -339,6 +755,8 @@ export class TaskScheduler {
|
|
|
339
755
|
if (current === undefined || this.#closing)
|
|
340
756
|
return;
|
|
341
757
|
const checkpoint = current.state.checkpoint;
|
|
758
|
+
// Each phase handler resolves its agent afresh, at first use.
|
|
759
|
+
phase.agent = undefined;
|
|
342
760
|
try {
|
|
343
761
|
await erased(state.task).phases[checkpoint.phase](current, runtime, invocation.context);
|
|
344
762
|
previous = { checkpoint };
|
|
@@ -349,11 +767,11 @@ export class TaskScheduler {
|
|
|
349
767
|
}
|
|
350
768
|
}
|
|
351
769
|
/**
|
|
352
|
-
* Precedence rules for a run invocation, on the line. Rules 1 (terminal) and 2 (closing)
|
|
353
|
-
* Returns whether the invocation continues with the next phase.
|
|
770
|
+
* Precedence rules for a run invocation, on the line. Rules 1 (terminal, `completing`, or `waiting`) and 2 (closing)
|
|
771
|
+
* are applied by `#step`. Returns whether the invocation continues with the next phase.
|
|
354
772
|
*/
|
|
355
773
|
#decide(tx, current, previous, state) {
|
|
356
|
-
// 3. abort mark: end;
|
|
774
|
+
// 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.
|
|
357
775
|
if (current.abortRequested)
|
|
358
776
|
return false;
|
|
359
777
|
if (previous === undefined)
|
|
@@ -390,7 +808,8 @@ export class TaskScheduler {
|
|
|
390
808
|
return;
|
|
391
809
|
let failure;
|
|
392
810
|
try {
|
|
393
|
-
const
|
|
811
|
+
const phase = { snapshot: () => reservation.snapshot, task: () => reservation.task, agent: undefined };
|
|
812
|
+
const runtime = this.#runtime(invocation, phase);
|
|
394
813
|
await erased(reservation.task).abort(current, runtime, invocation.context);
|
|
395
814
|
}
|
|
396
815
|
catch (error) {
|
|
@@ -400,15 +819,16 @@ export class TaskScheduler {
|
|
|
400
819
|
await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
|
|
401
820
|
}
|
|
402
821
|
/**
|
|
403
|
-
* One synchronous decision on the Session line. A
|
|
404
|
-
* invocation without a write; otherwise `decide` may stage a write
|
|
405
|
-
* Ending happens inside the callback, before a fault's Harness
|
|
406
|
-
* close, also ends the invocation.
|
|
822
|
+
* One synchronous decision on the Session line. A task that is no longer running (rule 1: terminal, `completing`, or
|
|
823
|
+
* `waiting`) or a closing Harness (rule 2) ends the invocation without a write; otherwise `decide` may stage a write
|
|
824
|
+
* and returns whether the invocation continues. Ending happens inside the callback, before a fault's Harness
|
|
825
|
+
* cleanup. A rejected step, such as admission after close, also ends the invocation.
|
|
407
826
|
*/
|
|
408
827
|
async #step(invocation, decide) {
|
|
409
828
|
try {
|
|
410
829
|
return await this.#session.commitWith(async (tx) => {
|
|
411
|
-
const
|
|
830
|
+
const found = this.#live.get(invocation.taskId);
|
|
831
|
+
const current = found?.state.status === "running" ? found : undefined;
|
|
412
832
|
const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
|
|
413
833
|
if (decision === true)
|
|
414
834
|
return current;
|
|
@@ -427,12 +847,65 @@ export class TaskScheduler {
|
|
|
427
847
|
return undefined;
|
|
428
848
|
}
|
|
429
849
|
}
|
|
430
|
-
/**
|
|
431
|
-
|
|
850
|
+
/**
|
|
851
|
+
* Write an outcome the scheduler decided. While the task's ordinary owned work is live it holds as `completing` and
|
|
852
|
+
* its Harness cleanup waits for the final commit (spec §5.5, rule 4); otherwise it is terminal with its cleanup.
|
|
853
|
+
*/
|
|
854
|
+
async #terminate(tx, record, outcome) {
|
|
855
|
+
await this.#loadScopes(false);
|
|
856
|
+
if (this.#ownedLive(overlayOf(tx)).has(record.id)) {
|
|
857
|
+
tx.setTask(withState(record, { status: "completing", outcome }));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
432
860
|
tx.setTask(withState(record, { status: "terminal", outcome }));
|
|
433
|
-
|
|
861
|
+
await this.#settleOutcome(tx, record, outcome);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Replace a running task's state with what it committed. A terminal state holds as `completing` while ordinary owned
|
|
865
|
+
* work is live, judged on the commit's candidates, so work the same commit creates below the task counts. A wait is
|
|
866
|
+
* validated first.
|
|
867
|
+
*/
|
|
868
|
+
async #commitState(tx, invocation, current, next) {
|
|
869
|
+
if (next.status === "waiting")
|
|
870
|
+
await this.#validateWait(tx, invocation, current, next.on, next.policy);
|
|
871
|
+
if (next.status === "terminal") {
|
|
872
|
+
const overlay = overlayOf(tx);
|
|
873
|
+
await this.#loadScopes(false);
|
|
874
|
+
for (const record of overlay.tasks.values())
|
|
875
|
+
await this.#loadChain(parentOf(record), overlay);
|
|
876
|
+
if (this.#ownedLive(overlay).has(current.id)) {
|
|
877
|
+
tx.setTask(withState(current, { status: "completing", outcome: next.outcome }));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
tx.setTask(withState(current, next));
|
|
434
882
|
}
|
|
435
|
-
/**
|
|
883
|
+
/**
|
|
884
|
+
* A wait names existing tasks other than the waiter and its owners, which could never finish first; `failFast` only
|
|
885
|
+
* tasks the waiter owns. An abort handler cannot wait.
|
|
886
|
+
*/
|
|
887
|
+
async #validateWait(tx, invocation, current, on, policy) {
|
|
888
|
+
if (invocation.mode === "abort")
|
|
889
|
+
throw new Error(`Abort handler of task ${current.id} cannot wait`);
|
|
890
|
+
const overlay = overlayOf(tx);
|
|
891
|
+
await this.#loadChain(parentOf(current));
|
|
892
|
+
const owners = new Set();
|
|
893
|
+
for (const step of this.#above(parentOf(current)))
|
|
894
|
+
if ("task" in step)
|
|
895
|
+
owners.add(step.task);
|
|
896
|
+
for (const id of on) {
|
|
897
|
+
if (id === current.id || owners.has(id)) {
|
|
898
|
+
throw new Error(`Task ${current.id} cannot wait on itself or its owner ${id}`);
|
|
899
|
+
}
|
|
900
|
+
const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));
|
|
901
|
+
if (member === undefined)
|
|
902
|
+
throw new Error(`Task ${id} does not exist`);
|
|
903
|
+
if (policy === "failFast" && member.owner !== current.id) {
|
|
904
|
+
throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** End an invocation: its runtime operations reject from now on, its signal aborts, its watches stop, and its task is free. */
|
|
436
909
|
#end(invocation) {
|
|
437
910
|
if (invocation.ended)
|
|
438
911
|
return;
|
|
@@ -441,30 +914,67 @@ export class TaskScheduler {
|
|
|
441
914
|
this.#invocations.delete(invocation.taskId);
|
|
442
915
|
for (const watch of invocation.watches)
|
|
443
916
|
void watch.stop();
|
|
917
|
+
// Pending waits bound to the invocation, such as a tool's waitForTask(), reject with it.
|
|
918
|
+
invocation.controller.abort(endedError(invocation));
|
|
444
919
|
}
|
|
920
|
+
/** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */
|
|
445
921
|
#idle(conversationId) {
|
|
922
|
+
const scope = conversationId === undefined ? { roots: true } : { conversation: conversationId };
|
|
446
923
|
for (const record of this.#live.values()) {
|
|
447
|
-
if (record.background)
|
|
448
|
-
continue;
|
|
449
|
-
if (conversationId === undefined || record.conversationId === conversationId)
|
|
924
|
+
if (!record.background && this.#inScope(parentOf(record), scope) !== false)
|
|
450
925
|
return false;
|
|
451
926
|
}
|
|
452
927
|
return true;
|
|
453
928
|
}
|
|
454
929
|
// ─── Invocation runtime ──────────────────────────────────────────────────
|
|
455
|
-
#runtime(invocation,
|
|
930
|
+
#runtime(invocation, phase) {
|
|
931
|
+
// Resolved at most once per phase handler, at first use, with the invocation's context; fixed for the phase.
|
|
932
|
+
const agent = () => {
|
|
933
|
+
if (invocation.ended)
|
|
934
|
+
return Promise.reject(endedError(invocation));
|
|
935
|
+
if (phase.agent === undefined) {
|
|
936
|
+
phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context);
|
|
937
|
+
// A caller that stops waiting must not leave the shared resolution's failure unobserved.
|
|
938
|
+
phase.agent.catch(() => { });
|
|
939
|
+
}
|
|
940
|
+
return phase.agent;
|
|
941
|
+
};
|
|
942
|
+
const hooks = {
|
|
943
|
+
each: async (name, invoke) => {
|
|
944
|
+
for (const handlers of agentHooks(await agent(), phase.task().definition.name)) {
|
|
945
|
+
const handler = handlers[name];
|
|
946
|
+
if (typeof handler !== "function")
|
|
947
|
+
continue;
|
|
948
|
+
try {
|
|
949
|
+
await invoke(handler.bind(handlers));
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
if (invocation.controller.signal.aborted)
|
|
953
|
+
throw error;
|
|
954
|
+
this.#report(error);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
const settings = this.#settings;
|
|
456
960
|
return {
|
|
457
961
|
taskId: invocation.taskId,
|
|
458
962
|
conversationId: invocation.conversationId,
|
|
459
963
|
signal: invocation.controller.signal,
|
|
460
964
|
models: this.#models,
|
|
965
|
+
agent: (context) => invocation.ended ? Promise.reject(endedError(invocation)) : awaitWithContext(agent(), context),
|
|
966
|
+
get settings() {
|
|
967
|
+
return settings();
|
|
968
|
+
},
|
|
969
|
+
env: (context) => this.#read(invocation, () => this.#env(invocation.conversationId, context)),
|
|
970
|
+
hooks: hooks,
|
|
461
971
|
get registry() {
|
|
462
|
-
return snapshot();
|
|
972
|
+
return phase.snapshot();
|
|
463
973
|
},
|
|
464
974
|
commit: (change, context) => this.#gated(invocation, async (tx, current) => {
|
|
465
975
|
const next = await change(tx, current);
|
|
466
976
|
if (next !== undefined)
|
|
467
|
-
tx
|
|
977
|
+
await this.#commitState(tx, invocation, current, next);
|
|
468
978
|
}, context),
|
|
469
979
|
memo: ((name, ...rest) => {
|
|
470
980
|
if (rest.length === 1) {
|
|
@@ -483,9 +993,46 @@ export class TaskScheduler {
|
|
|
483
993
|
watchDoc: ((...args) => this.#watchDoc(invocation, args)),
|
|
484
994
|
snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
|
|
485
995
|
snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
|
|
996
|
+
getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine(() => this.#storage.task(id, context)))),
|
|
997
|
+
waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
|
|
998
|
+
outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
|
|
999
|
+
const outcomes = [];
|
|
1000
|
+
for (const id of ids) {
|
|
1001
|
+
const state = (await this.#storage.task(id, context))?.state;
|
|
1002
|
+
if (state?.status !== "terminal")
|
|
1003
|
+
throw new Error(`Task ${id} is not terminal`);
|
|
1004
|
+
outcomes.push(state.outcome);
|
|
1005
|
+
}
|
|
1006
|
+
return outcomes;
|
|
1007
|
+
}))),
|
|
1008
|
+
conversation: (id, context) => this.#read(invocation, () => this.#conversation(id, {
|
|
1009
|
+
signal: invocation.controller.signal,
|
|
1010
|
+
check: () => {
|
|
1011
|
+
if (invocation.ended)
|
|
1012
|
+
throw endedError(invocation);
|
|
1013
|
+
},
|
|
1014
|
+
}, context)),
|
|
1015
|
+
entry: ((...args) => {
|
|
1016
|
+
const [token, id, context] = args.length === 2
|
|
1017
|
+
? [undefined, args[0], args[1]]
|
|
1018
|
+
: [args[0], args[1], args[2]];
|
|
1019
|
+
return this.#read(invocation, async () => {
|
|
1020
|
+
const found = await this.#session.readOnLine(() => this.#storage.entry(invocation.conversationId, id, context));
|
|
1021
|
+
const entry = found?.entry;
|
|
1022
|
+
return token === undefined || entry?.kind === token.kind ? entry : undefined;
|
|
1023
|
+
});
|
|
1024
|
+
}),
|
|
486
1025
|
context: (conversationId, context, at) => this.#read(invocation, () => readContext(this.#session, this.#storage, conversationId, context, at)),
|
|
487
|
-
now: () =>
|
|
488
|
-
|
|
1026
|
+
now: () => {
|
|
1027
|
+
if (invocation.ended)
|
|
1028
|
+
throw endedError(invocation);
|
|
1029
|
+
return this.#now();
|
|
1030
|
+
},
|
|
1031
|
+
report: (error) => {
|
|
1032
|
+
if (invocation.ended)
|
|
1033
|
+
throw endedError(invocation);
|
|
1034
|
+
this.#report(error);
|
|
1035
|
+
},
|
|
489
1036
|
};
|
|
490
1037
|
}
|
|
491
1038
|
/** Run a committed-state read unless the invocation has ended. */
|
|
@@ -503,9 +1050,12 @@ export class TaskScheduler {
|
|
|
503
1050
|
throw endedError(invocation);
|
|
504
1051
|
if (this.#closing)
|
|
505
1052
|
throw closedError();
|
|
506
|
-
const
|
|
507
|
-
if (
|
|
1053
|
+
const found = this.#live.get(invocation.taskId);
|
|
1054
|
+
if (found === undefined)
|
|
508
1055
|
throw new Error(`Task ${invocation.taskId} is terminal`);
|
|
1056
|
+
if (found.state.status !== "running")
|
|
1057
|
+
throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);
|
|
1058
|
+
const current = found;
|
|
509
1059
|
if (invocation.mode === "run" && current.abortRequested) {
|
|
510
1060
|
throw new Error(`Task ${invocation.taskId} has a durable abort mark`);
|
|
511
1061
|
}
|
|
@@ -543,6 +1093,30 @@ export class TaskScheduler {
|
|
|
543
1093
|
return watch;
|
|
544
1094
|
}
|
|
545
1095
|
}
|
|
1096
|
+
/** A live owner's durable cancellation intent: its abort mark, or a held outcome other than `completed`. */
|
|
1097
|
+
function cancellationIntent(record) {
|
|
1098
|
+
return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record));
|
|
1099
|
+
}
|
|
1100
|
+
/** Whether the record holds or ends with an outcome other than `completed`. */
|
|
1101
|
+
function failedOutcome(record) {
|
|
1102
|
+
const state = record.state;
|
|
1103
|
+
return (state.status === "completing" || state.status === "terminal") && state.outcome.status !== "completed";
|
|
1104
|
+
}
|
|
1105
|
+
function parentOf(node) {
|
|
1106
|
+
return node.owner !== undefined ? { task: node.owner } : { conversation: node.conversationId };
|
|
1107
|
+
}
|
|
1108
|
+
function nodeOf(record) {
|
|
1109
|
+
return {
|
|
1110
|
+
conversationId: record.conversationId,
|
|
1111
|
+
...(record.owner === undefined ? {} : { owner: record.owner }),
|
|
1112
|
+
background: record.background,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
function overlayOf(tx) {
|
|
1116
|
+
const tasks = new Map(tx.stagedTasks().map((record) => [record.id, record]));
|
|
1117
|
+
const edges = new Map(tx.stagedConversations().map((record) => [record.id, record.owner?.taskId ?? null]));
|
|
1118
|
+
return { tasks, edges };
|
|
1119
|
+
}
|
|
546
1120
|
/** Own memo entry only; memo names such as `toString` must not resolve to inherited properties. */
|
|
547
1121
|
function memoOf(record, name) {
|
|
548
1122
|
const memos = record?.memos;
|
|
@@ -561,9 +1135,9 @@ function erased(task) {
|
|
|
561
1135
|
function endedError(invocation) {
|
|
562
1136
|
return new Error(`Task ${invocation.taskId} invocation has ended`);
|
|
563
1137
|
}
|
|
564
|
-
/** Replace a live record's state; memos disappear
|
|
1138
|
+
/** Replace a live record's state; memos disappear once an outcome is decided. */
|
|
565
1139
|
function withState(record, state) {
|
|
566
|
-
if (state.status !== "terminal")
|
|
1140
|
+
if (state.status !== "terminal" && state.status !== "completing")
|
|
567
1141
|
return { ...record, state };
|
|
568
1142
|
const { memos: _memos, ...rest } = record;
|
|
569
1143
|
return { ...rest, state };
|