@animalabs/agent-framework 0.29.0 → 0.30.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.
Files changed (104) hide show
  1. package/dist/src/admission/admit.d.ts +90 -0
  2. package/dist/src/admission/admit.d.ts.map +1 -0
  3. package/dist/src/admission/admit.js +43 -0
  4. package/dist/src/admission/admit.js.map +1 -0
  5. package/dist/src/agent.d.ts +21 -0
  6. package/dist/src/agent.d.ts.map +1 -1
  7. package/dist/src/agent.js +43 -6
  8. package/dist/src/agent.js.map +1 -1
  9. package/dist/src/author-presentation.d.ts +50 -0
  10. package/dist/src/author-presentation.d.ts.map +1 -0
  11. package/dist/src/author-presentation.js +58 -0
  12. package/dist/src/author-presentation.js.map +1 -0
  13. package/dist/src/dendrite/index.d.ts +6 -0
  14. package/dist/src/dendrite/index.d.ts.map +1 -0
  15. package/dist/src/dendrite/index.js +3 -0
  16. package/dist/src/dendrite/index.js.map +1 -0
  17. package/dist/src/dendrite/presets.d.ts +60 -0
  18. package/dist/src/dendrite/presets.d.ts.map +1 -0
  19. package/dist/src/dendrite/presets.js +117 -0
  20. package/dist/src/dendrite/presets.js.map +1 -0
  21. package/dist/src/dendrite/registry.d.ts +260 -0
  22. package/dist/src/dendrite/registry.d.ts.map +1 -0
  23. package/dist/src/dendrite/registry.js +742 -0
  24. package/dist/src/dendrite/registry.js.map +1 -0
  25. package/dist/src/dendrite/types.d.ts +392 -0
  26. package/dist/src/dendrite/types.d.ts.map +1 -0
  27. package/dist/src/dendrite/types.js +24 -0
  28. package/dist/src/dendrite/types.js.map +1 -0
  29. package/dist/src/framework.d.ts +684 -33
  30. package/dist/src/framework.d.ts.map +1 -1
  31. package/dist/src/framework.js +2998 -350
  32. package/dist/src/framework.js.map +1 -1
  33. package/dist/src/gate/event-gate.d.ts +12 -4
  34. package/dist/src/gate/event-gate.d.ts.map +1 -1
  35. package/dist/src/gate/event-gate.js +25 -5
  36. package/dist/src/gate/event-gate.js.map +1 -1
  37. package/dist/src/host-presentation.d.ts +4 -0
  38. package/dist/src/host-presentation.d.ts.map +1 -1
  39. package/dist/src/host-presentation.js +7 -3
  40. package/dist/src/host-presentation.js.map +1 -1
  41. package/dist/src/index.d.ts +5 -2
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +6 -1
  44. package/dist/src/index.js.map +1 -1
  45. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  46. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  47. package/dist/src/mcpl/channel-registry.js +136 -58
  48. package/dist/src/mcpl/channel-registry.js.map +1 -1
  49. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  50. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-coalescer.js +21 -0
  52. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  53. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  54. package/dist/src/mcpl/toolsets-module.js +7 -0
  55. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  56. package/dist/src/notice-tags.d.ts +2 -0
  57. package/dist/src/notice-tags.d.ts.map +1 -1
  58. package/dist/src/notice-tags.js +2 -0
  59. package/dist/src/notice-tags.js.map +1 -1
  60. package/dist/src/silent-heartbeat.d.ts +4 -2
  61. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  62. package/dist/src/silent-heartbeat.js +7 -4
  63. package/dist/src/silent-heartbeat.js.map +1 -1
  64. package/dist/src/stored-injections.d.ts +4 -0
  65. package/dist/src/stored-injections.d.ts.map +1 -1
  66. package/dist/src/stored-injections.js +6 -0
  67. package/dist/src/stored-injections.js.map +1 -1
  68. package/dist/src/tune-out/coordinator.d.ts +379 -9
  69. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  70. package/dist/src/tune-out/coordinator.js +1080 -97
  71. package/dist/src/tune-out/coordinator.js.map +1 -1
  72. package/dist/src/tune-out/tools.d.ts +101 -5
  73. package/dist/src/tune-out/tools.d.ts.map +1 -1
  74. package/dist/src/tune-out/tools.js +55 -18
  75. package/dist/src/tune-out/tools.js.map +1 -1
  76. package/dist/src/types/agent.d.ts +25 -0
  77. package/dist/src/types/agent.d.ts.map +1 -1
  78. package/dist/src/types/framework.d.ts +99 -0
  79. package/dist/src/types/framework.d.ts.map +1 -1
  80. package/dist/src/types/trace.d.ts +136 -0
  81. package/dist/src/types/trace.d.ts.map +1 -1
  82. package/package.json +3 -3
  83. package/src/admission/admit.ts +113 -0
  84. package/src/agent.ts +48 -6
  85. package/src/author-presentation.ts +81 -0
  86. package/src/dendrite/index.ts +32 -0
  87. package/src/dendrite/presets.ts +149 -0
  88. package/src/dendrite/registry.ts +808 -0
  89. package/src/dendrite/types.ts +397 -0
  90. package/src/framework.ts +3239 -369
  91. package/src/gate/event-gate.ts +31 -5
  92. package/src/host-presentation.ts +10 -3
  93. package/src/index.ts +15 -2
  94. package/src/mcpl/channel-registry.ts +170 -60
  95. package/src/mcpl/push-coalescer.ts +19 -0
  96. package/src/mcpl/toolsets-module.ts +7 -0
  97. package/src/notice-tags.ts +2 -0
  98. package/src/silent-heartbeat.ts +7 -4
  99. package/src/stored-injections.ts +7 -0
  100. package/src/tune-out/coordinator.ts +1229 -98
  101. package/src/tune-out/tools.ts +152 -11
  102. package/src/types/agent.ts +28 -0
  103. package/src/types/framework.ts +102 -0
  104. package/src/types/trace.ts +150 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * AgentRegistry — the one place that knows which agents exist, what they
3
+ * are for, how they relate and what ends them.
4
+ *
5
+ * Pure bookkeeping: it never touches a stream, a context manager or a
6
+ * channel. Lifecycle methods return what changed (who ended, who was
7
+ * orphaned, which mail became deliverable) and the framework performs the
8
+ * side effects. That split keeps every rule here testable without a store
9
+ * and keeps the framework from re-deriving policy at each call site.
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ /** Thrown for a spec the registry refuses to load. */
13
+ export class AgentSpecError extends Error {
14
+ constructor(agent, problem) {
15
+ super(`agent "${agent}": ${problem}`);
16
+ this.name = 'AgentSpecError';
17
+ }
18
+ }
19
+ const DEFAULT_ENDED_RETENTION = 256;
20
+ function clone(value) {
21
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
22
+ }
23
+ export class AgentRegistry {
24
+ live = new Map();
25
+ /** Ended records, oldest first. */
26
+ ended = [];
27
+ /** Persistent records from the previous process, not yet re-declared. */
28
+ awaiting = new Map();
29
+ /**
30
+ * Bounded work the previous process left live, ended by this restart in
31
+ * memory but kept in the snapshot as it was until the host has told each
32
+ * recipient (settleInterrupted), so a crash before then owes the same
33
+ * notices at the next boot rather than none.
34
+ */
35
+ interruptedPending = new Map();
36
+ incarnations = new Map();
37
+ mail = [];
38
+ now;
39
+ persistFn;
40
+ appendEndedFn;
41
+ endedRetention;
42
+ onRecordWriteFailed;
43
+ onRollback;
44
+ findEnded;
45
+ /** Above zero while a record of what happened is being written: its writes alert rather than throw. */
46
+ recordingDepth = 0;
47
+ constructor(options = {}) {
48
+ this.now = options.now ?? Date.now;
49
+ this.persistFn = options.persist;
50
+ this.appendEndedFn = options.appendEnded;
51
+ this.onRollback = options.onRollback;
52
+ this.findEnded = options.findEnded;
53
+ this.onRecordWriteFailed = options.onRecordWriteFailed
54
+ ?? ((what, error) => console.error(`[dendrite] the registry could not record ${what}; it stays in memory and the next write carries it:`, error));
55
+ this.endedRetention = options.endedRetention ?? DEFAULT_ENDED_RETENTION;
56
+ }
57
+ // ==========================================================================
58
+ // Registration
59
+ // ==========================================================================
60
+ /**
61
+ * Admit an agent. Refuses a spec that breaks a registry invariant — an
62
+ * unbounded task, a second default-delivery owner, a relationship that
63
+ * names nobody — so a malformed agent fails at load, not mid-run.
64
+ *
65
+ * `resume` admits a new incarnation of an identity that ended: where its
66
+ * context came from is then history, so the context source no longer has
67
+ * to be live. Who it reports to still does. A spawner that has ended, and
68
+ * a result route to it, are admitted: the agent registers as that
69
+ * spawner's orphan, as if it had been live when the spawner ended.
70
+ * `after` is the incarnation a resumption continues from: the new one is
71
+ * past it, since the counter of a name that aged out of memory isn't kept.
72
+ */
73
+ register(spec, options = {}) {
74
+ return this.requested(() => this.registerUnguarded(spec, options));
75
+ }
76
+ registerUnguarded(spec, options = {}) {
77
+ this.validate(spec, options.resume === true);
78
+ const previous = this.awaiting.get(spec.name);
79
+ this.awaiting.delete(spec.name);
80
+ const incarnation = Math.max(this.incarnations.get(spec.name) ?? 0, options.after ?? 0) + 1;
81
+ this.incarnations.set(spec.name, incarnation);
82
+ const record = {
83
+ name: spec.name,
84
+ kind: spec.kind,
85
+ ...(spec.model ? { model: spec.model } : {}),
86
+ incarnation,
87
+ roles: { ...spec.roles },
88
+ lifetime: { ...spec.lifetime },
89
+ ...(spec.activation ? { activation: { ...spec.activation } } : {}),
90
+ onParentEnd: spec.onParentEnd ?? 'orphan',
91
+ relationships: {
92
+ ...(spec.spawnedBy ? { spawnedBy: spec.spawnedBy } : {}),
93
+ observes: (spec.observes ?? []).map((edge) => ({ ...edge })),
94
+ // Explicit message paths accumulate over an identity's life.
95
+ messagePeers: previous ? [...previous.relationships.messagePeers] : [],
96
+ ...(spec.resultTo ? { resultTo: { ...spec.resultTo } } : {}),
97
+ },
98
+ ...(spec.inherit ? { inherit: clone(spec.inherit) } : {}),
99
+ ...(spec.selfParticipants?.length ? { selfParticipants: [...spec.selfParticipants] } : {}),
100
+ ...(spec.presentAs ? { presentAs: spec.presentAs } : {}),
101
+ ...(spec.homeChannel ? { homeChannel: { serverId: spec.homeChannel.serverId, channelId: spec.homeChannel.channelId } } : {}),
102
+ readsSharedSlot: spec.readsSharedSlot ?? false,
103
+ ...(spec.namespace ? { namespace: spec.namespace } : {}),
104
+ ...(spec.metadata ? { metadata: clone(spec.metadata) } : {}),
105
+ createdAt: previous?.createdAt ?? this.now(),
106
+ };
107
+ const spawner = record.relationships.spawnedBy;
108
+ if (spawner !== undefined && !this.live.has(spawner)) {
109
+ const former = this.inspect(spawner);
110
+ const above = former.relationships.spawnedBy;
111
+ const candidates = above !== undefined && this.live.has(above) ? [above, ...this.ancestors(above)] : [];
112
+ const primary = this.primary();
113
+ if (primary && primary !== record.name && !candidates.includes(primary))
114
+ candidates.push(primary);
115
+ record.orphaned = {
116
+ since: this.now(),
117
+ formerParent: { agent: former.name, incarnation: former.incarnation },
118
+ reason: former.ended?.reason ?? 'stopped',
119
+ candidates,
120
+ };
121
+ }
122
+ this.live.set(spec.name, record);
123
+ this.persist();
124
+ return record;
125
+ }
126
+ validate(spec, resume = false) {
127
+ const fail = (problem) => {
128
+ throw new AgentSpecError(spec.name || '(unnamed)', problem);
129
+ };
130
+ if (!spec.name)
131
+ fail('a name is required');
132
+ if (this.live.has(spec.name))
133
+ fail('is already registered');
134
+ if (spec.roles.defaultDelivery) {
135
+ const owner = this.primary();
136
+ if (owner)
137
+ fail(`default delivery already belongs to "${owner}"; exactly one agent owns it`);
138
+ }
139
+ const lifetime = spec.lifetime;
140
+ if (lifetime.kind === 'task') {
141
+ const bounded = (lifetime.deadlineMs !== undefined && lifetime.deadlineMs > 0) ||
142
+ (lifetime.idleTimeoutMs !== undefined && lifetime.idleTimeoutMs > 0);
143
+ if (!bounded)
144
+ fail('a task lifetime needs a deadline or an idle timeout; every task agent must end');
145
+ }
146
+ else if (lifetime.kind === 'idle') {
147
+ if (!(lifetime.idleTtlMs > 0))
148
+ fail('an idle lifetime needs a positive idleTtlMs');
149
+ }
150
+ // A spawner that ended before its child registered (between creating the
151
+ // child and running it): the child is its orphan from the start, as one
152
+ // live when the spawner ended would be, with its route to the spawner
153
+ // held; one that ends with its parent would have ended with it.
154
+ const endedSpawner = spec.spawnedBy !== undefined && !this.live.has(spec.spawnedBy)
155
+ ? this.inspect(spec.spawnedBy)
156
+ : undefined;
157
+ if (spec.spawnedBy !== undefined) {
158
+ if (spec.spawnedBy === spec.name)
159
+ fail('cannot be spawned by itself');
160
+ if (!this.live.has(spec.spawnedBy)) {
161
+ if (!endedSpawner?.ended)
162
+ fail(`spawner "${spec.spawnedBy}" is not a registered agent`);
163
+ if (spec.onParentEnd === 'end') {
164
+ fail(`spawner "${spec.spawnedBy}" has ended, and an agent that ends with its parent would have ended with it`);
165
+ }
166
+ }
167
+ }
168
+ else if (spec.onParentEnd === 'end') {
169
+ fail('onParentEnd "end" needs a spawnedBy relationship');
170
+ }
171
+ if (spec.resultTo && !this.live.has(spec.resultTo.to)
172
+ && !(endedSpawner?.ended && spec.resultTo.to === spec.spawnedBy)) {
173
+ fail(`result recipient "${spec.resultTo.to}" is not a registered agent`);
174
+ }
175
+ if (spec.inherit && !resume && !this.live.has(spec.inherit.from)) {
176
+ fail(`context source "${spec.inherit.from}" is not a registered agent`);
177
+ }
178
+ for (const edge of spec.observes ?? []) {
179
+ if (edge.agent !== undefined && !this.live.has(edge.agent)) {
180
+ fail(`observed agent "${edge.agent}" is not a registered agent`);
181
+ }
182
+ }
183
+ }
184
+ // ==========================================================================
185
+ // Discovery
186
+ // ==========================================================================
187
+ /** A live agent's record. */
188
+ get(name) {
189
+ return this.live.get(name);
190
+ }
191
+ has(name) {
192
+ return this.live.has(name);
193
+ }
194
+ /** The live record, else the most recent ended one with that name, from memory or the host's ended log. */
195
+ inspect(name) {
196
+ const live = this.live.get(name);
197
+ if (live)
198
+ return live;
199
+ for (let i = this.ended.length - 1; i >= 0; i--) {
200
+ if (this.ended[i].name === name)
201
+ return this.ended[i];
202
+ }
203
+ // An interrupted record trimmed from memory before its end is written:
204
+ // the newest incarnation, where two of one name are pending.
205
+ let newest;
206
+ for (const { ended } of this.interruptedPending.values()) {
207
+ if (ended.name === name && (!newest || ended.incarnation > newest.incarnation))
208
+ newest = ended;
209
+ }
210
+ return newest ?? this.findEnded?.(name);
211
+ }
212
+ /** Flat list of agents — the discovery surface. Ended ones on request. */
213
+ list(options = {}) {
214
+ const live = [...this.live.values()];
215
+ return options.includeEnded ? [...this.ended, ...live] : live;
216
+ }
217
+ /**
218
+ * An agent other than `except` whose own context a namespace holds, among
219
+ * the records in memory: live, awaiting re-declaration, or ended. A shared
220
+ * fork's record names its parent's namespace, where it owns only a branch,
221
+ * so it holds none.
222
+ * @internal For the framework's namespace check in
223
+ * `createEphemeralAgent()`; not a host API.
224
+ */
225
+ namespaceHolder(namespace, except) {
226
+ const pending = [...this.interruptedPending.values()].map((entry) => entry.asWas);
227
+ for (const record of [...this.live.values(), ...this.awaiting.values(), ...pending, ...this.ended]) {
228
+ if (record.name !== except && record.namespace === namespace && record.inherit?.mode !== 'shared')
229
+ return record.name;
230
+ }
231
+ return undefined;
232
+ }
233
+ /** Provenance stamp for a live agent (or its last incarnation). */
234
+ ref(name) {
235
+ const record = this.inspect(name);
236
+ return { agent: name, incarnation: record?.incarnation ?? this.incarnations.get(name) ?? 0 };
237
+ }
238
+ // ==========================================================================
239
+ // Role queries — the positive replacements for exclusion filters
240
+ // ==========================================================================
241
+ /** The agent that owns default delivery, if any. */
242
+ primary() {
243
+ for (const record of this.live.values()) {
244
+ if (record.roles.defaultDelivery)
245
+ return record.name;
246
+ }
247
+ return null;
248
+ }
249
+ /** Recipients of untargeted inbound, in registration order. */
250
+ untargetedRecipients() {
251
+ return this.namesWhere((r) => r.roles.receivesUntargeted);
252
+ }
253
+ /** Recipients of host-wide gate fan-out, in registration order. */
254
+ gateRecipients() {
255
+ return this.namesWhere((r) => r.roles.receivesGateWakes);
256
+ }
257
+ ownsProviderScheduling(name) {
258
+ return this.live.get(name)?.roles.ownsProviderScheduling === true;
259
+ }
260
+ /** Agents reading the residents' shared message slot. */
261
+ sharedSlotReaders() {
262
+ return this.namesWhere((r) => r.readsSharedSlot);
263
+ }
264
+ readsSharedSlot(name) {
265
+ return this.live.get(name)?.readsSharedSlot === true;
266
+ }
267
+ /** The channel an agent is bound to, if it has one. */
268
+ homeChannel(name) {
269
+ return this.live.get(name)?.homeChannel;
270
+ }
271
+ /** Live agents of one preset label. Discovery only — see {@link AgentKind}. */
272
+ ofKind(kind) {
273
+ return [...this.live.values()].filter((r) => r.kind === kind);
274
+ }
275
+ namesWhere(predicate) {
276
+ const names = [];
277
+ for (const record of this.live.values())
278
+ if (predicate(record))
279
+ names.push(record.name);
280
+ return names;
281
+ }
282
+ // ==========================================================================
283
+ // Relationships
284
+ // ==========================================================================
285
+ /** Live agents this one spawned. */
286
+ children(name) {
287
+ return [...this.live.values()].filter((r) => r.relationships.spawnedBy === name && !r.orphaned);
288
+ }
289
+ /** Live spawn ancestors, nearest first. */
290
+ ancestors(name) {
291
+ const chain = [];
292
+ const seen = new Set([name]);
293
+ let cursor = this.live.get(name)?.relationships.spawnedBy;
294
+ while (cursor && !seen.has(cursor) && this.live.has(cursor)) {
295
+ chain.push(cursor);
296
+ seen.add(cursor);
297
+ cursor = this.live.get(cursor)?.relationships.spawnedBy;
298
+ }
299
+ return chain;
300
+ }
301
+ /** Live agents whose completed work returns to `name`. */
302
+ resultSources(name) {
303
+ return [...this.live.values()].filter((r) => r.relationships.resultTo?.to === name);
304
+ }
305
+ /** Record that `from` used an explicit message path to `to`. */
306
+ noteMessagePath(from, to) {
307
+ const record = this.live.get(from);
308
+ if (!record || record.relationships.messagePeers.includes(to))
309
+ return;
310
+ record.relationships.messagePeers.push(to);
311
+ this.persistRecord(`the message path ${from} -> ${to}`);
312
+ }
313
+ // ==========================================================================
314
+ // Lifecycle
315
+ // ==========================================================================
316
+ /**
317
+ * End an agent identity. Attention tenants it spawned end with it; task
318
+ * work it spawned is orphaned, with reparenting candidates. Result routes
319
+ * that pointed at it are left in place — delivery holds the mail instead
320
+ * of dropping it, so a later adoption can still receive the result.
321
+ */
322
+ /**
323
+ * End an agent. A stop a caller asks for is a requested change: a write that
324
+ * fails puts memory back and fails the call. A run's own end or an idle
325
+ * reap (`record: true`) records what has already happened: a write that
326
+ * fails alerts, and the next write carries it (nissa's #546 review, round 3).
327
+ */
328
+ end(name, reason, by, options = {}) {
329
+ return options.record
330
+ ? this.recording(() => this.endUnguarded(name, reason, by))
331
+ : this.requested(() => this.endUnguarded(name, reason, by));
332
+ }
333
+ endUnguarded(name, reason, by) {
334
+ const record = this.live.get(name);
335
+ if (!record)
336
+ return null;
337
+ const outcome = this.endRecursive(record, reason, by);
338
+ this.persist();
339
+ return outcome;
340
+ }
341
+ endRecursive(record, reason, by) {
342
+ // Candidates are computed before removal so the chain is still walkable.
343
+ const adopters = this.adoptionCandidates(record);
344
+ this.live.delete(record.name);
345
+ this.recordEnded(record, reason, by);
346
+ const cascaded = [];
347
+ const orphaned = [];
348
+ const formerParent = { agent: record.name, incarnation: record.incarnation };
349
+ for (const child of [...this.live.values()]) {
350
+ if (child.relationships.spawnedBy !== record.name || child.orphaned)
351
+ continue;
352
+ if (!this.live.has(child.name))
353
+ continue; // ended by an earlier cascade
354
+ if (child.onParentEnd === 'end') {
355
+ const inner = this.endRecursive(child, 'parent-ended', record.name);
356
+ cascaded.push(...inner.cascaded, inner.ended);
357
+ orphaned.push(...inner.orphaned);
358
+ }
359
+ else {
360
+ const candidates = adopters.filter((candidate) => candidate !== child.name);
361
+ child.orphaned = { since: this.now(), formerParent, reason, candidates };
362
+ orphaned.push({ record: child, candidates });
363
+ }
364
+ }
365
+ return { ended: record, cascaded, orphaned };
366
+ }
367
+ /** Who could take over an ending agent's children: nearest live ancestor, then the primary. */
368
+ adoptionCandidates(record) {
369
+ const candidates = this.ancestors(record.name);
370
+ const primary = this.primary();
371
+ if (primary && primary !== record.name && !candidates.includes(primary))
372
+ candidates.push(primary);
373
+ return candidates;
374
+ }
375
+ /**
376
+ * Give an agent a new spawner. Clears orphan state. A result route whose
377
+ * recipient is gone follows the new parent, and mail the agent already
378
+ * produced for the lost recipient is re-addressed and returned so the
379
+ * caller can deliver it.
380
+ */
381
+ reparent(name, newParent) {
382
+ return this.requested(() => this.reparentUnguarded(name, newParent));
383
+ }
384
+ reparentUnguarded(name, newParent) {
385
+ const record = this.live.get(name);
386
+ if (!record)
387
+ throw new AgentSpecError(name, 'is not a registered agent');
388
+ if (!this.live.has(newParent))
389
+ throw new AgentSpecError(name, `new parent "${newParent}" is not a registered agent`);
390
+ if (newParent === name)
391
+ throw new AgentSpecError(name, 'cannot be its own parent');
392
+ if (this.ancestors(newParent).includes(name)) {
393
+ throw new AgentSpecError(name, `"${newParent}" descends from it; reparenting would form a cycle`);
394
+ }
395
+ record.relationships.spawnedBy = newParent;
396
+ delete record.orphaned;
397
+ const route = record.relationships.resultTo;
398
+ if (route && !this.live.has(route.to))
399
+ route.to = newParent;
400
+ const deliverable = [];
401
+ for (const item of this.mail) {
402
+ if (item.from.agent === name && !this.live.has(item.to)) {
403
+ item.to = newParent;
404
+ item.heldBecause = 'undelivered';
405
+ deliverable.push(item);
406
+ }
407
+ }
408
+ this.persist();
409
+ return { record, deliverable };
410
+ }
411
+ /**
412
+ * An ended agent's held mail finds a new recipient: every item it produced
413
+ * for a recipient that is gone (a result or a message, as `reparent`
414
+ * moves for a live agent) is re-addressed to `newParent` and returned for
415
+ * delivery. Its record stays as it ended, since history isn't reparented.
416
+ * @internal For the framework's `reparentAgent()` of an ended agent; not a host API.
417
+ */
418
+ readdressHeldMail(name, newParent) {
419
+ return this.requested(() => {
420
+ const record = this.inspect(name);
421
+ if (!record?.ended)
422
+ throw new AgentSpecError(name, 'is not an ended agent');
423
+ if (!this.live.has(newParent))
424
+ throw new AgentSpecError(name, `new parent "${newParent}" is not a registered agent`);
425
+ const deliverable = [];
426
+ for (const item of this.mail) {
427
+ if (item.from.agent === name && !this.live.has(item.to)) {
428
+ item.to = newParent;
429
+ item.heldBecause = 'undelivered';
430
+ deliverable.push(item);
431
+ }
432
+ }
433
+ if (deliverable.length > 0)
434
+ this.persist();
435
+ return { record, deliverable };
436
+ });
437
+ }
438
+ // ==========================================================================
439
+ // Held mail
440
+ // ==========================================================================
441
+ /** Record a communication durably BEFORE attempting delivery. */
442
+ holdMail(mail) {
443
+ const item = {
444
+ ...mail,
445
+ id: mail.id ?? randomUUID(),
446
+ createdAt: this.now(),
447
+ content: clone(mail.content),
448
+ };
449
+ this.mail.push(item);
450
+ // A hold that isn't durable isn't held: the persist's failure fails this
451
+ // call, and the item leaves memory and every copy written before the
452
+ // failure too, so a retry holds it once.
453
+ try {
454
+ this.persist();
455
+ }
456
+ catch (error) {
457
+ this.mail.pop();
458
+ this.putBack();
459
+ throw error;
460
+ }
461
+ return item;
462
+ }
463
+ /** Delivery confirmed: forget the held copy. */
464
+ releaseMail(id) {
465
+ const before = this.mail.length;
466
+ this.mail = this.mail.filter((item) => item.id !== id);
467
+ if (this.mail.length !== before)
468
+ this.persistRecord(`the release of mail ${id}`);
469
+ }
470
+ /** Mark why an item is still held (delivery attempted and not possible yet). */
471
+ markMailHeld(id, because) {
472
+ const item = this.mail.find((candidate) => candidate.id === id);
473
+ if (!item || item.heldBecause === because)
474
+ return;
475
+ item.heldBecause = because;
476
+ this.persistRecord(`why mail ${id} is held (${because})`);
477
+ }
478
+ listMail(filter = {}) {
479
+ return this.mail.filter((item) => (filter.to === undefined || item.to === filter.to) &&
480
+ (filter.from === undefined || item.from.agent === filter.from));
481
+ }
482
+ // ==========================================================================
483
+ // Persistence and restart
484
+ // ==========================================================================
485
+ snapshot() {
486
+ const pending = [...this.interruptedPending.values()].map((entry) => entry.asWas);
487
+ const records = [...this.awaiting.values(), ...pending, ...this.live.values()].map((record) => clone(record));
488
+ // Counters are kept for identities that can still be referred to: live
489
+ // agents, persistent agents awaiting re-declaration, and ended agents
490
+ // still in retention. A single-use name that has aged out restarts at 1.
491
+ const referable = new Set([
492
+ ...this.live.keys(),
493
+ ...this.awaiting.keys(),
494
+ ...pending.map((record) => record.name),
495
+ ...this.ended.map((record) => record.name),
496
+ ]);
497
+ const incarnations = {};
498
+ for (const [name, value] of this.incarnations)
499
+ if (referable.has(name))
500
+ incarnations[name] = value;
501
+ return { version: 1, records, incarnations, mail: clone(this.mail) };
502
+ }
503
+ /**
504
+ * Load what the previous process persisted. Restart behaviour follows
505
+ * from lifetime: a persistent agent waits to be re-declared by
506
+ * configuration (see {@link reconcile}); every other agent that was live
507
+ * is ended here as interrupted, in memory, and returned so its result
508
+ * recipient can be told. It stays in the snapshot as it was, and its end
509
+ * is written, once the host has told the recipients (settleInterrupted).
510
+ * Held mail is kept for redelivery.
511
+ *
512
+ * A bounded record the ended history already holds, by name and
513
+ * incarnation, ended before the previous process could write the snapshot
514
+ * without it: it is dropped, not ended again. A persistent record waits to
515
+ * be re-declared whatever that history says, since the end there may be one
516
+ * a stop left that failed and was put back (an append can't be taken
517
+ * back); configuration then declares it again or ends it.
518
+ *
519
+ * `endedHistory` is the tail of the ended-record log, oldest first.
520
+ */
521
+ restore(snapshot, endedHistory = []) {
522
+ const interrupted = [];
523
+ for (const raw of endedHistory.slice(-this.endedRetention)) {
524
+ if (raw && typeof raw === 'object' && raw.ended)
525
+ this.ended.push(clone(raw));
526
+ }
527
+ if (!snapshot)
528
+ return { interrupted };
529
+ if (snapshot.version !== 1) {
530
+ throw new Error(`the agent registry reads as version ${JSON.stringify(snapshot.version)}, which this code doesn't read; nothing was restored`);
531
+ }
532
+ // Damaged in its content rather than its version: refused the same way,
533
+ // since taken as empty it would be overwritten, and without its counters
534
+ // every identity it names would start again at incarnation 1.
535
+ if (!Array.isArray(snapshot.mail) || !Array.isArray(snapshot.records)) {
536
+ throw new Error('the agent registry reads as version 1 but its records or mail are not lists; nothing was restored');
537
+ }
538
+ const counters = snapshot.incarnations;
539
+ if (counters === null || typeof counters !== 'object' || Array.isArray(counters)) {
540
+ throw new Error('the agent registry reads as version 1 but has no map of incarnations; nothing was restored');
541
+ }
542
+ for (const [name, value] of Object.entries(counters)) {
543
+ if (typeof value === 'number')
544
+ this.incarnations.set(name, value);
545
+ }
546
+ this.mail = clone(snapshot.mail);
547
+ const endedKeys = new Set(this.ended.map((record) => `${record.name}#${record.incarnation}`));
548
+ let dropped = false;
549
+ for (const raw of snapshot.records) {
550
+ const record = clone(raw);
551
+ if (record.ended)
552
+ continue; // history lives in the ended log
553
+ if (record.lifetime.kind === 'persistent') {
554
+ this.awaiting.set(record.name, record);
555
+ continue;
556
+ }
557
+ if (endedKeys.has(`${record.name}#${record.incarnation}`)) {
558
+ dropped = true;
559
+ continue;
560
+ }
561
+ const asWas = clone(record);
562
+ this.pushEnded(record, 'host-restart');
563
+ this.interruptedPending.set(`${record.name}#${record.incarnation}`, { asWas, ended: clone(record) });
564
+ interrupted.push(record);
565
+ }
566
+ if (dropped)
567
+ this.persist();
568
+ return { interrupted };
569
+ }
570
+ /**
571
+ * The host has told each interrupted job's recipient (restore's
572
+ * `interrupted`): their ends are written now, and the snapshot no longer
573
+ * lists them. Each pending entry carries its own end, as memory keeps only
574
+ * the newest `endedRetention`. An end whose write fails is reported and
575
+ * stays pending, so the snapshot still lists it and a later settlement (the
576
+ * next boot's) writes it; a failed snapshot write is reported too.
577
+ * @internal For the framework's boot, once the interrupted jobs'
578
+ * recipients are told; not a host API.
579
+ */
580
+ settleInterrupted() {
581
+ if (this.interruptedPending.size === 0)
582
+ return;
583
+ for (const [key, { ended }] of [...this.interruptedPending]) {
584
+ try {
585
+ this.appendEndedFn?.(clone(ended));
586
+ }
587
+ catch (error) {
588
+ this.onRecordWriteFailed(`the end of ${ended.name}`, error);
589
+ continue;
590
+ }
591
+ this.interruptedPending.delete(key);
592
+ }
593
+ this.recording(() => this.persist());
594
+ }
595
+ /**
596
+ * Call once configuration has declared its agents. A persistent agent the
597
+ * previous process ran but this configuration omits is ended, with the
598
+ * usual consequences for what it spawned.
599
+ */
600
+ reconcile() {
601
+ const unconfigured = [];
602
+ for (const record of [...this.awaiting.values()]) {
603
+ this.awaiting.delete(record.name);
604
+ this.recordEnded(record, 'not-configured');
605
+ const formerParent = { agent: record.name, incarnation: record.incarnation };
606
+ const orphaned = [];
607
+ const primary = this.primary();
608
+ for (const child of this.live.values()) {
609
+ if (child.relationships.spawnedBy !== record.name || child.orphaned)
610
+ continue;
611
+ const candidates = primary && primary !== child.name ? [primary] : [];
612
+ child.orphaned = { since: this.now(), formerParent, reason: 'not-configured', candidates };
613
+ orphaned.push({ record: child, candidates });
614
+ }
615
+ unconfigured.push({ ended: record, cascaded: [], orphaned });
616
+ }
617
+ if (unconfigured.length > 0)
618
+ this.persist();
619
+ return { unconfigured };
620
+ }
621
+ recordEnded(record, reason, by) {
622
+ this.pushEnded(record, reason, by);
623
+ this.appendEndedRecord(record);
624
+ }
625
+ /** Mark a record ended and keep it in memory, without writing the end. */
626
+ pushEnded(record, reason, by) {
627
+ record.ended = { at: this.now(), reason, ...(by ? { by } : {}) };
628
+ this.ended.push(record);
629
+ if (this.ended.length > this.endedRetention) {
630
+ this.ended.splice(0, this.ended.length - this.endedRetention);
631
+ }
632
+ }
633
+ /** Write an ended record through the host's append hook. */
634
+ appendEndedRecord(record) {
635
+ if (!this.appendEndedFn)
636
+ return;
637
+ // A failed append fails the end that made it, as a failed persist does —
638
+ // unless the end records a run that has ended, when it alerts instead.
639
+ if (this.recordingDepth > 0) {
640
+ try {
641
+ this.appendEndedFn(clone(record));
642
+ }
643
+ catch (error) {
644
+ this.onRecordWriteFailed(`the end of ${record.name}`, error);
645
+ }
646
+ return;
647
+ }
648
+ this.appendEndedFn(clone(record));
649
+ }
650
+ /**
651
+ * Run a change a caller asks for — a registration, an end, a reparent, a
652
+ * hold — so that a write failing on the way puts memory back as it was:
653
+ * the call fails and changes nothing, and a retry does it once (nissa's
654
+ * #546 review, round 2: a stop whose write failed left the agent recorded
655
+ * as stopped while it kept running).
656
+ */
657
+ requested(change) {
658
+ const saved = {
659
+ live: new Map([...this.live].map(([k, v]) => [k, clone(v)])),
660
+ ended: this.ended.map(clone),
661
+ awaiting: new Map([...this.awaiting].map(([k, v]) => [k, clone(v)])),
662
+ incarnations: new Map(this.incarnations),
663
+ mail: this.mail.map(clone),
664
+ };
665
+ try {
666
+ return change();
667
+ }
668
+ catch (error) {
669
+ this.live.clear();
670
+ for (const [k, v] of saved.live)
671
+ this.live.set(k, v);
672
+ this.ended = saved.ended;
673
+ this.awaiting.clear();
674
+ for (const [k, v] of saved.awaiting)
675
+ this.awaiting.set(k, v);
676
+ this.incarnations.clear();
677
+ for (const [k, v] of saved.incarnations)
678
+ this.incarnations.set(k, v);
679
+ this.mail = saved.mail;
680
+ this.putBack();
681
+ throw error;
682
+ }
683
+ }
684
+ /** Memory is as it was before a change that failed: the host rewrites its copies to match. The caller hears why
685
+ * its change failed, not why the put-back did. */
686
+ putBack() {
687
+ try {
688
+ this.onRollback?.();
689
+ }
690
+ catch (rollbackError) {
691
+ this.onRecordWriteFailed('the put-back of a change that failed', rollbackError);
692
+ }
693
+ }
694
+ /** Run a record of something that has already happened: its writes alert and stay in memory rather than throw. */
695
+ recording(change) {
696
+ this.recordingDepth++;
697
+ try {
698
+ return change();
699
+ }
700
+ finally {
701
+ this.recordingDepth--;
702
+ }
703
+ }
704
+ /** The ended records in memory, newest last. */
705
+ endedHistory() {
706
+ // An interrupted end not yet written isn't history yet: written into the
707
+ // ended file early, it would let a crash drop the record before its
708
+ // recipient is told (restore drops what the ended history holds).
709
+ return this.ended
710
+ .filter((record) => !this.interruptedPending.has(`${record.name}#${record.incarnation}`))
711
+ .map(clone);
712
+ }
713
+ /** Persist a record of something that has already happened: a failure
714
+ * alerts and leaves the change in memory for the next write to carry. */
715
+ persistRecord(what) {
716
+ try {
717
+ this.persist();
718
+ }
719
+ catch (error) {
720
+ this.onRecordWriteFailed(what, error);
721
+ }
722
+ }
723
+ /** Write the snapshot through the host's persist hook. A write that fails
724
+ * fails the change that made it (nissa's #546 finding 2): the change is in
725
+ * memory, and the caller is told it is not durable, rather than told it
726
+ * succeeded while a restart would lose it. */
727
+ persist() {
728
+ if (!this.persistFn)
729
+ return;
730
+ if (this.recordingDepth > 0) {
731
+ try {
732
+ this.persistFn(this.snapshot());
733
+ }
734
+ catch (error) {
735
+ this.onRecordWriteFailed('a change already made', error);
736
+ }
737
+ return;
738
+ }
739
+ this.persistFn(this.snapshot());
740
+ }
741
+ }
742
+ //# sourceMappingURL=registry.js.map