pi-durable-subagents 1.0.22 → 1.0.24
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 +30 -0
- package/README.md +120 -4
- package/dist/agent/child.js +4 -4
- package/dist/agent/main/schema.js +2 -1
- package/dist/agent/main/tool.js +5 -1
- package/dist/agent/main.js +1 -1
- package/dist/cli/main.js +6 -1
- package/dist/cli/requests.js +59 -47
- package/dist/events/cli.js +117 -0
- package/dist/events/derive.js +128 -0
- package/dist/events/fence.js +61 -0
- package/dist/events/labels.js +53 -0
- package/dist/events/log.js +491 -0
- package/dist/events/pump.js +351 -0
- package/dist/events/r7.js +412 -0
- package/dist/events/types.js +19 -0
- package/dist/kernel/journal.js +23 -2
- package/dist/orchestrator/config.js +3 -2
- package/dist/orchestrator/engine.js +77 -9
- package/dist/orchestrator/executor/hibernate.js +8 -13
- package/dist/orchestrator/executor/session.js +28 -2
- package/dist/orchestrator/snapshot.js +11 -3
- package/dist/orchestrator/store.js +3 -0
- package/dist/paths.js +2 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// R2: the orchestrator's event pump. Derives events from the durable sources (workflow journals, the orchestrator
|
|
2
|
+
// ledger's `created`) after their appends and writes them to the event log with the watermarks of the sources derived
|
|
3
|
+
// (same fsync), so a restart re-derives only after them (at least once). It is also the EventSink other producers
|
|
4
|
+
// (R7 waiting/moving) emit through. One chain serializes passes, emits, compaction and close. A pass that cannot log
|
|
5
|
+
// what it derived retries in the background, and `flush` (prune) rejects; a broken log (a failure after a compaction's
|
|
6
|
+
// rename) is reopened by the next pass or emit.
|
|
7
|
+
// Memory: per unpruned workflow one watermark and one cached identity; reads the journals' committed arrays from their
|
|
8
|
+
// watermark (no journal copies per tick); a pass holds at most one chunk (EVENT_SEQ_SKIP drafts) at a time.
|
|
9
|
+
import { eventsLog } from "../paths.js";
|
|
10
|
+
import { JT, isEntry } from "../types.js";
|
|
11
|
+
import { requestId } from "../requests.js";
|
|
12
|
+
import { deriveCreated, deriveEntry, labelsOf } from "./derive.js";
|
|
13
|
+
import { EventLog } from "./log.js";
|
|
14
|
+
import { EVENT_RETENTION_MS, EVENT_SEQ_SKIP } from "./types.js";
|
|
15
|
+
const COMPACT_EVERY_MS = 3600_000;
|
|
16
|
+
const committed = (j) => j.committed?.() ?? j.entries();
|
|
17
|
+
/** Retention: a workflow whose events may go once old: final (done/failed/stopped, not parked) in its current
|
|
18
|
+
* revision, no open question and no call of that revision without a seal (or retirement). */
|
|
19
|
+
export function quiet(wid, entries) {
|
|
20
|
+
let revision = 1, done;
|
|
21
|
+
const calls = new Set(), ended = new Set(), open = new Set();
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
if (e.type === "revised") {
|
|
24
|
+
revision = Number(e.revision);
|
|
25
|
+
calls.clear();
|
|
26
|
+
done = undefined;
|
|
27
|
+
}
|
|
28
|
+
else if (e.type === JT.done)
|
|
29
|
+
done = e;
|
|
30
|
+
else if (e.type === "resumed" && !e.call)
|
|
31
|
+
done = undefined;
|
|
32
|
+
else if (e.type === "call")
|
|
33
|
+
calls.add(`${wid}@${revision}/${String(e.key)}@${String(e.gen)}`);
|
|
34
|
+
else if (e.type === "generation" && Number(e.revision) === revision)
|
|
35
|
+
calls.add(`${wid}@${revision}/${String(e.key)}@${String(e.gen)}`);
|
|
36
|
+
else if (e.type === JT.sealed || e.type === "retired")
|
|
37
|
+
ended.add(String(e.call));
|
|
38
|
+
else if (isEntry(e, JT.attention) && e.item.kind === "question")
|
|
39
|
+
open.add(`${e.item.id}\0${e.item.rev}`);
|
|
40
|
+
else if (isEntry(e, JT.attentionResolved))
|
|
41
|
+
open.delete(`${e.id}\0${e.rev}`);
|
|
42
|
+
}
|
|
43
|
+
if (!done || !["done", "failed", "stopped"].includes(String(done.status)) || open.size)
|
|
44
|
+
return false;
|
|
45
|
+
return [...calls].every(c => ended.has(c));
|
|
46
|
+
}
|
|
47
|
+
export class EventPump {
|
|
48
|
+
options;
|
|
49
|
+
log;
|
|
50
|
+
chain = Promise.resolve();
|
|
51
|
+
marks = new Map();
|
|
52
|
+
/** Sources whose watermark moved since the log last recorded it. */
|
|
53
|
+
unsaved = new Set();
|
|
54
|
+
dirty = new Set();
|
|
55
|
+
identities = new Map();
|
|
56
|
+
timer;
|
|
57
|
+
closed = false;
|
|
58
|
+
compactedAt = 0;
|
|
59
|
+
reported;
|
|
60
|
+
identityScan = -1;
|
|
61
|
+
/** Set by open() when the log was created (or replaced after corruption) and everything on disk was derived. */
|
|
62
|
+
backfilled = false;
|
|
63
|
+
/** Resolves once open() (or close()) ran: emits wait for it. */
|
|
64
|
+
ready;
|
|
65
|
+
isReady;
|
|
66
|
+
constructor(options) {
|
|
67
|
+
this.options = options;
|
|
68
|
+
this.ready = new Promise(resolve => { this.isReady = resolve; });
|
|
69
|
+
options.orch.onAppend = () => this.kick();
|
|
70
|
+
options.store.appended = wid => this.kick(wid);
|
|
71
|
+
}
|
|
72
|
+
/** The log's epoch and head (after open). */
|
|
73
|
+
get head() { return this.log && { epoch: this.log.epoch, head: this.log.head, dropped: this.log.dropped }; }
|
|
74
|
+
serial(fn) {
|
|
75
|
+
const run = this.chain.then(fn);
|
|
76
|
+
this.chain = run.catch(() => { });
|
|
77
|
+
return run;
|
|
78
|
+
}
|
|
79
|
+
/** Orchestrator start, after the store recovered its workflows: open (or create) the log, compact, then derive
|
|
80
|
+
* everything after the watermarks (on a new log: everything still on disk). */
|
|
81
|
+
open() {
|
|
82
|
+
return this.serial(async () => {
|
|
83
|
+
if (this.closed || this.log)
|
|
84
|
+
return;
|
|
85
|
+
const opened = await EventLog.open(eventsLog(this.options.home));
|
|
86
|
+
if (opened.corrupt)
|
|
87
|
+
console.error(`durable-subagents: ${opened.corrupt}; a new event log (epoch ${opened.log.epoch}) starts`);
|
|
88
|
+
this.log = opened.log;
|
|
89
|
+
this.backfilled = opened.created;
|
|
90
|
+
// A source the store no longer has was pruned: its journal is gone and it is never derived again.
|
|
91
|
+
for (const [source, seq] of opened.marks)
|
|
92
|
+
if (source === "orch" || this.options.store.workflows.has(source))
|
|
93
|
+
this.marks.set(source, seq);
|
|
94
|
+
await this.compact();
|
|
95
|
+
for (const wid of this.options.store.workflows.keys())
|
|
96
|
+
this.dirty.add(wid);
|
|
97
|
+
await this.pass().catch(() => { }); // logged; retried in the background
|
|
98
|
+
}).finally(() => this.isReady());
|
|
99
|
+
}
|
|
100
|
+
/** A source appended: derive soon (batched). */
|
|
101
|
+
kick(wid) {
|
|
102
|
+
if (wid !== undefined)
|
|
103
|
+
this.dirty.add(wid);
|
|
104
|
+
if (this.timer || this.closed)
|
|
105
|
+
return;
|
|
106
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.serial(() => this.pass()).catch(() => { }); }, this.options.delayMs ?? 50);
|
|
107
|
+
this.timer.unref?.();
|
|
108
|
+
}
|
|
109
|
+
/** Derive and log everything appended so far (prune calls it before the journal goes). Rejects when that could not be
|
|
110
|
+
* logged (or the log is not open): the caller must not drop the sources. */
|
|
111
|
+
flush() {
|
|
112
|
+
if (this.timer) {
|
|
113
|
+
clearTimeout(this.timer);
|
|
114
|
+
this.timer = undefined;
|
|
115
|
+
}
|
|
116
|
+
return this.serial(async () => {
|
|
117
|
+
if (!this.log || this.closed)
|
|
118
|
+
throw new Error("event log not open");
|
|
119
|
+
await this.pass();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** EventSink: append drafts (any type), echoing the workflow's request id and labels when the draft has none. */
|
|
123
|
+
async emit(drafts) {
|
|
124
|
+
await this.ready;
|
|
125
|
+
return this.serial(async () => {
|
|
126
|
+
// After close (the orchestrator exits) nothing is logged: R7 state is derived again by the next orchestrator.
|
|
127
|
+
if (!this.log || this.closed || !drafts.length)
|
|
128
|
+
return;
|
|
129
|
+
const log = await this.writer();
|
|
130
|
+
const filled = drafts.map(d => {
|
|
131
|
+
const id = this.identity(d.wid);
|
|
132
|
+
return { ...d, ...(d.request === undefined && id.request !== undefined ? { request: id.request } : {}), ...(d.labels === undefined && id.labels ? { labels: id.labels } : {}) };
|
|
133
|
+
});
|
|
134
|
+
await log.append(filled);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Orchestrator exit: derive what is left, record the watermarks, close the log. */
|
|
138
|
+
close() {
|
|
139
|
+
if (this.timer) {
|
|
140
|
+
clearTimeout(this.timer);
|
|
141
|
+
this.timer = undefined;
|
|
142
|
+
}
|
|
143
|
+
const done = this.serial(async () => {
|
|
144
|
+
if (this.closed)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
if (this.log) {
|
|
148
|
+
await this.pass();
|
|
149
|
+
if (this.unsaved.size) {
|
|
150
|
+
await (await this.writer()).append([], this.saved());
|
|
151
|
+
this.unsaved.clear();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.closed = true;
|
|
157
|
+
await this.log?.close();
|
|
158
|
+
if (this.timer) {
|
|
159
|
+
clearTimeout(this.timer);
|
|
160
|
+
this.timer = undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}).finally(() => this.isReady());
|
|
164
|
+
return done.catch(error => console.error(`durable-subagents: event log close failed: ${String(error)}`));
|
|
165
|
+
}
|
|
166
|
+
saved(next) {
|
|
167
|
+
const out = new Map();
|
|
168
|
+
for (const source of this.unsaved) {
|
|
169
|
+
const seq = this.marks.get(source);
|
|
170
|
+
if (seq !== undefined)
|
|
171
|
+
out.set(source, seq);
|
|
172
|
+
}
|
|
173
|
+
for (const [source, seq] of next ?? [])
|
|
174
|
+
out.set(source, seq);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/** Request id and labels of a workflow's run request (cached while the workflow is not pruned). */
|
|
178
|
+
identity(wid) {
|
|
179
|
+
const cached = this.identities.get(wid);
|
|
180
|
+
if (cached)
|
|
181
|
+
return cached;
|
|
182
|
+
const orch = committed(this.options.orch);
|
|
183
|
+
// A miss rescans the ledger only after it grew (a journal may be derived before its `created` is appended).
|
|
184
|
+
if (orch.length === this.identityScan)
|
|
185
|
+
return {};
|
|
186
|
+
this.identityScan = orch.length;
|
|
187
|
+
// One scan resolves every workflow not cached yet (a backfill asks for all of them).
|
|
188
|
+
const wanted = new Map(), rids = new Map();
|
|
189
|
+
for (const e of orch)
|
|
190
|
+
if (e.type === JT.created && this.options.store.workflows.has(String(e.wid)) && !this.identities.has(String(e.wid))) {
|
|
191
|
+
wanted.set(String(e.wid), String(e.rid));
|
|
192
|
+
rids.set(String(e.rid), String(e.wid));
|
|
193
|
+
}
|
|
194
|
+
const found = new Map();
|
|
195
|
+
for (const [w, rid] of wanted)
|
|
196
|
+
found.set(w, requestId(rid) !== undefined ? { request: requestId(rid) } : {});
|
|
197
|
+
for (const e of orch) {
|
|
198
|
+
if (e.type !== "request")
|
|
199
|
+
continue;
|
|
200
|
+
const req = e.request, w = req && req.kind === "run" ? rids.get(req.rid) : undefined;
|
|
201
|
+
const labels = w !== undefined ? labelsOf(req.body) : undefined;
|
|
202
|
+
if (labels)
|
|
203
|
+
found.set(w, { ...found.get(w), labels });
|
|
204
|
+
}
|
|
205
|
+
// Only workflows with a `created` entry are cached: an earlier pass may see a journal before its ledger entry.
|
|
206
|
+
for (const [w, id] of found)
|
|
207
|
+
this.identities.set(w, id);
|
|
208
|
+
return this.identities.get(wid) ?? {};
|
|
209
|
+
}
|
|
210
|
+
/** The writer; a broken one (a failure after a compaction's rename) is reopened first: the open applies the start
|
|
211
|
+
* skip. A log it had to create anew (corrupt or gone) is backfilled from everything on disk: the caller's drafts
|
|
212
|
+
* were derived for the old one, so this throws (a pass retries from the cleared watermarks, R7 on its next tick). */
|
|
213
|
+
async writer() {
|
|
214
|
+
const log = this.log;
|
|
215
|
+
if (!log.broken)
|
|
216
|
+
return log;
|
|
217
|
+
await log.close();
|
|
218
|
+
const opened = await EventLog.open(eventsLog(this.options.home));
|
|
219
|
+
this.log = opened.log;
|
|
220
|
+
if (opened.corrupt)
|
|
221
|
+
console.error(`durable-subagents: ${opened.corrupt}; a new event log (epoch ${opened.log.epoch}) starts`);
|
|
222
|
+
if (opened.created) {
|
|
223
|
+
this.marks.clear();
|
|
224
|
+
this.unsaved.clear();
|
|
225
|
+
this.backfilled = true;
|
|
226
|
+
for (const wid of this.options.store.workflows.keys())
|
|
227
|
+
this.dirty.add(wid);
|
|
228
|
+
this.kick();
|
|
229
|
+
throw new Error(`event log replaced by a new epoch ${opened.log.epoch}: deriving everything again`);
|
|
230
|
+
}
|
|
231
|
+
return opened.log;
|
|
232
|
+
}
|
|
233
|
+
/** One derivation pass over the orchestrator ledger and the workflows appended to since the last one. Appends in
|
|
234
|
+
* chunks of at most EVENT_SEQ_SKIP drafts, each with the watermarks of what it completes (a source cut inside a chunk
|
|
235
|
+
* gets the position up to which its events are in it), so memory stays bounded and a failure re-derives only the
|
|
236
|
+
* rest. Throws when a chunk could not be logged (after scheduling a retry). */
|
|
237
|
+
async pass() {
|
|
238
|
+
if (!this.log || this.closed)
|
|
239
|
+
return;
|
|
240
|
+
const dirty = [...this.dirty];
|
|
241
|
+
this.dirty.clear();
|
|
242
|
+
let drafts = [], forgotten = [];
|
|
243
|
+
const next = new Map();
|
|
244
|
+
// Log the chunk (with the watermarks it completes) and move the in-memory watermarks; a chunk without events only
|
|
245
|
+
// moves them (recorded with the next write, or at close).
|
|
246
|
+
const commit = async () => {
|
|
247
|
+
if (drafts.length) {
|
|
248
|
+
await (await this.writer()).append(drafts, this.saved(next));
|
|
249
|
+
this.unsaved.clear();
|
|
250
|
+
}
|
|
251
|
+
for (const [source, seq] of next) {
|
|
252
|
+
this.marks.set(source, seq);
|
|
253
|
+
if (!drafts.length)
|
|
254
|
+
this.unsaved.add(source);
|
|
255
|
+
}
|
|
256
|
+
for (const wid of forgotten) {
|
|
257
|
+
this.marks.delete(wid);
|
|
258
|
+
this.identities.delete(wid);
|
|
259
|
+
this.unsaved.delete(wid);
|
|
260
|
+
}
|
|
261
|
+
drafts = [];
|
|
262
|
+
forgotten = [];
|
|
263
|
+
next.clear();
|
|
264
|
+
};
|
|
265
|
+
/** Add the drafts of source position `at`, committing first when they would overflow the chunk (`done` = the
|
|
266
|
+
* watermark of that source before them). */
|
|
267
|
+
const add = async (source, done, derived) => {
|
|
268
|
+
if (!derived.length)
|
|
269
|
+
return;
|
|
270
|
+
if (drafts.length + derived.length > EVENT_SEQ_SKIP && drafts.length) {
|
|
271
|
+
if (done > (this.marks.get(source) ?? 0))
|
|
272
|
+
next.set(source, done);
|
|
273
|
+
await commit();
|
|
274
|
+
}
|
|
275
|
+
drafts.push(...derived);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
const orch = committed(this.options.orch), from = this.marks.get("orch") ?? 0;
|
|
279
|
+
// The ledger first: a workflow's `submitted` precedes its journal's events.
|
|
280
|
+
for (let i = from; i < orch.length; i++) {
|
|
281
|
+
const e = orch[i];
|
|
282
|
+
if (e.type === JT.created && this.options.store.workflows.has(String(e.wid)))
|
|
283
|
+
await add("orch", i, [deriveCreated(orch, i, this.identity(String(e.wid)))]);
|
|
284
|
+
else if (e.type === "pruned")
|
|
285
|
+
forgotten.push(String(e.wid));
|
|
286
|
+
}
|
|
287
|
+
if (orch.length > from)
|
|
288
|
+
next.set("orch", orch.length);
|
|
289
|
+
for (const wid of dirty) {
|
|
290
|
+
const wf = this.options.store.workflows.get(wid);
|
|
291
|
+
if (!wf)
|
|
292
|
+
continue;
|
|
293
|
+
const entries = committed(wf.journal), start = this.marks.get(wid) ?? 0;
|
|
294
|
+
if (entries.length <= start)
|
|
295
|
+
continue;
|
|
296
|
+
const id = this.identity(wid);
|
|
297
|
+
for (let i = start; i < entries.length; i++)
|
|
298
|
+
await add(wid, i, deriveEntry(wid, entries, i, orch, id));
|
|
299
|
+
next.set(wid, entries.length);
|
|
300
|
+
}
|
|
301
|
+
await commit();
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
for (const wid of dirty)
|
|
305
|
+
this.dirty.add(wid);
|
|
306
|
+
const text = `durable-subagents: event log append failed (retrying): ${String(error)}`;
|
|
307
|
+
if (this.reported !== text) {
|
|
308
|
+
this.reported = text;
|
|
309
|
+
console.error(text);
|
|
310
|
+
}
|
|
311
|
+
if (!this.closed && !this.timer) {
|
|
312
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.serial(() => this.pass()).catch(() => { }); }, 1000);
|
|
313
|
+
this.timer.unref?.();
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (Date.now() - this.compactedAt >= COMPACT_EVERY_MS)
|
|
318
|
+
await this.compact();
|
|
319
|
+
}
|
|
320
|
+
/** Retention: drop events older than the window whose workflow is quiet (or pruned); at start and at most hourly. */
|
|
321
|
+
async compact() {
|
|
322
|
+
if (!this.log)
|
|
323
|
+
return;
|
|
324
|
+
this.compactedAt = Date.now();
|
|
325
|
+
const window = this.options.retentionMs?.() ?? EVENT_RETENTION_MS, cutoff = Date.now() - window;
|
|
326
|
+
const pruned = new Set(), orch = committed(this.options.orch);
|
|
327
|
+
for (const e of orch)
|
|
328
|
+
if (e.type === "pruned")
|
|
329
|
+
pruned.add(String(e.wid));
|
|
330
|
+
const verdicts = new Map();
|
|
331
|
+
const droppable = (wid) => {
|
|
332
|
+
let v = verdicts.get(wid);
|
|
333
|
+
if (v === undefined) {
|
|
334
|
+
const wf = this.options.store.workflows.get(wid);
|
|
335
|
+
v = wf ? quiet(wid, committed(wf.journal)) : pruned.has(wid);
|
|
336
|
+
verdicts.set(wid, v);
|
|
337
|
+
}
|
|
338
|
+
return v;
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
// The watermarks written are the ones durable now plus those moved since (both are true of the derived log).
|
|
342
|
+
const marks = new Map(this.marks);
|
|
343
|
+
const dropped = await (await this.writer()).compact(r => r.at < cutoff && droppable(String(r.e.wid)), marks);
|
|
344
|
+
if (dropped)
|
|
345
|
+
this.unsaved.clear();
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
console.error(`durable-subagents: event log compaction failed: ${String(error)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|