@animalabs/agent-framework 0.28.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 (289) hide show
  1. package/README.md +88 -25
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +21 -0
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +47 -7
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +3 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +7 -3
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/author-presentation.d.ts +50 -0
  17. package/dist/src/author-presentation.d.ts.map +1 -0
  18. package/dist/src/author-presentation.js +58 -0
  19. package/dist/src/author-presentation.js.map +1 -0
  20. package/dist/src/auto-continue.d.ts +2 -0
  21. package/dist/src/auto-continue.d.ts.map +1 -1
  22. package/dist/src/auto-continue.js +5 -2
  23. package/dist/src/auto-continue.js.map +1 -1
  24. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  25. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/isolate/runner.js +7 -5
  27. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  28. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  29. package/dist/src/code-execution/isolate/scripts.js +3 -2
  30. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  31. package/dist/src/dendrite/index.d.ts +6 -0
  32. package/dist/src/dendrite/index.d.ts.map +1 -0
  33. package/dist/src/dendrite/index.js +3 -0
  34. package/dist/src/dendrite/index.js.map +1 -0
  35. package/dist/src/dendrite/presets.d.ts +60 -0
  36. package/dist/src/dendrite/presets.d.ts.map +1 -0
  37. package/dist/src/dendrite/presets.js +117 -0
  38. package/dist/src/dendrite/presets.js.map +1 -0
  39. package/dist/src/dendrite/registry.d.ts +260 -0
  40. package/dist/src/dendrite/registry.d.ts.map +1 -0
  41. package/dist/src/dendrite/registry.js +742 -0
  42. package/dist/src/dendrite/registry.js.map +1 -0
  43. package/dist/src/dendrite/types.d.ts +392 -0
  44. package/dist/src/dendrite/types.d.ts.map +1 -0
  45. package/dist/src/dendrite/types.js +24 -0
  46. package/dist/src/dendrite/types.js.map +1 -0
  47. package/dist/src/framework.d.ts +846 -67
  48. package/dist/src/framework.d.ts.map +1 -1
  49. package/dist/src/framework.js +4213 -924
  50. package/dist/src/framework.js.map +1 -1
  51. package/dist/src/gate/event-gate.d.ts +46 -14
  52. package/dist/src/gate/event-gate.d.ts.map +1 -1
  53. package/dist/src/gate/event-gate.js +143 -53
  54. package/dist/src/gate/event-gate.js.map +1 -1
  55. package/dist/src/gate/index.d.ts +1 -1
  56. package/dist/src/gate/index.d.ts.map +1 -1
  57. package/dist/src/gate/index.js +1 -1
  58. package/dist/src/gate/index.js.map +1 -1
  59. package/dist/src/gate/types.d.ts +12 -2
  60. package/dist/src/gate/types.d.ts.map +1 -1
  61. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  62. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  63. package/dist/src/heartbeat/heartbeat.js +34 -8
  64. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  65. package/dist/src/held-writes.d.ts +3 -3
  66. package/dist/src/held-writes.js +3 -3
  67. package/dist/src/host-presentation.d.ts +174 -0
  68. package/dist/src/host-presentation.d.ts.map +1 -0
  69. package/dist/src/host-presentation.js +565 -0
  70. package/dist/src/host-presentation.js.map +1 -0
  71. package/dist/src/index.d.ts +7 -4
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +7 -2
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/line-reader.d.ts +4 -0
  76. package/dist/src/line-reader.d.ts.map +1 -0
  77. package/dist/src/line-reader.js +59 -0
  78. package/dist/src/line-reader.js.map +1 -0
  79. package/dist/src/mcpl/channel-registry.d.ts +118 -15
  80. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  81. package/dist/src/mcpl/channel-registry.js +266 -110
  82. package/dist/src/mcpl/channel-registry.js.map +1 -1
  83. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  84. package/dist/src/mcpl/conversation-router.js +2 -1
  85. package/dist/src/mcpl/conversation-router.js.map +1 -1
  86. package/dist/src/mcpl/convert-block.d.ts +1 -0
  87. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  88. package/dist/src/mcpl/convert-block.js +16 -4
  89. package/dist/src/mcpl/convert-block.js.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  91. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  92. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  93. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  94. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  95. package/dist/src/mcpl/inbound-source.js +3 -1
  96. package/dist/src/mcpl/inbound-source.js.map +1 -1
  97. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/modern-connection.js +11 -7
  99. package/dist/src/mcpl/modern-connection.js.map +1 -1
  100. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  101. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  102. package/dist/src/mcpl/protocol-family.js +25 -3
  103. package/dist/src/mcpl/protocol-family.js.map +1 -1
  104. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  105. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  106. package/dist/src/mcpl/push-coalescer.js +21 -0
  107. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  108. package/dist/src/mcpl/push-handler.js +2 -2
  109. package/dist/src/mcpl/push-handler.js.map +1 -1
  110. package/dist/src/mcpl/references.d.ts +12 -40
  111. package/dist/src/mcpl/references.d.ts.map +1 -1
  112. package/dist/src/mcpl/references.js +29 -23
  113. package/dist/src/mcpl/references.js.map +1 -1
  114. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  115. package/dist/src/mcpl/server-connection.js +20 -20
  116. package/dist/src/mcpl/server-connection.js.map +1 -1
  117. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  118. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  119. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  120. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  121. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-presence.js +18 -17
  123. package/dist/src/mcpl/tool-presence.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  125. package/dist/src/mcpl/tool-signature.js +17 -16
  126. package/dist/src/mcpl/tool-signature.js.map +1 -1
  127. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  128. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  129. package/dist/src/mcpl/toolset-host.js +16 -6
  130. package/dist/src/mcpl/toolset-host.js.map +1 -1
  131. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  132. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  133. package/dist/src/mcpl/toolsets-module.js +187 -50
  134. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  135. package/dist/src/mcpl/transport.d.ts +1 -1
  136. package/dist/src/mcpl/transport.d.ts.map +1 -1
  137. package/dist/src/mcpl/transport.js +16 -16
  138. package/dist/src/mcpl/transport.js.map +1 -1
  139. package/dist/src/mcpl/types.d.ts +28 -12
  140. package/dist/src/mcpl/types.d.ts.map +1 -1
  141. package/dist/src/mcpl/types.js.map +1 -1
  142. package/dist/src/module-registry.d.ts +6 -7
  143. package/dist/src/module-registry.d.ts.map +1 -1
  144. package/dist/src/module-registry.js +6 -7
  145. package/dist/src/module-registry.js.map +1 -1
  146. package/dist/src/modules/api/index.d.ts.map +1 -1
  147. package/dist/src/modules/api/index.js +4 -2
  148. package/dist/src/modules/api/index.js.map +1 -1
  149. package/dist/src/modules/discord/index.d.ts.map +1 -1
  150. package/dist/src/modules/discord/index.js +33 -25
  151. package/dist/src/modules/discord/index.js.map +1 -1
  152. package/dist/src/modules/history/index.js +8 -6
  153. package/dist/src/modules/history/index.js.map +1 -1
  154. package/dist/src/modules/sender-name.d.ts +7 -0
  155. package/dist/src/modules/sender-name.d.ts.map +1 -0
  156. package/dist/src/modules/sender-name.js +10 -0
  157. package/dist/src/modules/sender-name.js.map +1 -0
  158. package/dist/src/modules/workspace/answers.d.ts +5 -1
  159. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/answers.js +16 -1
  161. package/dist/src/modules/workspace/answers.js.map +1 -1
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +6 -4
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/effects.d.ts +4 -2
  166. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/effects.js +50 -40
  168. package/dist/src/modules/workspace/effects.js.map +1 -1
  169. package/dist/src/modules/workspace/index.d.ts +1 -0
  170. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/index.js +66 -36
  172. package/dist/src/modules/workspace/index.js.map +1 -1
  173. package/dist/src/modules/workspace/observe.d.ts +51 -20
  174. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/observe.js +120 -82
  176. package/dist/src/modules/workspace/observe.js.map +1 -1
  177. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  178. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/reconcile.js +133 -37
  180. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  181. package/dist/src/notice-tags.d.ts +81 -0
  182. package/dist/src/notice-tags.d.ts.map +1 -0
  183. package/dist/src/notice-tags.js +85 -0
  184. package/dist/src/notice-tags.js.map +1 -0
  185. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  186. package/dist/src/reasoning-loss-notice.js +5 -3
  187. package/dist/src/reasoning-loss-notice.js.map +1 -1
  188. package/dist/src/silent-heartbeat.d.ts +5 -55
  189. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  190. package/dist/src/silent-heartbeat.js +63 -7
  191. package/dist/src/silent-heartbeat.js.map +1 -1
  192. package/dist/src/stored-injections.d.ts +4 -0
  193. package/dist/src/stored-injections.d.ts.map +1 -1
  194. package/dist/src/stored-injections.js +6 -0
  195. package/dist/src/stored-injections.js.map +1 -1
  196. package/dist/src/supplied-text.d.ts +35 -0
  197. package/dist/src/supplied-text.d.ts.map +1 -0
  198. package/dist/src/supplied-text.js +141 -0
  199. package/dist/src/supplied-text.js.map +1 -0
  200. package/dist/src/tool-args.d.ts.map +1 -1
  201. package/dist/src/tool-args.js +9 -8
  202. package/dist/src/tool-args.js.map +1 -1
  203. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  204. package/dist/src/tool-image-ledger.js +2 -1
  205. package/dist/src/tool-image-ledger.js.map +1 -1
  206. package/dist/src/tool-presentation.d.ts.map +1 -1
  207. package/dist/src/tool-presentation.js +5 -4
  208. package/dist/src/tool-presentation.js.map +1 -1
  209. package/dist/src/tune-out/coordinator.d.ts +382 -11
  210. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  211. package/dist/src/tune-out/coordinator.js +1100 -119
  212. package/dist/src/tune-out/coordinator.js.map +1 -1
  213. package/dist/src/tune-out/tools.d.ts +101 -5
  214. package/dist/src/tune-out/tools.d.ts.map +1 -1
  215. package/dist/src/tune-out/tools.js +55 -18
  216. package/dist/src/tune-out/tools.js.map +1 -1
  217. package/dist/src/types/agent.d.ts +48 -9
  218. package/dist/src/types/agent.d.ts.map +1 -1
  219. package/dist/src/types/framework.d.ts +142 -0
  220. package/dist/src/types/framework.d.ts.map +1 -1
  221. package/dist/src/types/module.d.ts +22 -11
  222. package/dist/src/types/module.d.ts.map +1 -1
  223. package/dist/src/types/trace.d.ts +136 -0
  224. package/dist/src/types/trace.d.ts.map +1 -1
  225. package/package.json +3 -3
  226. package/src/admission/admit.ts +113 -0
  227. package/src/agent.ts +52 -7
  228. package/src/api/mcp-server-core.ts +3 -14
  229. package/src/api/server.ts +7 -3
  230. package/src/author-presentation.ts +81 -0
  231. package/src/auto-continue.ts +8 -2
  232. package/src/code-execution/isolate/runner.ts +7 -5
  233. package/src/code-execution/isolate/scripts.ts +3 -2
  234. package/src/dendrite/index.ts +32 -0
  235. package/src/dendrite/presets.ts +149 -0
  236. package/src/dendrite/registry.ts +808 -0
  237. package/src/dendrite/types.ts +397 -0
  238. package/src/framework.ts +4362 -852
  239. package/src/gate/event-gate.ts +159 -52
  240. package/src/gate/index.ts +1 -1
  241. package/src/gate/types.ts +12 -2
  242. package/src/heartbeat/heartbeat.ts +48 -9
  243. package/src/held-writes.ts +3 -3
  244. package/src/host-presentation.ts +612 -0
  245. package/src/index.ts +17 -4
  246. package/src/line-reader.ts +58 -0
  247. package/src/mcpl/channel-registry.ts +306 -111
  248. package/src/mcpl/conversation-router.ts +2 -1
  249. package/src/mcpl/convert-block.ts +17 -4
  250. package/src/mcpl/hook-orchestrator.ts +4 -3
  251. package/src/mcpl/inbound-source.ts +3 -1
  252. package/src/mcpl/modern-connection.ts +11 -7
  253. package/src/mcpl/protocol-family.ts +24 -3
  254. package/src/mcpl/push-coalescer.ts +19 -0
  255. package/src/mcpl/push-handler.ts +2 -2
  256. package/src/mcpl/references.ts +31 -23
  257. package/src/mcpl/server-connection.ts +20 -18
  258. package/src/mcpl/skip-reply-wake.ts +35 -0
  259. package/src/mcpl/tool-presence.ts +18 -17
  260. package/src/mcpl/tool-signature.ts +17 -16
  261. package/src/mcpl/toolset-host.ts +18 -6
  262. package/src/mcpl/toolsets-module.ts +149 -50
  263. package/src/mcpl/transport.ts +18 -14
  264. package/src/mcpl/types.ts +29 -12
  265. package/src/module-registry.ts +6 -7
  266. package/src/modules/api/index.ts +4 -2
  267. package/src/modules/discord/index.ts +37 -25
  268. package/src/modules/history/index.ts +8 -6
  269. package/src/modules/sender-name.ts +9 -0
  270. package/src/modules/workspace/answers.ts +20 -2
  271. package/src/modules/workspace/direct-write.ts +4 -3
  272. package/src/modules/workspace/effects.ts +50 -40
  273. package/src/modules/workspace/index.ts +58 -34
  274. package/src/modules/workspace/observe.ts +139 -88
  275. package/src/modules/workspace/reconcile.ts +159 -36
  276. package/src/notice-tags.ts +86 -0
  277. package/src/reasoning-loss-notice.ts +5 -3
  278. package/src/silent-heartbeat.ts +11 -7
  279. package/src/stored-injections.ts +7 -0
  280. package/src/supplied-text.ts +154 -0
  281. package/src/tool-args.ts +9 -8
  282. package/src/tool-image-ledger.ts +2 -1
  283. package/src/tool-presentation.ts +5 -4
  284. package/src/tune-out/coordinator.ts +1252 -123
  285. package/src/tune-out/tools.ts +152 -11
  286. package/src/types/agent.ts +51 -9
  287. package/src/types/framework.ts +146 -0
  288. package/src/types/module.ts +23 -11
  289. package/src/types/trace.ts +150 -0
@@ -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
+ }