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