@animalabs/agent-framework 0.22.1 → 0.22.2

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 (359) hide show
  1. package/README.md +1 -1
  2. package/dist/src/agent.d.ts +192 -19
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +404 -65
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts +41 -0
  7. package/dist/src/api/mcp-server-core.d.ts.map +1 -0
  8. package/dist/src/api/mcp-server-core.js +593 -0
  9. package/dist/src/api/mcp-server-core.js.map +1 -0
  10. package/dist/src/api/mcp-server.d.ts +2 -36
  11. package/dist/src/api/mcp-server.d.ts.map +1 -1
  12. package/dist/src/api/mcp-server.js +15 -595
  13. package/dist/src/api/mcp-server.js.map +1 -1
  14. package/dist/src/api/server.d.ts +9 -1
  15. package/dist/src/api/server.d.ts.map +1 -1
  16. package/dist/src/api/server.js +50 -26
  17. package/dist/src/api/server.js.map +1 -1
  18. package/dist/src/api/types.d.ts +9 -1
  19. package/dist/src/api/types.d.ts.map +1 -1
  20. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  21. package/dist/src/code-execution/py-runner.js.map +1 -1
  22. package/dist/src/framework.d.ts +393 -113
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1748 -582
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/gate-script.d.ts.map +1 -1
  28. package/dist/src/gate/gate-script.js.map +1 -1
  29. package/dist/src/index.d.ts +2 -2
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +2 -2
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  34. package/dist/src/mcpl/channel-registry.d.ts +26 -7
  35. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  36. package/dist/src/mcpl/channel-registry.js +102 -22
  37. package/dist/src/mcpl/channel-registry.js.map +1 -1
  38. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  40. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  41. package/dist/src/mcpl/errors.js.map +1 -1
  42. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  43. package/dist/src/mcpl/feature-set-manager.js +4 -3
  44. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  46. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  47. package/dist/src/mcpl/log-value.d.ts +54 -0
  48. package/dist/src/mcpl/log-value.d.ts.map +1 -0
  49. package/dist/src/mcpl/log-value.js +109 -0
  50. package/dist/src/mcpl/log-value.js.map +1 -0
  51. package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
  52. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  53. package/dist/src/mcpl/prose-stream-router.js +4 -4
  54. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  55. package/dist/src/mcpl/push-coalescer.d.ts +38 -3
  56. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  57. package/dist/src/mcpl/push-coalescer.js +81 -16
  58. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  59. package/dist/src/mcpl/push-handler.d.ts +26 -3
  60. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-handler.js +87 -23
  62. package/dist/src/mcpl/push-handler.js.map +1 -1
  63. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  64. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  65. package/dist/src/mcpl/references.d.ts +2 -2
  66. package/dist/src/mcpl/references.d.ts.map +1 -1
  67. package/dist/src/mcpl/references.js.map +1 -1
  68. package/dist/src/mcpl/refusal-lines.d.ts +37 -0
  69. package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
  70. package/dist/src/mcpl/refusal-lines.js +61 -0
  71. package/dist/src/mcpl/refusal-lines.js.map +1 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
  73. package/dist/src/mcpl/server-connection.d.ts +18 -2
  74. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  75. package/dist/src/mcpl/server-connection.js +66 -11
  76. package/dist/src/mcpl/server-connection.js.map +1 -1
  77. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  78. package/dist/src/mcpl/tool-classes.d.ts +1 -1
  79. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  80. package/dist/src/mcpl/tool-classes.js +3 -2
  81. package/dist/src/mcpl/tool-classes.js.map +1 -1
  82. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  83. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  84. package/dist/src/mcpl/transport.d.ts +23 -5
  85. package/dist/src/mcpl/transport.d.ts.map +1 -1
  86. package/dist/src/mcpl/transport.js +103 -10
  87. package/dist/src/mcpl/transport.js.map +1 -1
  88. package/dist/src/mcpl/types.d.ts +30 -26
  89. package/dist/src/mcpl/types.d.ts.map +1 -1
  90. package/dist/src/mcpl/types.js.map +1 -1
  91. package/dist/src/module-registry.d.ts.map +1 -1
  92. package/dist/src/module-registry.js.map +1 -1
  93. package/dist/src/modules/api/index.d.ts.map +1 -1
  94. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  95. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  96. package/dist/src/modules/discord/index.d.ts.map +1 -1
  97. package/dist/src/modules/discord/index.js +4 -2
  98. package/dist/src/modules/discord/index.js.map +1 -1
  99. package/dist/src/modules/health/index.d.ts.map +1 -1
  100. package/dist/src/modules/health/index.js.map +1 -1
  101. package/dist/src/modules/history/index.d.ts.map +1 -1
  102. package/dist/src/modules/history/index.js.map +1 -1
  103. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  104. package/dist/src/modules/history/semantic.js.map +1 -1
  105. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  107. package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
  108. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/disk-agreement.js +63 -25
  110. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/index.d.ts +8 -4
  113. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  114. package/dist/src/modules/workspace/index.js +39 -12
  115. package/dist/src/modules/workspace/index.js.map +1 -1
  116. package/dist/src/modules/workspace/observe.d.ts +43 -8
  117. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  118. package/dist/src/modules/workspace/observe.js +264 -39
  119. package/dist/src/modules/workspace/observe.js.map +1 -1
  120. package/dist/src/modules/workspace/reconcile.d.ts +17 -1
  121. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  122. package/dist/src/modules/workspace/reconcile.js +57 -14
  123. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  124. package/dist/src/modules/workspace/types.d.ts +27 -2
  125. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/types.js.map +1 -1
  127. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  128. package/dist/src/modules/workspace/watcher.js.map +1 -1
  129. package/dist/src/operator-log.d.ts.map +1 -1
  130. package/dist/src/operator-log.js.map +1 -1
  131. package/dist/src/provider-waits.d.ts.map +1 -1
  132. package/dist/src/provider-waits.js.map +1 -1
  133. package/dist/src/queue.d.ts.map +1 -1
  134. package/dist/src/record-journal.d.ts.map +1 -1
  135. package/dist/src/record-journal.js.map +1 -1
  136. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  137. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  138. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  139. package/dist/src/recovery/offline-branch.js +29 -24
  140. package/dist/src/recovery/offline-branch.js.map +1 -1
  141. package/dist/src/recovery/recover-cli.js.map +1 -1
  142. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
  143. package/dist/src/runtime/liveness-watchdog.js.map +1 -1
  144. package/dist/src/shutdown.d.ts.map +1 -1
  145. package/dist/src/shutdown.js.map +1 -1
  146. package/dist/src/stored-removal.d.ts +73 -0
  147. package/dist/src/stored-removal.d.ts.map +1 -0
  148. package/dist/src/stored-removal.js +100 -0
  149. package/dist/src/stored-removal.js.map +1 -0
  150. package/dist/src/thinking-token-stamp.js.map +1 -1
  151. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  152. package/dist/src/tool-image-ledger.js.map +1 -1
  153. package/dist/src/tool-presentation.d.ts.map +1 -1
  154. package/dist/src/tool-presentation.js.map +1 -1
  155. package/dist/src/tool-result-guard.d.ts.map +1 -1
  156. package/dist/src/tool-result-guard.js.map +1 -1
  157. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  158. package/dist/src/tune-out/coordinator.js.map +1 -1
  159. package/dist/src/tune-out/tools.d.ts +1 -1
  160. package/dist/src/tune-out/tools.d.ts.map +1 -1
  161. package/dist/src/types/agent.d.ts +42 -6
  162. package/dist/src/types/agent.d.ts.map +1 -1
  163. package/dist/src/types/framework.d.ts +10 -0
  164. package/dist/src/types/framework.d.ts.map +1 -1
  165. package/dist/src/types/module.d.ts +8 -4
  166. package/dist/src/types/module.d.ts.map +1 -1
  167. package/dist/src/types/trace.d.ts +61 -10
  168. package/dist/src/types/trace.d.ts.map +1 -1
  169. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  170. package/dist/test/agent-runtime-settings.test.js +1139 -3
  171. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  172. package/dist/test/batched-wake-locus.test.js.map +1 -1
  173. package/dist/test/channel-label-history.test.js.map +1 -1
  174. package/dist/test/code-execution.test.js.map +1 -1
  175. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  176. package/dist/test/conversation-router.test.js.map +1 -1
  177. package/dist/test/conversation-routing.test.js +240 -6
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +104 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
  182. package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
  183. package/dist/test/discord-typing-keepalive.test.js +77 -0
  184. package/dist/test/discord-typing-keepalive.test.js.map +1 -0
  185. package/dist/test/ephemeral-settle.test.js.map +1 -1
  186. package/dist/test/event-gate-passthrough.test.js.map +1 -1
  187. package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
  188. package/dist/test/event-gate-rate-limit.test.js.map +1 -1
  189. package/dist/test/event-gate-script.test.js.map +1 -1
  190. package/dist/test/event-gate-selfwake.test.js.map +1 -1
  191. package/dist/test/event-gate-tags.test.js.map +1 -1
  192. package/dist/test/event-gate.test.js.map +1 -1
  193. package/dist/test/explicit-prose-routing.test.js +363 -42
  194. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  195. package/dist/test/framework.test.js.map +1 -1
  196. package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
  197. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  198. package/dist/test/helpers/coalescing-fixture.js +8 -2
  199. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  200. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  201. package/dist/test/helpers/mock-membrane.js.map +1 -1
  202. package/dist/test/hide-then-turn.test.d.ts +2 -0
  203. package/dist/test/hide-then-turn.test.d.ts.map +1 -0
  204. package/dist/test/hide-then-turn.test.js +101 -0
  205. package/dist/test/hide-then-turn.test.js.map +1 -0
  206. package/dist/test/history-module-overview.test.js.map +1 -1
  207. package/dist/test/history-module-semantic.test.js.map +1 -1
  208. package/dist/test/history-module.test.js.map +1 -1
  209. package/dist/test/history-semantic-review-findings.test.js.map +1 -1
  210. package/dist/test/host-quiesce.test.js +125 -0
  211. package/dist/test/host-quiesce.test.js.map +1 -1
  212. package/dist/test/inference-exhaustion-rewind.test.js +81 -3
  213. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  214. package/dist/test/inference-failure-bounds.test.js +1 -0
  215. package/dist/test/inference-failure-bounds.test.js.map +1 -1
  216. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
  217. package/dist/test/liveness-watchdog.test.js.map +1 -1
  218. package/dist/test/maintenance-timer.test.js +1564 -2
  219. package/dist/test/maintenance-timer.test.js.map +1 -1
  220. package/dist/test/mcp-server-bin.test.d.ts +2 -0
  221. package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
  222. package/dist/test/mcp-server-bin.test.js +142 -0
  223. package/dist/test/mcp-server-bin.test.js.map +1 -0
  224. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  225. package/dist/test/mcpl-child-env.test.js +1 -1
  226. package/dist/test/mcpl-child-env.test.js.map +1 -1
  227. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
  228. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
  229. package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
  230. package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
  231. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  232. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  233. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  234. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  235. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  236. package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
  237. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  238. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  239. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  240. package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
  241. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
  242. package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
  243. package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
  244. package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
  245. package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
  246. package/dist/test/mcpl-stderr-tail.test.js +210 -0
  247. package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
  248. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
  249. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  250. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
  251. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
  252. package/dist/test/mcpl-transport-close.test.d.ts +2 -0
  253. package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
  254. package/dist/test/mcpl-transport-close.test.js +373 -0
  255. package/dist/test/mcpl-transport-close.test.js.map +1 -0
  256. package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
  257. package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
  258. package/dist/test/mcpl-transport-spawn.test.js +58 -0
  259. package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
  260. package/dist/test/mcpl-ws-transport.test.js +1 -0
  261. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  262. package/dist/test/offline-recovery-branch.test.js +344 -0
  263. package/dist/test/offline-recovery-branch.test.js.map +1 -1
  264. package/dist/test/operator-surgery.test.js +197 -0
  265. package/dist/test/operator-surgery.test.js.map +1 -1
  266. package/dist/test/overbudget-drain-breaker.test.js +1 -0
  267. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  268. package/dist/test/pack-installs-alone.test.d.ts +2 -0
  269. package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
  270. package/dist/test/pack-installs-alone.test.js +284 -0
  271. package/dist/test/pack-installs-alone.test.js.map +1 -0
  272. package/dist/test/physical-window-restart.test.d.ts +6 -4
  273. package/dist/test/physical-window-restart.test.d.ts.map +1 -1
  274. package/dist/test/physical-window-restart.test.js +275 -10
  275. package/dist/test/physical-window-restart.test.js.map +1 -1
  276. package/dist/test/placed-reaction-baseline.test.js.map +1 -1
  277. package/dist/test/present-while-acting.test.js +949 -6
  278. package/dist/test/present-while-acting.test.js.map +1 -1
  279. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  280. package/dist/test/provider-waits.test.js +67 -4
  281. package/dist/test/provider-waits.test.js.map +1 -1
  282. package/dist/test/puppet-tool-call.test.js +2 -0
  283. package/dist/test/puppet-tool-call.test.js.map +1 -1
  284. package/dist/test/reaction-baseline-platform.test.js.map +1 -1
  285. package/dist/test/record-journal.test.js.map +1 -1
  286. package/dist/test/refusal-rewind.test.js +9 -0
  287. package/dist/test/refusal-rewind.test.js.map +1 -1
  288. package/dist/test/refusal-single-call.test.js.map +1 -1
  289. package/dist/test/release-bump.test.d.ts +2 -0
  290. package/dist/test/release-bump.test.d.ts.map +1 -0
  291. package/dist/test/release-bump.test.js +248 -0
  292. package/dist/test/release-bump.test.js.map +1 -0
  293. package/dist/test/release-changelog.test.js.map +1 -1
  294. package/dist/test/release-holds.test.d.ts +2 -0
  295. package/dist/test/release-holds.test.d.ts.map +1 -0
  296. package/dist/test/release-holds.test.js +456 -0
  297. package/dist/test/release-holds.test.js.map +1 -0
  298. package/dist/test/same-round-think-policy.test.js.map +1 -1
  299. package/dist/test/shutdown-errors.test.js.map +1 -1
  300. package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
  301. package/dist/test/stop-with-host-operations.test.d.ts +20 -0
  302. package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
  303. package/dist/test/stop-with-host-operations.test.js +1174 -0
  304. package/dist/test/stop-with-host-operations.test.js.map +1 -0
  305. package/dist/test/stop-with-work-in-flight.test.js +162 -50
  306. package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
  307. package/dist/test/surgery-marks-receipt.test.js.map +1 -1
  308. package/dist/test/tool-batch-hook.test.js.map +1 -1
  309. package/dist/test/tool-result-guard.test.js +131 -9
  310. package/dist/test/tool-result-guard.test.js.map +1 -1
  311. package/dist/test/tool-result-spill.test.js.map +1 -1
  312. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
  313. package/dist/test/trunk-channel-routing.test.js +330 -7
  314. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  315. package/dist/test/tune-out-e2e.test.js.map +1 -1
  316. package/dist/test/unconvertible-tool-result.test.js.map +1 -1
  317. package/dist/test/unsent-compiles.test.d.ts +2 -0
  318. package/dist/test/unsent-compiles.test.d.ts.map +1 -0
  319. package/dist/test/unsent-compiles.test.js +139 -0
  320. package/dist/test/unsent-compiles.test.js.map +1 -0
  321. package/dist/test/workspace-branch-continuation.test.js.map +1 -1
  322. package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
  323. package/dist/test/workspace-read-text.test.js.map +1 -1
  324. package/dist/test/workspace-three-way.test.js +907 -7
  325. package/dist/test/workspace-three-way.test.js.map +1 -1
  326. package/dist/test/workspace-watcher.test.js.map +1 -1
  327. package/dist/test/xml-round-persist.test.js.map +1 -1
  328. package/dist/tsconfig.tsbuildinfo +1 -1
  329. package/package.json +8 -6
  330. package/src/agent.ts +411 -66
  331. package/src/api/mcp-server-core.ts +681 -0
  332. package/src/api/mcp-server.ts +16 -685
  333. package/src/api/server.ts +51 -29
  334. package/src/api/types.ts +9 -1
  335. package/src/framework.ts +1899 -641
  336. package/src/index.ts +2 -2
  337. package/src/mcpl/channel-registry.ts +126 -31
  338. package/src/mcpl/feature-set-manager.ts +4 -3
  339. package/src/mcpl/log-value.ts +116 -0
  340. package/src/mcpl/prose-stream-router.ts +6 -6
  341. package/src/mcpl/push-coalescer.ts +76 -12
  342. package/src/mcpl/push-handler.ts +95 -23
  343. package/src/mcpl/refusal-lines.ts +59 -0
  344. package/src/mcpl/server-connection.ts +70 -11
  345. package/src/mcpl/tool-classes.ts +3 -2
  346. package/src/mcpl/transport.ts +105 -14
  347. package/src/mcpl/types.ts +5 -1
  348. package/src/modules/discord/index.ts +4 -2
  349. package/src/modules/workspace/disk-agreement.ts +74 -18
  350. package/src/modules/workspace/index.ts +41 -11
  351. package/src/modules/workspace/observe.ts +265 -39
  352. package/src/modules/workspace/reconcile.ts +71 -14
  353. package/src/modules/workspace/types.ts +26 -1
  354. package/src/recovery/offline-branch.ts +32 -28
  355. package/src/stored-removal.ts +126 -0
  356. package/src/types/agent.ts +42 -6
  357. package/src/types/framework.ts +10 -0
  358. package/src/types/module.ts +8 -4
  359. package/src/types/trace.ts +62 -11
@@ -4,6 +4,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { AgentFramework } from '../src/index.js';
7
+ import { OperatorActionError } from '../src/operator-log.js';
8
+ import { MembraneError } from '@animalabs/membrane';
7
9
  class QueuedStrategy {
8
10
  name = 'queued-test';
9
11
  ticks = 0;
@@ -56,6 +58,291 @@ class FailingStrategy {
56
58
  return [];
57
59
  }
58
60
  }
61
+ /** A strategy whose every pass takes one tick, then meets the same failure. */
62
+ class WorkThenFailStrategy {
63
+ name = 'work-then-fail-test';
64
+ ticks = 0;
65
+ checkReadiness() {
66
+ return { ready: false, description: 'compression queued' };
67
+ }
68
+ async tick(_ctx) {
69
+ this.ticks++;
70
+ if (this.ticks % 2 === 0)
71
+ throw new Error('provider rejected maintenance request');
72
+ }
73
+ select(_store, _log, _budget) {
74
+ return [];
75
+ }
76
+ }
77
+ /** A strategy whose initialization always fails. */
78
+ class RefusingInitStrategy {
79
+ name = 'refusing-init-test';
80
+ async initialize(_ctx) {
81
+ throw new Error('strategy initialization failed for the test');
82
+ }
83
+ checkReadiness() {
84
+ return { ready: false, description: 'not expected' };
85
+ }
86
+ async tick(_ctx) { }
87
+ select(_store, _log, _budget) {
88
+ return [];
89
+ }
90
+ }
91
+ /** A strategy that refuses on its own, as the autobiographical one does for a branch it hasn't reloaded. */
92
+ class StaleStrategy {
93
+ name = 'stale-test';
94
+ checkReadiness() {
95
+ throw new Error('StaleStrategy.checkReadiness requires reinitialization for the current branch generation');
96
+ }
97
+ getProgressSnapshot() {
98
+ throw new Error('StaleStrategy.getProgressSnapshot requires reinitialization for the current branch generation');
99
+ }
100
+ async tick(_ctx) { }
101
+ select(_store, _log, _budget) {
102
+ return [];
103
+ }
104
+ }
105
+ /** A strategy whose readiness query refuses with whatever text it's given. */
106
+ class SayingStrategy {
107
+ name = 'saying-test';
108
+ says = 'the first refusal';
109
+ checkReadiness() {
110
+ throw new Error(this.says);
111
+ }
112
+ async tick(_ctx) { }
113
+ select(_store, _log, _budget) {
114
+ return [];
115
+ }
116
+ }
117
+ /** A strategy with work pending that no tick finishes. */
118
+ class NeverReadyStrategy {
119
+ name = 'never-ready-test';
120
+ checkReadiness() {
121
+ return { ready: false, description: 'never ready' };
122
+ }
123
+ async tick(_ctx) { }
124
+ select(_store, _log, _budget) {
125
+ return [];
126
+ }
127
+ }
128
+ /** A strategy whose tick fences its own manager with a failed initialization. */
129
+ class FencingTickStrategy {
130
+ name = 'fencing-tick-test';
131
+ manager;
132
+ checkReadiness() {
133
+ return { ready: false, description: 'fencing tick queued' };
134
+ }
135
+ async tick(_ctx) {
136
+ await this.manager?.setStrategy(new RefusingInitStrategy()).catch(() => undefined);
137
+ }
138
+ select(_store, _log, _budget) {
139
+ return [];
140
+ }
141
+ }
142
+ /** A strategy with work pending whose tick runs a hook: a surgery can begin inside a pass. */
143
+ class HookedTickStrategy {
144
+ name = 'hooked-tick-test';
145
+ ticks = 0;
146
+ onTick;
147
+ checkReadiness() {
148
+ return { ready: false, description: 'hooked tick queued' };
149
+ }
150
+ async tick(_ctx) {
151
+ this.ticks++;
152
+ this.onTick?.();
153
+ }
154
+ select(_store, _log, _budget) {
155
+ return [];
156
+ }
157
+ }
158
+ /** A strategy with no maintenance to do. */
159
+ class ReadyStrategy {
160
+ name = 'ready-test';
161
+ checkReadiness() {
162
+ return { ready: true };
163
+ }
164
+ async tick(_ctx) { }
165
+ select(_store, _log, _budget) {
166
+ return [];
167
+ }
168
+ }
169
+ /** A strategy whose first tick waits on a gate, and that notes what it sees. */
170
+ class GatedTickStrategy {
171
+ name = 'gated-tick-test';
172
+ ticks = 0;
173
+ ticksUnderHold = 0;
174
+ branchAtFirstTickEnd;
175
+ storeOpenAtFirstTickEnd;
176
+ framework;
177
+ open;
178
+ gate = new Promise((resolve) => { this.open = resolve; });
179
+ release() {
180
+ this.open?.();
181
+ }
182
+ checkReadiness() {
183
+ return { ready: false, description: 'gated tick queued' };
184
+ }
185
+ async tick(_ctx) {
186
+ this.ticks++;
187
+ if (this.framework.surgeryHold)
188
+ this.ticksUnderHold++;
189
+ if (this.ticks === 1) {
190
+ await this.gate;
191
+ this.storeOpenAtFirstTickEnd = !this.framework.store.isClosed();
192
+ this.branchAtFirstTickEnd = this.framework.getAgent('agent').getContextManager().currentBranch().name;
193
+ }
194
+ }
195
+ select(_store, _log, _budget) {
196
+ return [];
197
+ }
198
+ }
199
+ /**
200
+ * A strategy whose first tick makes a provider call that parks at the agent's
201
+ * provider gate, as one does once a cooldown a primary call started has ended
202
+ * and the retry turn it queued hasn't run yet.
203
+ */
204
+ class ParkedCallTickStrategy {
205
+ name = 'parked-call-tick-test';
206
+ ticks = 0;
207
+ calls = 0;
208
+ outcome;
209
+ framework;
210
+ checkReadiness() {
211
+ return { ready: false, description: 'parked call queued' };
212
+ }
213
+ async tick(_ctx) {
214
+ this.ticks++;
215
+ if (this.ticks > 1)
216
+ return;
217
+ const internals = this.framework;
218
+ internals.providerGate('agent').primaryPending = true;
219
+ try {
220
+ await internals.withAuxiliaryAdmission('agent', async () => { this.calls++; });
221
+ this.outcome = 'ran';
222
+ }
223
+ catch (error) {
224
+ this.outcome = error.name;
225
+ throw error;
226
+ }
227
+ }
228
+ select(_store, _log, _budget) {
229
+ return [];
230
+ }
231
+ }
232
+ /** A provider's stated wait of `ms`, as membrane reports a 429 with retry-after. */
233
+ function heldFor(ms) {
234
+ return new MembraneError({
235
+ type: 'rate_limit', retryable: true, httpStatus: 429, retryAfterMs: ms,
236
+ message: 'rate limited for the test', rawError: { status: 429 },
237
+ });
238
+ }
239
+ /** A strategy whose first tick calls stop() before its first await, then notes whether the store was still open when it ended. */
240
+ class StopInTickStrategy {
241
+ name = 'stop-in-tick-test';
242
+ ticks = 0;
243
+ framework;
244
+ stopping;
245
+ stoppedBeforeTickEnd;
246
+ storeOpenAtTickEnd;
247
+ checkReadiness() {
248
+ return { ready: false, description: 'compression queued' };
249
+ }
250
+ async tick(_ctx) {
251
+ this.ticks++;
252
+ if (this.ticks > 1)
253
+ return;
254
+ this.stopping = this.framework.stop();
255
+ // A stop() that doesn't wait for this tick settles first; one that does
256
+ // can't settle before it ends, so the second second is this tick's.
257
+ const first = await Promise.race([
258
+ this.stopping.then(() => 'stopped', () => 'stopped'),
259
+ new Promise((resolve) => setTimeout(() => resolve('waited'), 1000)),
260
+ ]);
261
+ this.stoppedBeforeTickEnd = first === 'stopped';
262
+ this.storeOpenAtTickEnd = !this.framework.store.isClosed();
263
+ }
264
+ select(_store, _log, _budget) {
265
+ return [];
266
+ }
267
+ }
268
+ /** A strategy whose every tick meets the same failure, its second only once its gate opens. */
269
+ class GatedRepeatFailureStrategy {
270
+ name = 'gated-repeat-failure-test';
271
+ ticks = 0;
272
+ open;
273
+ gate = new Promise((resolve) => { this.open = resolve; });
274
+ release() {
275
+ this.open?.();
276
+ }
277
+ checkReadiness() {
278
+ return { ready: false, description: 'compression blocked' };
279
+ }
280
+ async tick(_ctx) {
281
+ this.ticks++;
282
+ if (this.ticks === 2)
283
+ await this.gate;
284
+ throw new Error('provider rejected maintenance request');
285
+ }
286
+ select(_store, _log, _budget) {
287
+ return [];
288
+ }
289
+ }
290
+ /** A strategy whose first tick waits on a gate, then calls its manager's membrane, as a compression would. */
291
+ class CallAfterGateStrategy {
292
+ name = 'call-after-gate-test';
293
+ ticks = 0;
294
+ entered = false;
295
+ outcome;
296
+ open;
297
+ gate = new Promise((resolve) => { this.open = resolve; });
298
+ release() {
299
+ this.open?.();
300
+ }
301
+ checkReadiness() {
302
+ return { ready: false, description: 'a compression queued' };
303
+ }
304
+ async tick(ctx) {
305
+ this.ticks++;
306
+ if (this.ticks > 1)
307
+ return;
308
+ this.entered = true;
309
+ await this.gate;
310
+ try {
311
+ await ctx.membrane?.complete({
312
+ model: 'test-model',
313
+ config: { model: 'test-model' },
314
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'compress' }] }],
315
+ });
316
+ this.outcome = 'sent';
317
+ }
318
+ catch (error) {
319
+ this.outcome = error.name;
320
+ throw error;
321
+ }
322
+ }
323
+ select(_store, _log, _budget) {
324
+ return [];
325
+ }
326
+ }
327
+ /** A strategy whose ticks succeed and whose readiness can't be read once all of a pass's ticks have run. */
328
+ class RefusesAfterTicksStrategy {
329
+ name = 'refuses-after-ticks-test';
330
+ ticks = 0;
331
+ failTick = false;
332
+ checkReadiness() {
333
+ if (this.ticks >= 8 || (this.failTick && this.ticks > 0))
334
+ throw new Error('readiness refused after the ticks');
335
+ return { ready: false, description: 'work queued' };
336
+ }
337
+ async tick(_ctx) {
338
+ this.ticks++;
339
+ if (this.failTick)
340
+ throw new Error('tick failed');
341
+ }
342
+ select(_store, _log, _budget) {
343
+ return [];
344
+ }
345
+ }
59
346
  class ToolModule {
60
347
  name = 'maintenance-tools';
61
348
  async start(_ctx) { }
@@ -97,6 +384,12 @@ class CapturingMembrane {
97
384
  return this;
98
385
  }
99
386
  }
387
+ /** The first pass the timer recorded. The history lists the newest first, and
388
+ * under load more passes can end before a test reads it. */
389
+ function firstPass(framework) {
390
+ const history = framework.getContextMaintenanceSnapshot().history;
391
+ return history[history.length - 1];
392
+ }
100
393
  async function waitFor(predicate, timeoutMs = 1000) {
101
394
  const deadline = Date.now() + timeoutMs;
102
395
  while (!predicate()) {
@@ -253,8 +546,1277 @@ describe('queued context maintenance timer', () => {
253
546
  pending: 'compression blocked',
254
547
  },
255
548
  });
256
- await waitFor(() => framework.getContextMaintenanceSnapshot().history.length > 0);
257
- assert.equal(framework.getContextMaintenanceSnapshot().history[0].agents[0].error, 'provider rejected maintenance request');
549
+ // Every pass meets the same failure. It is alerted once, and the
550
+ // passes that only repeat it stay out of the history.
551
+ await waitFor(() => framework.maintenanceRunId >= 3);
552
+ const history = framework.getContextMaintenanceSnapshot().history;
553
+ assert.equal(history.length, 1);
554
+ assert.equal(history[0].agents[0].error, 'provider rejected maintenance request');
555
+ assert.equal(alerts.length, 1);
556
+ }
557
+ finally {
558
+ await framework.stop();
559
+ rmSync(dir, { recursive: true, force: true });
560
+ }
561
+ });
562
+ it('an agent whose readiness can\'t be read fails alone: its run records the error and it gets its own alert', async () => {
563
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-refused-'));
564
+ const healthy = new QueuedStrategy();
565
+ const framework = await AgentFramework.create({
566
+ storePath: join(dir, 'store'),
567
+ membrane,
568
+ agents: [
569
+ { name: 'fenced', model: 'test-model', systemPrompt: 'test', strategy: new QueuedStrategy() },
570
+ { name: 'healthy', model: 'test-model', systemPrompt: 'test', strategy: healthy },
571
+ ],
572
+ modules: [],
573
+ syncIntervalMs: 0,
574
+ maintenanceIntervalMs: 10,
575
+ });
576
+ const alerts = [];
577
+ framework.opsAlert = (kind, agentName, _message, opts) => alerts.push({ kind, agentName, data: opts?.data });
578
+ // A failed strategy initialization fences the context manager: its state
579
+ // queries throw until a successful switchBranch or setStrategy.
580
+ const fenced = framework.getAgent('fenced').getContextManager();
581
+ await assert.rejects(fenced.setStrategy(new RefusingInitStrategy()), /strategy initialization failed for the test/);
582
+ assert.throws(() => fenced.isReady(), /requires successful strategy initialization/);
583
+ try {
584
+ framework.start();
585
+ await waitFor(() => healthy.ticks >= 2);
586
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.some((run) => run.agents.some((agent) => agent.agentName === 'healthy' && agent.readyAfter === true)));
587
+ const snapshot = framework.getContextMaintenanceSnapshot();
588
+ const run = snapshot.history.find((r) => r.agents.some((agent) => agent.agentName === 'healthy'));
589
+ const fencedRun = run.agents.find((agent) => agent.agentName === 'fenced');
590
+ assert.equal(fencedRun?.ticks, 0);
591
+ assert.match(fencedRun?.error ?? '', /requires successful strategy initialization/);
592
+ const entry = snapshot.agents.find((agent) => agent.agentName === 'fenced');
593
+ assert.equal(entry?.ready, false);
594
+ assert.match(entry?.error ?? '', /requires successful strategy initialization/);
595
+ assert.ok(alerts.some((alert) => alert.agentName === 'fenced' && alert.data?.scope === 'agent'));
596
+ assert.equal(alerts.some((alert) => alert.data?.scope === 'pass'), false);
597
+ assert.equal(alerts.some((alert) => alert.agentName === 'healthy'), false);
598
+ }
599
+ finally {
600
+ await framework.stop();
601
+ rmSync(dir, { recursive: true, force: true });
602
+ }
603
+ });
604
+ it('a failure that begins during the pass is that agent\'s error and alert, not the pass\'s', async () => {
605
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-refused-after-'));
606
+ const strategy = new FencingTickStrategy();
607
+ const framework = await AgentFramework.create({
608
+ storePath: join(dir, 'store'),
609
+ membrane,
610
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
611
+ modules: [],
612
+ syncIntervalMs: 0,
613
+ maintenanceIntervalMs: 10,
614
+ });
615
+ strategy.manager = framework.getAgent('agent').getContextManager();
616
+ const alerts = [];
617
+ framework.opsAlert = (kind, agentName, _message, opts) => alerts.push({ kind, agentName, data: opts?.data });
618
+ try {
619
+ framework.start();
620
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
621
+ const run = firstPass(framework).agents[0];
622
+ assert.equal(run.agentName, 'agent');
623
+ assert.equal(run.ticks, 1);
624
+ assert.match(run.error ?? '', /requires successful strategy initialization/);
625
+ assert.equal(run.readyAfter, undefined);
626
+ assert.ok(alerts.some((alert) => alert.agentName === 'agent' && alert.kind === 'context-maintenance-failed' && alert.data?.scope === 'agent'));
627
+ assert.equal(alerts.some((alert) => alert.data?.scope === 'pass'), false);
628
+ }
629
+ finally {
630
+ await framework.stop();
631
+ rmSync(dir, { recursive: true, force: true });
632
+ }
633
+ });
634
+ it('a strategy that can\'t report its readiness or its progress fails that agent alone, and the snapshot still answers', async () => {
635
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-stale-'));
636
+ const healthy = new QueuedStrategy();
637
+ const framework = await AgentFramework.create({
638
+ storePath: join(dir, 'store'),
639
+ membrane,
640
+ agents: [
641
+ { name: 'stale', model: 'test-model', systemPrompt: 'test', strategy: new StaleStrategy() },
642
+ { name: 'healthy', model: 'test-model', systemPrompt: 'test', strategy: healthy },
643
+ ],
644
+ modules: [],
645
+ syncIntervalMs: 0,
646
+ maintenanceIntervalMs: 10,
647
+ });
648
+ const alerts = [];
649
+ framework.opsAlert = (kind, agentName, _message, opts) => alerts.push({ kind, agentName, data: opts?.data });
650
+ try {
651
+ framework.start();
652
+ await waitFor(() => healthy.ticks >= 2);
653
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.some((run) => run.agents.some((agent) => agent.agentName === 'healthy' && agent.readyAfter === true)));
654
+ const snapshot = framework.getContextMaintenanceSnapshot();
655
+ const entry = snapshot.agents.find((agent) => agent.agentName === 'stale');
656
+ assert.equal(entry?.ready, false);
657
+ assert.match(entry?.error ?? '', /requires reinitialization for the current branch generation/);
658
+ assert.equal(entry?.progress, undefined);
659
+ assert.ok(alerts.some((alert) => alert.agentName === 'stale' && alert.data?.scope === 'agent'));
660
+ assert.equal(alerts.some((alert) => alert.data?.scope === 'pass'), false);
661
+ }
662
+ finally {
663
+ await framework.stop();
664
+ rmSync(dir, { recursive: true, force: true });
665
+ }
666
+ });
667
+ it('whatever throws while one agent\'s maintenance is prepared is that agent\'s failure alone', async () => {
668
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-prepare-throws-'));
669
+ const healthy = new QueuedStrategy();
670
+ const framework = await AgentFramework.create({
671
+ storePath: join(dir, 'store'),
672
+ membrane,
673
+ agents: [
674
+ { name: 'broken', model: 'test-model', systemPrompt: 'test', strategy: new QueuedStrategy() },
675
+ { name: 'healthy', model: 'test-model', systemPrompt: 'test', strategy: healthy },
676
+ ],
677
+ modules: [],
678
+ syncIntervalMs: 0,
679
+ maintenanceIntervalMs: 10,
680
+ });
681
+ const alerts = [];
682
+ framework.opsAlert = (kind, agentName, _message, opts) => alerts.push({ kind, agentName, data: opts?.data });
683
+ const internals = framework;
684
+ const gateBlocked = internals.providerGateBlocked.bind(framework);
685
+ internals.providerGateBlocked = (name) => {
686
+ if (name === 'broken')
687
+ throw new Error('gate read failed');
688
+ return gateBlocked(name);
689
+ };
690
+ try {
691
+ framework.start();
692
+ await waitFor(() => healthy.ticks >= 2);
693
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.some((run) => run.agents.some((agent) => agent.agentName === 'broken' && agent.error !== undefined)));
694
+ const run = framework.getContextMaintenanceSnapshot().history.find((candidate) => candidate.agents.some((agent) => agent.agentName === 'broken'));
695
+ const broken = run.agents.find((agent) => agent.agentName === 'broken');
696
+ assert.equal(broken.error, 'gate read failed');
697
+ assert.equal(broken.ticks, 0);
698
+ assert.ok(alerts.some((alert) => alert.agentName === 'broken' && alert.data?.scope === 'agent'));
699
+ assert.equal(alerts.some((alert) => alert.data?.scope === 'pass'), false);
700
+ }
701
+ finally {
702
+ await framework.stop();
703
+ rmSync(dir, { recursive: true, force: true });
704
+ }
705
+ });
706
+ it('a failure that lasts is logged and alerted once, and again once the agent has recovered', async (t) => {
707
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-lasting-'));
708
+ const framework = await AgentFramework.create({
709
+ storePath: join(dir, 'store'),
710
+ membrane,
711
+ agents: [{ name: 'fenced', model: 'test-model', systemPrompt: 'test', strategy: new QueuedStrategy() }],
712
+ modules: [],
713
+ syncIntervalMs: 0,
714
+ maintenanceIntervalMs: 10,
715
+ });
716
+ const alerts = [];
717
+ framework.opsAlert = (kind, agentName) => alerts.push(`${kind}:${agentName}`);
718
+ const internals = framework;
719
+ let passes = 0;
720
+ const runPass = internals.runQueuedMaintenance.bind(framework);
721
+ internals.runQueuedMaintenance = () => { passes++; return runPass(); };
722
+ const logged = t.mock.method(console, 'error', () => { });
723
+ const failedLines = () => logged.mock.calls.filter((call) => String(call.arguments[0]).includes('[context-maintenance] agent=fenced failed:')).length;
724
+ const runsWithError = () => framework.getContextMaintenanceSnapshot().history.filter((run) => run.agents.some((agent) => agent.agentName === 'fenced' && agent.error !== undefined)).length;
725
+ const cm = framework.getAgent('fenced').getContextManager();
726
+ const fence = () => assert.rejects(cm.setStrategy(new RefusingInitStrategy()), /strategy initialization failed for the test/);
727
+ await fence();
728
+ try {
729
+ framework.start();
730
+ await waitFor(() => passes >= 3);
731
+ assert.deepEqual(alerts, ['context-maintenance-failed:fenced']);
732
+ assert.equal(failedLines(), 1);
733
+ assert.equal(runsWithError(), 1, 'the passes that only repeat the failure stay out of the history');
734
+ // Recovered with nothing to do: passes find it ready and skip it.
735
+ await cm.setStrategy(new ReadyStrategy());
736
+ const after = passes;
737
+ await waitFor(() => passes >= after + 3);
738
+ await fence();
739
+ await waitFor(() => alerts.length === 2);
740
+ assert.equal(failedLines(), 2);
741
+ // Recovered with work no tick finishes: a pass runs it without a failure.
742
+ await cm.setStrategy(new NeverReadyStrategy());
743
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.some((run) => run.agents.some((agent) => agent.agentName === 'fenced' && agent.ticks > 0 && agent.error === undefined)));
744
+ await fence();
745
+ await waitFor(() => alerts.length === 3);
746
+ assert.equal(failedLines(), 3);
747
+ assert.deepEqual(new Set(alerts), new Set(['context-maintenance-failed:fenced']));
748
+ }
749
+ finally {
750
+ logged.mock.restore();
751
+ await framework.stop();
752
+ rmSync(dir, { recursive: true, force: true });
753
+ }
754
+ });
755
+ it('a failure that lasts is alerted again when its whole text changes, and again once the alert cooldown has passed', async () => {
756
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-lasting-text-'));
757
+ const strategy = new SayingStrategy();
758
+ const framework = await AgentFramework.create({
759
+ storePath: join(dir, 'store'),
760
+ membrane,
761
+ agents: [{ name: 'saying', model: 'test-model', systemPrompt: 'test', strategy }],
762
+ modules: [],
763
+ syncIntervalMs: 0,
764
+ maintenanceIntervalMs: 10,
765
+ });
766
+ const alerts = [];
767
+ framework.opsAlert = (_kind, _agentName, text) => alerts.push(text);
768
+ const internals = framework;
769
+ let passes = 0;
770
+ const runPass = internals.runQueuedMaintenance.bind(framework);
771
+ internals.runQueuedMaintenance = () => { passes++; return runPass(); };
772
+ const passed = async (n) => { const after = passes; await waitFor(() => passes >= after + n); };
773
+ const quiet = console.error;
774
+ console.error = () => { };
775
+ try {
776
+ framework.start();
777
+ await waitFor(() => alerts.length === 1);
778
+ await passed(3);
779
+ assert.deepEqual(alerts, ['the first refusal']);
780
+ // Two long texts that differ only in their middle, which the bounded
781
+ // text a run records leaves out: the alert follows the whole text.
782
+ const long = (middle) => `${'a'.repeat(3_000)} ${middle} ${'z'.repeat(3_000)}`;
783
+ strategy.says = long('one');
784
+ await waitFor(() => alerts.length === 2);
785
+ strategy.says = long('two');
786
+ await waitFor(() => alerts.length === 3);
787
+ await passed(3);
788
+ assert.equal(alerts.length, 3);
789
+ // Unchanged, it is alerted again once the cooldown has passed.
790
+ internals.opsAlertCooldownMs = 0;
791
+ await waitFor(() => alerts.length >= 5);
792
+ assert.ok(alerts.slice(3).every((text) => text === long('two')));
793
+ }
794
+ finally {
795
+ console.error = quiet;
796
+ await framework.stop();
797
+ rmSync(dir, { recursive: true, force: true });
798
+ }
799
+ });
800
+ it('a failure that lasts is posted to the webhook again a cooldown after its last post, whatever changed-text report came between', async () => {
801
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-webhook-reminder-'));
802
+ const strategy = new SayingStrategy();
803
+ const framework = await AgentFramework.create({
804
+ storePath: join(dir, 'store'),
805
+ membrane,
806
+ agents: [{ name: 'saying', model: 'test-model', systemPrompt: 'test', strategy }],
807
+ modules: [],
808
+ syncIntervalMs: 0,
809
+ maintenanceIntervalMs: 10,
810
+ });
811
+ const internals = framework;
812
+ let passes = 0;
813
+ const runPass = internals.runQueuedMaintenance.bind(framework);
814
+ internals.runQueuedMaintenance = () => { passes++; return runPass(); };
815
+ const passed = async (n) => { const after = passes; await waitFor(() => passes >= after + n); };
816
+ // The real opsAlert, with a webhook whose posts are recorded.
817
+ const posts = [];
818
+ const realFetch = globalThis.fetch;
819
+ globalThis.fetch = (async (_url, init) => {
820
+ posts.push(String(JSON.parse(String(init?.body)).content));
821
+ return new Response('{}');
822
+ });
823
+ const hook = process.env.CONNECTOME_OPS_WEBHOOK;
824
+ process.env.CONNECTOME_OPS_WEBHOOK = 'http://webhook.invalid/hook';
825
+ const realNow = Date.now;
826
+ const start = realNow();
827
+ let offset = 0;
828
+ Date.now = () => realNow() + offset;
829
+ const minute = 60_000;
830
+ const maintenancePosts = () => posts.filter((post) => post.includes('context-maintenance-failed'));
831
+ const quiet = console.error;
832
+ console.error = () => { };
833
+ try {
834
+ framework.start();
835
+ await waitFor(() => maintenancePosts().length === 1);
836
+ assert.match(maintenancePosts()[0], /the first refusal/);
837
+ // 14 minutes on, the text changes: it's reported, and the webhook,
838
+ // still within its cooldown, doesn't post it.
839
+ offset = 14 * minute;
840
+ strategy.says = 'the second refusal';
841
+ await passed(3);
842
+ assert.equal(maintenancePosts().length, 1);
843
+ // At 15 minutes the webhook's cooldown since its post has passed.
844
+ offset = 15 * minute + 1_000;
845
+ await waitFor(() => maintenancePosts().length === 2);
846
+ assert.match(maintenancePosts()[1], /the second refusal/);
847
+ assert.ok(Date.now() - start < 16 * minute);
848
+ }
849
+ finally {
850
+ console.error = quiet;
851
+ Date.now = realNow;
852
+ globalThis.fetch = realFetch;
853
+ if (hook === undefined)
854
+ delete process.env.CONNECTOME_OPS_WEBHOOK;
855
+ else
856
+ process.env.CONNECTOME_OPS_WEBHOOK = hook;
857
+ await framework.stop();
858
+ rmSync(dir, { recursive: true, force: true });
859
+ }
860
+ });
861
+ it('a readiness refusal read after an agent\'s last tick is that agent\'s failure, alerted once', async () => {
862
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-refused-after-ticks-'));
863
+ const strategy = new RefusesAfterTicksStrategy();
864
+ const framework = await AgentFramework.create({
865
+ storePath: join(dir, 'store'),
866
+ membrane,
867
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
868
+ modules: [],
869
+ syncIntervalMs: 0,
870
+ // start() runs one pass at once; none follows within the test.
871
+ maintenanceIntervalMs: 60_000,
872
+ });
873
+ const alerts = [];
874
+ framework.opsAlert = (kind, agentName, message) => alerts.push(`${kind}:${agentName}:${message}`);
875
+ try {
876
+ framework.start();
877
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
878
+ const run = firstPass(framework).agents[0];
879
+ assert.equal(run.ticks, 8);
880
+ assert.equal(run.error, 'readiness refused after the ticks');
881
+ assert.equal(run.readyAfter, undefined);
882
+ assert.deepEqual(alerts, ['context-maintenance-failed:agent:readiness refused after the ticks']);
883
+ }
884
+ finally {
885
+ await framework.stop();
886
+ rmSync(dir, { recursive: true, force: true });
887
+ }
888
+ });
889
+ it('a run records an agent\'s first failure: a readiness refusal after a failed tick isn\'t reported again', async () => {
890
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-first-failure-'));
891
+ const strategy = new RefusesAfterTicksStrategy();
892
+ strategy.failTick = true;
893
+ const framework = await AgentFramework.create({
894
+ storePath: join(dir, 'store'),
895
+ membrane,
896
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
897
+ modules: [],
898
+ syncIntervalMs: 0,
899
+ maintenanceIntervalMs: 60_000,
900
+ });
901
+ const alerts = [];
902
+ framework.opsAlert = (kind, agentName, message) => alerts.push(`${kind}:${agentName}:${message}`);
903
+ try {
904
+ framework.start();
905
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
906
+ const run = firstPass(framework).agents[0];
907
+ assert.equal(run.ticks, 0);
908
+ assert.equal(run.error, 'tick failed');
909
+ assert.deepEqual(alerts, ['context-maintenance-failed:agent:tick failed']);
910
+ }
911
+ finally {
912
+ await framework.stop();
913
+ rmSync(dir, { recursive: true, force: true });
914
+ }
915
+ });
916
+ for (const surgery of [
917
+ { verb: 'roll back', run: (framework, first) => framework.rollbackToMessage('agent', { messageId: first }) },
918
+ { verb: 'suppress', run: (framework, first) => framework.suppressMessages('agent', { messageIds: [first] }) },
919
+ {
920
+ verb: 'switch branch',
921
+ who: 'every agent',
922
+ run: (framework, first) => {
923
+ framework.getAgent('agent').getContextManager().branchAt(first, 'elsewhere');
924
+ return framework.switchBranch('elsewhere');
925
+ },
926
+ },
927
+ ]) {
928
+ it(`a ${surgery.verb} that begins during a tick waits for it before it moves the branch, and resume refuses while the ${surgery.verb} holds the store`, { timeout: 10_000 }, async () => {
929
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-surgery-waits-'));
930
+ const strategy = new GatedTickStrategy();
931
+ const framework = await AgentFramework.create({
932
+ storePath: join(dir, 'store'),
933
+ membrane,
934
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
935
+ modules: [],
936
+ syncIntervalMs: 0,
937
+ maintenanceIntervalMs: 10,
938
+ });
939
+ strategy.framework = framework;
940
+ const cm = framework.getAgent('agent').getContextManager();
941
+ const first = String(cm.addMessage('user', [{ type: 'text', text: 'm1' }]));
942
+ cm.addMessage('user', [{ type: 'text', text: 'm2' }]);
943
+ const internals = framework;
944
+ try {
945
+ await framework.quiesce();
946
+ framework.start();
947
+ await waitFor(() => strategy.ticks === 1);
948
+ const moved = surgery.run(framework, first);
949
+ await waitFor(() => internals.surgeryHold !== null);
950
+ await assert.rejects(framework.resume(), (error) => error instanceof OperatorActionError && error.code === 'agent-busy'
951
+ && error.message.includes(`a live ${surgery.verb} for ${surgery.who ?? 'agent'} holds the store`));
952
+ assert.equal(cm.currentBranch().name, 'main', `the ${surgery.verb} waits for the running tick`);
953
+ strategy.release();
954
+ await moved;
955
+ assert.equal(strategy.branchAtFirstTickEnd, 'main');
956
+ assert.equal(strategy.ticksUnderHold, 0, `the pass starts no tick while the ${surgery.verb} holds the store`);
957
+ assert.notEqual(cm.currentBranch().name, 'main');
958
+ const status = await framework.resume();
959
+ assert.equal(status.quiesced, false);
960
+ }
961
+ finally {
962
+ strategy.release();
963
+ await framework.stop();
964
+ rmSync(dir, { recursive: true, force: true });
965
+ }
966
+ });
967
+ }
968
+ for (const surgery of [
969
+ { verb: 'roll back', run: (framework, first) => framework.rollbackToMessage('agent', { messageId: first }) },
970
+ { verb: 'suppress', run: (framework, first) => framework.suppressMessages('agent', { messageIds: [first] }) },
971
+ {
972
+ verb: 'switch branch',
973
+ run: (framework, first) => {
974
+ framework.getAgent('agent').getContextManager().branchAt(first, 'elsewhere');
975
+ return framework.switchBranch('elsewhere');
976
+ },
977
+ },
978
+ ]) {
979
+ it(`a ${surgery.verb} withdraws a maintenance call parked at the provider gate rather than waiting on it, and that is no failure`, { timeout: 10_000 }, async () => {
980
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-parked-call-'));
981
+ const strategy = new ParkedCallTickStrategy();
982
+ const framework = await AgentFramework.create({
983
+ storePath: join(dir, 'store'),
984
+ membrane,
985
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
986
+ modules: [],
987
+ syncIntervalMs: 0,
988
+ maintenanceIntervalMs: 10,
989
+ });
990
+ strategy.framework = framework;
991
+ const alerts = [];
992
+ framework.opsAlert = (kind, agentName, message) => alerts.push(`${kind}:${agentName}:${message}`);
993
+ const cm = framework.getAgent('agent').getContextManager();
994
+ const first = String(cm.addMessage('user', [{ type: 'text', text: 'm1' }]));
995
+ cm.addMessage('user', [{ type: 'text', text: 'm2' }]);
996
+ const internals = framework;
997
+ const quiet = console.error;
998
+ console.error = () => { };
999
+ try {
1000
+ await framework.quiesce();
1001
+ framework.start();
1002
+ await waitFor(() => internals.providerGate('agent').auxiliaryWaiters.length === 1);
1003
+ const moved = surgery.run(framework, first);
1004
+ const outcome = await Promise.race([
1005
+ moved.then(() => 'moved'),
1006
+ new Promise((resolve) => setTimeout(() => resolve(`the ${surgery.verb} still waits`), 3_000)),
1007
+ ]);
1008
+ assert.equal(outcome, 'moved');
1009
+ assert.equal(strategy.outcome, 'MaintenanceWithdrawnError');
1010
+ assert.equal(strategy.calls, 0, 'the withdrawn call sent nothing');
1011
+ assert.notEqual(cm.currentBranch().name, 'main');
1012
+ assert.deepEqual(alerts.filter((alert) => alert.startsWith('context-maintenance-failed')), []);
1013
+ const run = firstPass(framework).agents[0];
1014
+ assert.equal(run.error, undefined);
1015
+ }
1016
+ finally {
1017
+ console.error = quiet;
1018
+ await framework.stop();
1019
+ rmSync(dir, { recursive: true, force: true });
1020
+ }
1021
+ });
1022
+ }
1023
+ it('a maintenance provider call made while a surgery holds the store is withdrawn before it is sent, with the gate closed too', async () => {
1024
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-withdrawn-'));
1025
+ const framework = await AgentFramework.create({
1026
+ storePath: join(dir, 'store'),
1027
+ membrane,
1028
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new GatedTickStrategy() }],
1029
+ modules: [],
1030
+ syncIntervalMs: 0,
1031
+ maintenanceIntervalMs: 0,
1032
+ });
1033
+ const internals = framework;
1034
+ try {
1035
+ for (const closed of [false, true]) {
1036
+ let ran = false;
1037
+ const release = internals.reserveStoreForSurgery('roll back', 'agent');
1038
+ // Closed: a cooldown's retry turn is queued, which the hold keeps from
1039
+ // starting, so a call that parked would never be woken.
1040
+ internals.providerGate('agent').primaryPending = closed;
1041
+ try {
1042
+ const admitted = internals.withAuxiliaryAdmission('agent', async () => { ran = true; });
1043
+ void admitted.catch(() => { });
1044
+ const outcome = await Promise.race([
1045
+ admitted.then(() => 'admitted', (error) => error),
1046
+ new Promise((resolve) => setTimeout(() => resolve('parked'), 1_000)),
1047
+ ]);
1048
+ assert.ok(outcome instanceof Error && outcome.name === 'MaintenanceWithdrawnError'
1049
+ && outcome.message === 'maintenance call withdrawn: a live roll back for agent holds the store', `${closed ? 'gate closed' : 'gate open'}: ${outcome instanceof Error ? outcome.message : String(outcome)}`);
1050
+ assert.equal(ran, false);
1051
+ }
1052
+ finally {
1053
+ internals.providerGate('agent').primaryPending = false;
1054
+ release();
1055
+ }
1056
+ }
1057
+ assert.equal(await internals.withAuxiliaryAdmission('agent', async () => 7), 7, 'admitted again once the hold is released');
1058
+ }
1059
+ finally {
1060
+ await framework.stop();
1061
+ rmSync(dir, { recursive: true, force: true });
1062
+ }
1063
+ });
1064
+ it('a parked maintenance call woken while its gate is still closed parks again, and goes once the gate opens', async () => {
1065
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-parks-again-'));
1066
+ const framework = await AgentFramework.create({
1067
+ storePath: join(dir, 'store'),
1068
+ membrane,
1069
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new GatedTickStrategy() }],
1070
+ modules: [],
1071
+ syncIntervalMs: 0,
1072
+ maintenanceIntervalMs: 0,
1073
+ });
1074
+ const internals = framework;
1075
+ try {
1076
+ const gate = internals.providerGate('agent');
1077
+ gate.primaryPending = true;
1078
+ let ran = false;
1079
+ const call = internals.withAuxiliaryAdmission('agent', async () => { ran = true; return 7; });
1080
+ await waitFor(() => gate.auxiliaryWaiters.length === 1);
1081
+ for (const resolve of gate.auxiliaryWaiters.splice(0))
1082
+ resolve();
1083
+ await waitFor(() => gate.auxiliaryWaiters.length === 1);
1084
+ assert.equal(ran, false, 'woken with the gate still closed, it parked again');
1085
+ gate.primaryPending = false;
1086
+ internals.flushAuxiliaryAdmission('agent');
1087
+ assert.equal(await call, 7);
1088
+ assert.equal(ran, true);
1089
+ }
1090
+ finally {
1091
+ await framework.stop();
1092
+ rmSync(dir, { recursive: true, force: true });
1093
+ }
1094
+ });
1095
+ for (const kind of ['resident', 'conversation agent']) {
1096
+ for (const held of [false, true]) {
1097
+ it(`a ${kind}'s tick that reaches its provider under a surgery's hold is withdrawn${held ? ', though a provider wait holds its model' : ''}`, { timeout: 10_000 }, async () => {
1098
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-surgery-withdrawn-'));
1099
+ const provider = new CapturingMembrane();
1100
+ const tick = new CallAfterGateStrategy();
1101
+ const conversational = kind === 'conversation agent';
1102
+ const framework = await AgentFramework.create({
1103
+ storePath: join(dir, 'store'),
1104
+ membrane: provider.asMembrane(),
1105
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: conversational ? new ReadyStrategy() : tick }],
1106
+ modules: [],
1107
+ syncIntervalMs: 0,
1108
+ maintenanceIntervalMs: 10,
1109
+ ...(conversational ? { conversations: { templateAgent: 'agent', strategyFactory: () => tick } } : {}),
1110
+ });
1111
+ const internals = framework;
1112
+ const name = conversational ? 'agent-conv-1' : 'agent';
1113
+ const alerts = [];
1114
+ framework.opsAlert = (kind, agentName) => alerts.push(`${kind}:${agentName}`);
1115
+ const quiet = console.error;
1116
+ console.error = () => { };
1117
+ try {
1118
+ if (conversational)
1119
+ await internals.createConversationAgent(name, 'chan-1');
1120
+ if (held)
1121
+ internals.recordProviderWait(name, 'test-model', heldFor(60_000));
1122
+ framework.start();
1123
+ await waitFor(() => tick.entered);
1124
+ const release = internals.reserveStoreForSurgery('roll back', 'agent');
1125
+ try {
1126
+ tick.release();
1127
+ await waitFor(() => tick.outcome !== undefined);
1128
+ assert.equal(tick.outcome, 'MaintenanceWithdrawnError');
1129
+ assert.equal(provider.calls.length, 0, 'the withdrawn call sent nothing');
1130
+ }
1131
+ finally {
1132
+ release();
1133
+ }
1134
+ assert.deepEqual(alerts.filter((alert) => alert.startsWith('context-maintenance-failed')), []);
1135
+ }
1136
+ finally {
1137
+ console.error = quiet;
1138
+ tick.release();
1139
+ await framework.stop();
1140
+ rmSync(dir, { recursive: true, force: true });
1141
+ }
1142
+ });
1143
+ }
1144
+ }
1145
+ it('a pass that only meets a failure already reported stays out of the history', { timeout: 10_000 }, async () => {
1146
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-repeats-'));
1147
+ const framework = await AgentFramework.create({
1148
+ storePath: join(dir, 'store'),
1149
+ membrane,
1150
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new FailingStrategy() }],
1151
+ modules: [],
1152
+ syncIntervalMs: 0,
1153
+ maintenanceIntervalMs: 10,
1154
+ });
1155
+ framework.opsAlert = () => { };
1156
+ const internals = framework;
1157
+ const quiet = console.error;
1158
+ console.error = () => { };
1159
+ try {
1160
+ framework.start();
1161
+ await waitFor(() => internals.maintenanceRunId >= 4);
1162
+ const history = framework.getContextMaintenanceSnapshot().history;
1163
+ assert.equal(history.length, 1, 'the first report only, though four passes met the failure');
1164
+ assert.equal(history[0].id, 1);
1165
+ assert.ok(history[0].agents[0].error);
1166
+ }
1167
+ finally {
1168
+ console.error = quiet;
1169
+ await framework.stop();
1170
+ rmSync(dir, { recursive: true, force: true });
1171
+ }
1172
+ });
1173
+ it("an operator's maintain keeps the pass it ran in the history, though the pass only met a failure already reported", { timeout: 10_000 }, async () => {
1174
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-requested-pass-'));
1175
+ const framework = await AgentFramework.create({
1176
+ storePath: join(dir, 'store'),
1177
+ membrane,
1178
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new FailingStrategy() }],
1179
+ modules: [],
1180
+ syncIntervalMs: 0,
1181
+ maintenanceIntervalMs: 0,
1182
+ });
1183
+ framework.opsAlert = () => { };
1184
+ const quiet = console.error;
1185
+ console.error = () => { };
1186
+ try {
1187
+ framework.start();
1188
+ const first = await framework.maintenanceTick();
1189
+ assert.equal(first.ran, true);
1190
+ const reported = first.history[0].id;
1191
+ assert.ok(first.history[0].agents[0].error, 'the first pass reports the failure');
1192
+ const second = await framework.maintenanceTick();
1193
+ assert.equal(second.ran, true);
1194
+ assert.ok(second.history[0].id > reported, 'the newest run is the pass the second maintain ran');
1195
+ assert.ok(second.history[0].agents[0].error);
1196
+ }
1197
+ finally {
1198
+ console.error = quiet;
1199
+ await framework.stop();
1200
+ rmSync(dir, { recursive: true, force: true });
1201
+ }
1202
+ });
1203
+ it("a maintain whose pass had nothing to run leaves no request for the next pass", { timeout: 10_000 }, async () => {
1204
+ // Gordon-2414's probe. While the agent's own call holds its provider gate,
1205
+ // a maintain's pass queues nothing and records no run, so the next pass,
1206
+ // which nobody asked for and which meets only the failure already
1207
+ // reported, stays out of the history.
1208
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-request-reset-'));
1209
+ const strategy = new FailingStrategy();
1210
+ let ticks = 0;
1211
+ const failingTick = strategy.tick.bind(strategy);
1212
+ strategy.tick = async (ctx) => { ticks++; return failingTick(ctx); };
1213
+ const framework = await AgentFramework.create({
1214
+ storePath: join(dir, 'store'),
1215
+ membrane,
1216
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1217
+ modules: [],
1218
+ syncIntervalMs: 0,
1219
+ maintenanceIntervalMs: 0,
1220
+ });
1221
+ const internals = framework;
1222
+ framework.opsAlert = () => { };
1223
+ const quiet = console.error;
1224
+ const quietWarn = console.warn;
1225
+ console.error = () => { };
1226
+ console.warn = () => { };
1227
+ try {
1228
+ framework.start();
1229
+ const first = await framework.maintenanceTick();
1230
+ assert.equal(first.history.length, 1, 'the first maintain reports the failure');
1231
+ internals.acquirePrimaryProviderGate('agent');
1232
+ const second = await framework.maintenanceTick();
1233
+ assert.equal(second.ran, true);
1234
+ assert.equal(second.history.length, 1, 'a pass with nothing queued records no run');
1235
+ assert.equal(ticks, 1, 'the second maintain ran no tick');
1236
+ internals.releasePrimaryProviderGate('agent');
1237
+ internals.startQueuedMaintenance();
1238
+ await internals.maintenancePass;
1239
+ assert.equal(ticks, 2, 'the pass nobody asked for ran its tick');
1240
+ assert.equal(framework.getContextMaintenanceSnapshot().history.length, 1, 'the repeat nobody asked for stays out of the history');
1241
+ }
1242
+ finally {
1243
+ console.error = quiet;
1244
+ console.warn = quietWarn;
1245
+ await framework.stop();
1246
+ rmSync(dir, { recursive: true, force: true });
1247
+ }
1248
+ });
1249
+ it("a maintain that joins a pass already running keeps that pass in the history", { timeout: 10_000 }, async () => {
1250
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-request-join-'));
1251
+ const strategy = new GatedRepeatFailureStrategy();
1252
+ const framework = await AgentFramework.create({
1253
+ storePath: join(dir, 'store'),
1254
+ membrane,
1255
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1256
+ modules: [],
1257
+ syncIntervalMs: 0,
1258
+ maintenanceIntervalMs: 0,
1259
+ });
1260
+ const internals = framework;
1261
+ framework.opsAlert = () => { };
1262
+ const quiet = console.error;
1263
+ const quietWarn = console.warn;
1264
+ console.error = () => { };
1265
+ console.warn = () => { };
1266
+ try {
1267
+ framework.start();
1268
+ const first = await framework.maintenanceTick();
1269
+ assert.equal(first.history.length, 1, 'the first maintain reports the failure');
1270
+ // A pass nobody asked for, whose tick waits on the gate.
1271
+ internals.startQueuedMaintenance();
1272
+ await waitFor(() => strategy.ticks === 2);
1273
+ // The maintain joins it, and the pass then meets the failure already reported.
1274
+ const joining = framework.maintenanceTick();
1275
+ strategy.release();
1276
+ const joined = await joining;
1277
+ assert.equal(joined.ran, true);
1278
+ assert.equal(strategy.ticks, 2, 'the maintain started no pass of its own');
1279
+ assert.equal(joined.history.length, 2, 'the pass the maintain joined is kept');
1280
+ assert.ok(joined.history[0].id > first.history[0].id);
1281
+ }
1282
+ finally {
1283
+ console.error = quiet;
1284
+ console.warn = quietWarn;
1285
+ strategy.release();
1286
+ await framework.stop();
1287
+ rmSync(dir, { recursive: true, force: true });
1288
+ }
1289
+ });
1290
+ it('a pass that took a tick before meeting a failure already reported stays in the history', { timeout: 10_000 }, async () => {
1291
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-repeats-after-work-'));
1292
+ const framework = await AgentFramework.create({
1293
+ storePath: join(dir, 'store'),
1294
+ membrane,
1295
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new WorkThenFailStrategy() }],
1296
+ modules: [],
1297
+ syncIntervalMs: 0,
1298
+ maintenanceIntervalMs: 10,
1299
+ });
1300
+ framework.opsAlert = () => { };
1301
+ const internals = framework;
1302
+ const quiet = console.error;
1303
+ console.error = () => { };
1304
+ try {
1305
+ framework.start();
1306
+ await waitFor(() => internals.maintenanceRunId >= 4);
1307
+ const history = framework.getContextMaintenanceSnapshot().history;
1308
+ assert.ok(history.length >= 3, `each finished pass did work, so each is kept (${history.length})`);
1309
+ for (const run of history) {
1310
+ assert.equal(run.agents[0].ticks, 1);
1311
+ assert.ok(run.agents[0].error);
1312
+ }
1313
+ }
1314
+ finally {
1315
+ console.error = quiet;
1316
+ await framework.stop();
1317
+ rmSync(dir, { recursive: true, force: true });
1318
+ }
1319
+ });
1320
+ for (const kind of ['resident', 'conversation agent'])
1321
+ for (const held of [false, true]) {
1322
+ it(`a ${kind}'s maintenance call made once stop() has begun is withdrawn${held ? ', though a provider wait holds its model,' : ''} and no failure is reported`, { timeout: 10_000 }, async () => {
1323
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-stopping-'));
1324
+ const provider = new CapturingMembrane();
1325
+ const tick = new CallAfterGateStrategy();
1326
+ const conversational = kind === 'conversation agent';
1327
+ const framework = await AgentFramework.create({
1328
+ storePath: join(dir, 'store'),
1329
+ membrane: provider.asMembrane(),
1330
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: conversational ? new ReadyStrategy() : tick }],
1331
+ modules: [],
1332
+ syncIntervalMs: 0,
1333
+ maintenanceIntervalMs: 10,
1334
+ ...(conversational ? { conversations: { templateAgent: 'agent', strategyFactory: () => tick } } : {}),
1335
+ });
1336
+ const internals = framework;
1337
+ const name = conversational ? 'agent-conv-1' : 'agent';
1338
+ const alerts = [];
1339
+ framework.opsAlert = (...args) => { alerts.push(args.map(String).join(' ')); };
1340
+ const quiet = console.error;
1341
+ const logged = [];
1342
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1343
+ let stopping;
1344
+ try {
1345
+ if (conversational)
1346
+ await internals.createConversationAgent(name, 'chan-1');
1347
+ if (held)
1348
+ internals.recordProviderWait(name, 'test-model', heldFor(60_000));
1349
+ framework.start();
1350
+ await waitFor(() => tick.entered);
1351
+ stopping = framework.stop();
1352
+ tick.release();
1353
+ await waitFor(() => tick.outcome !== undefined);
1354
+ await stopping;
1355
+ assert.equal(tick.outcome, 'MaintenanceWithdrawnError');
1356
+ assert.equal(provider.calls.length, 0, 'the withdrawn call sent nothing');
1357
+ assert.ok(logged.some((line) => line.includes(`[context-maintenance] agent=${name} stopped: maintenance call withdrawn: provider admission closed while stopping (${name})`)), logged.join('\n'));
1358
+ assert.deepEqual(alerts, [], 'no alert');
1359
+ for (const run of framework.getContextMaintenanceSnapshot().history) {
1360
+ for (const record of run.agents)
1361
+ assert.equal(record.error, undefined, `no failure recorded for ${record.agentName}`);
1362
+ }
1363
+ }
1364
+ finally {
1365
+ console.error = quiet;
1366
+ tick.release();
1367
+ await (stopping ?? framework.stop());
1368
+ rmSync(dir, { recursive: true, force: true });
1369
+ }
1370
+ });
1371
+ }
1372
+ it('stop() withdraws a maintenance call parked at the provider gate, and that is no failure', { timeout: 10_000 }, async () => {
1373
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-parked-stop-'));
1374
+ const strategy = new ParkedCallTickStrategy();
1375
+ const framework = await AgentFramework.create({
1376
+ storePath: join(dir, 'store'),
1377
+ membrane,
1378
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1379
+ modules: [],
1380
+ syncIntervalMs: 0,
1381
+ maintenanceIntervalMs: 10,
1382
+ });
1383
+ strategy.framework = framework;
1384
+ const alerts = [];
1385
+ framework.opsAlert = (...args) => { alerts.push(args.map(String).join(' ')); };
1386
+ const internals = framework;
1387
+ const quiet = console.error;
1388
+ console.error = () => { };
1389
+ try {
1390
+ framework.start();
1391
+ await waitFor(() => internals.providerGate('agent').auxiliaryWaiters.length === 1);
1392
+ await framework.stop();
1393
+ assert.equal(strategy.outcome, 'MaintenanceWithdrawnError');
1394
+ assert.equal(strategy.calls, 0, 'the withdrawn call sent nothing');
1395
+ assert.deepEqual(alerts, []);
1396
+ }
1397
+ finally {
1398
+ console.error = quiet;
1399
+ rmSync(dir, { recursive: true, force: true });
1400
+ }
1401
+ });
1402
+ it('an over-budget drain kick starts in a framework that was never started, as one run with runUntilIdle() is', async () => {
1403
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-unstarted-'));
1404
+ const framework = await AgentFramework.create({
1405
+ storePath: join(dir, 'store'),
1406
+ membrane,
1407
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new ReadyStrategy() }],
1408
+ modules: [],
1409
+ syncIntervalMs: 0,
1410
+ maintenanceIntervalMs: 0,
1411
+ });
1412
+ const internals = framework;
1413
+ const alerts = [];
1414
+ framework.opsAlert = (...args) => { alerts.push(args.map(String).join(' ')); };
1415
+ const quiet = console.error;
1416
+ const logged = [];
1417
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1418
+ try {
1419
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1420
+ assert.equal(internals.overBudgetDrains.size, 1, 'a kick was started');
1421
+ assert.ok(alerts.some((alert) => alert.includes('drain breaker kicking;')), alerts.join('\n'));
1422
+ await Promise.all([...internals.overBudgetDrains]);
1423
+ assert.ok(logged.some((line) => line.includes('drain kicked for agent (OverBudget breaker, 8 ticks)')), logged.join('\n'));
1424
+ }
1425
+ finally {
1426
+ console.error = quiet;
1427
+ await framework.stop();
1428
+ rmSync(dir, { recursive: true, force: true });
1429
+ }
1430
+ });
1431
+ it("a stop() begun from inside the refusal's alert waits for the kick the alert reports, which ticks none", { timeout: 10_000 }, async () => {
1432
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-stop-in-alert-'));
1433
+ const strategy = new GatedTickStrategy();
1434
+ const framework = await AgentFramework.create({
1435
+ storePath: join(dir, 'store'),
1436
+ membrane,
1437
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1438
+ modules: [],
1439
+ syncIntervalMs: 0,
1440
+ maintenanceIntervalMs: 0,
1441
+ });
1442
+ strategy.framework = framework;
1443
+ const internals = framework;
1444
+ const alerts = [];
1445
+ let drainsAtAlert = -1;
1446
+ let stopping;
1447
+ // An ops:alert listener that stops the framework, as the alert is raised.
1448
+ framework.opsAlert = (kind, _agentName, message) => {
1449
+ if (kind !== 'context-refusal')
1450
+ return;
1451
+ alerts.push(message);
1452
+ drainsAtAlert = internals.overBudgetDrains.size;
1453
+ stopping ??= framework.stop();
1454
+ };
1455
+ const logged = [];
1456
+ const quiet = console.error;
1457
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1458
+ try {
1459
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1460
+ assert.ok(stopping, 'the alert began stop()');
1461
+ assert.match(alerts[0] ?? '', /drain breaker kicking;/);
1462
+ assert.equal(drainsAtAlert, 1, 'the kick joined the drains stop() waits for before the alert');
1463
+ await stopping;
1464
+ assert.equal(strategy.ticks, 0, 'the kick ticked none');
1465
+ assert.ok(logged.some((line) => line.includes('drain kicked for agent (OverBudget breaker, 0 ticks, stopped as the framework stops)')), 'the kick says it stopped, and why');
1466
+ }
1467
+ finally {
1468
+ console.error = quiet;
1469
+ strategy.release();
1470
+ await (stopping ?? framework.stop());
1471
+ rmSync(dir, { recursive: true, force: true });
1472
+ }
1473
+ });
1474
+ it("a surgery begun from inside the refusal's alert waits for the kick the alert reports, which ticks none", { timeout: 10_000 }, async () => {
1475
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-surgery-in-alert-'));
1476
+ const strategy = new GatedTickStrategy();
1477
+ const framework = await AgentFramework.create({
1478
+ storePath: join(dir, 'store'),
1479
+ membrane,
1480
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1481
+ modules: [],
1482
+ syncIntervalMs: 0,
1483
+ maintenanceIntervalMs: 0,
1484
+ });
1485
+ strategy.framework = framework;
1486
+ const cm = framework.getAgent('agent').getContextManager();
1487
+ const first = String(cm.addMessage('user', [{ type: 'text', text: 'm1' }]));
1488
+ cm.addMessage('user', [{ type: 'text', text: 'm2' }]);
1489
+ const internals = framework;
1490
+ const alerts = [];
1491
+ let drainsAtAlert = -1;
1492
+ let moving;
1493
+ // An ops:alert listener that rolls the agent back, as the alert is raised.
1494
+ framework.opsAlert = (kind, _agentName, message) => {
1495
+ if (kind !== 'context-refusal')
1496
+ return;
1497
+ alerts.push(message);
1498
+ drainsAtAlert = internals.overBudgetDrains.size;
1499
+ moving ??= framework.rollbackToMessage('agent', { messageId: first });
1500
+ };
1501
+ const logged = [];
1502
+ const quiet = console.error;
1503
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1504
+ try {
1505
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1506
+ assert.ok(moving, 'the alert began a rollback');
1507
+ assert.match(alerts[0] ?? '', /drain breaker kicking;/);
1508
+ assert.equal(drainsAtAlert, 1, 'the kick joined the drains the rollback waits for before the alert');
1509
+ await moving;
1510
+ assert.equal(strategy.ticks, 0, 'the kick ticked none');
1511
+ assert.ok(logged.some((line) => line.includes('drain kicked for agent (OverBudget breaker, 0 ticks, stopped while a surgery holds the store)')), 'the kick says it stopped, and why');
1512
+ assert.notEqual(cm.currentBranch().name, 'main', 'the rollback went on once the kick ended');
1513
+ }
1514
+ finally {
1515
+ console.error = quiet;
1516
+ strategy.release();
1517
+ await framework.stop();
1518
+ rmSync(dir, { recursive: true, force: true });
1519
+ }
1520
+ });
1521
+ it("a stop() made from a drain's first tick, before that tick's first await, waits for the tick", { timeout: 10_000 }, async () => {
1522
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-stop-in-tick-'));
1523
+ const strategy = new StopInTickStrategy();
1524
+ const framework = await AgentFramework.create({
1525
+ storePath: join(dir, 'store'),
1526
+ membrane,
1527
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1528
+ modules: [],
1529
+ syncIntervalMs: 0,
1530
+ maintenanceIntervalMs: 0,
1531
+ });
1532
+ strategy.framework = framework;
1533
+ const internals = framework;
1534
+ framework.opsAlert = () => { };
1535
+ const quiet = console.error;
1536
+ console.error = () => { };
1537
+ try {
1538
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1539
+ await waitFor(() => strategy.stopping !== undefined);
1540
+ await strategy.stopping;
1541
+ assert.equal(strategy.stoppedBeforeTickEnd, false, 'stop() waited for the tick');
1542
+ assert.equal(strategy.storeOpenAtTickEnd, true, 'the tick ended before the store closed');
1543
+ }
1544
+ finally {
1545
+ console.error = quiet;
1546
+ await (strategy.stopping ?? framework.stop());
1547
+ rmSync(dir, { recursive: true, force: true });
1548
+ }
1549
+ });
1550
+ it('no over-budget drain kick starts once stop() has begun', async () => {
1551
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-after-stop-'));
1552
+ const framework = await AgentFramework.create({
1553
+ storePath: join(dir, 'store'),
1554
+ membrane,
1555
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new ReadyStrategy() }],
1556
+ modules: [],
1557
+ syncIntervalMs: 0,
1558
+ maintenanceIntervalMs: 0,
1559
+ });
1560
+ const internals = framework;
1561
+ const alerts = [];
1562
+ framework.opsAlert = (...args) => { alerts.push(args.map(String).join(' ')); };
1563
+ const quiet = console.error;
1564
+ const logged = [];
1565
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1566
+ try {
1567
+ framework.start();
1568
+ const stopping = framework.stop();
1569
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1570
+ assert.equal(internals.overBudgetDrains.size, 0, 'no kick was started');
1571
+ assert.ok(alerts.some((alert) => alert.startsWith('context-refusal agent ')
1572
+ && alert.includes('drain breaker not kicked: the framework is stopping;')), alerts.join('\n'));
1573
+ await stopping;
1574
+ assert.ok(!logged.some((line) => line.includes('drain kicked')), logged.join('\n'));
1575
+ }
1576
+ finally {
1577
+ console.error = quiet;
1578
+ rmSync(dir, { recursive: true, force: true });
1579
+ }
1580
+ });
1581
+ it('a surgery that takes the store while resume waits for a tick is refused by resume after the wait', { timeout: 10_000 }, async () => {
1582
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-resume-rechecks-'));
1583
+ const strategy = new GatedTickStrategy();
1584
+ const framework = await AgentFramework.create({
1585
+ storePath: join(dir, 'store'),
1586
+ membrane,
1587
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1588
+ modules: [],
1589
+ syncIntervalMs: 0,
1590
+ maintenanceIntervalMs: 10,
1591
+ });
1592
+ strategy.framework = framework;
1593
+ const cm = framework.getAgent('agent').getContextManager();
1594
+ const first = String(cm.addMessage('user', [{ type: 'text', text: 'm1' }]));
1595
+ cm.addMessage('user', [{ type: 'text', text: 'm2' }]);
1596
+ const internals = framework;
1597
+ try {
1598
+ await framework.quiesce();
1599
+ framework.start();
1600
+ await waitFor(() => strategy.ticks === 1);
1601
+ // resume() finds no surgery and waits for the running tick; the
1602
+ // rollback takes the store meanwhile.
1603
+ const resuming = framework.resume();
1604
+ void resuming.catch(() => { });
1605
+ const moved = framework.rollbackToMessage('agent', { messageId: first });
1606
+ await waitFor(() => internals.surgeryHold !== null);
1607
+ strategy.release();
1608
+ await assert.rejects(resuming, (error) => error instanceof OperatorActionError && error.code === 'agent-busy'
1609
+ && error.message.includes('a live roll back for agent holds the store'));
1610
+ assert.equal(framework.getHostModeStatus().quiesced, true, 'the host stays quiesced');
1611
+ await moved;
1612
+ }
1613
+ finally {
1614
+ strategy.release();
1615
+ await framework.stop();
1616
+ rmSync(dir, { recursive: true, force: true });
1617
+ }
1618
+ });
1619
+ it('a rollback waits for an over-budget drain kick\'s tick in flight, and the kick ticks no more under the hold', { timeout: 10_000 }, async () => {
1620
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-hold-'));
1621
+ const strategy = new GatedTickStrategy();
1622
+ const framework = await AgentFramework.create({
1623
+ storePath: join(dir, 'store'),
1624
+ membrane,
1625
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1626
+ modules: [],
1627
+ syncIntervalMs: 0,
1628
+ maintenanceIntervalMs: 0,
1629
+ });
1630
+ strategy.framework = framework;
1631
+ const cm = framework.getAgent('agent').getContextManager();
1632
+ const first = String(cm.addMessage('user', [{ type: 'text', text: 'm1' }]));
1633
+ cm.addMessage('user', [{ type: 'text', text: 'm2' }]);
1634
+ const internals = framework;
1635
+ const quiet = console.error;
1636
+ console.error = () => { };
1637
+ try {
1638
+ // A refused compile kicks the drain, whose first tick waits on the gate.
1639
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1640
+ await waitFor(() => strategy.ticks === 1);
1641
+ const moved = framework.rollbackToMessage('agent', { messageId: first });
1642
+ await waitFor(() => internals.surgeryHold !== null);
1643
+ assert.equal(cm.currentBranch().name, 'main', 'the rollback waits for the drain kick\'s tick');
1644
+ strategy.release();
1645
+ await moved;
1646
+ assert.equal(strategy.branchAtFirstTickEnd, 'main');
1647
+ assert.equal(strategy.ticksUnderHold, 0, 'the kick starts no tick while the rollback holds the store');
1648
+ assert.notEqual(cm.currentBranch().name, 'main');
1649
+ }
1650
+ finally {
1651
+ console.error = quiet;
1652
+ strategy.release();
1653
+ await framework.stop();
1654
+ rmSync(dir, { recursive: true, force: true });
1655
+ }
1656
+ });
1657
+ it("stop() waits for an over-budget drain kick's tick in flight, and the kick ticks no more once stop() has begun", { timeout: 10_000 }, async () => {
1658
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-drain-stop-'));
1659
+ const strategy = new GatedTickStrategy();
1660
+ const framework = await AgentFramework.create({
1661
+ storePath: join(dir, 'store'),
1662
+ membrane,
1663
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1664
+ modules: [],
1665
+ syncIntervalMs: 0,
1666
+ maintenanceIntervalMs: 0,
1667
+ });
1668
+ strategy.framework = framework;
1669
+ const internals = framework;
1670
+ const quiet = console.error;
1671
+ console.error = () => { };
1672
+ let stopped = false;
1673
+ try {
1674
+ framework.start();
1675
+ // A refused compile kicks the drain, whose first tick waits on the gate.
1676
+ internals.noteInferenceExhausted('agent', 'compile refused', false, 'over_budget');
1677
+ await waitFor(() => strategy.ticks === 1);
1678
+ const stopping = framework.stop().then(() => { stopped = true; });
1679
+ // A stop() that didn't wait would close the store with the tick held:
1680
+ // it gets half a second to.
1681
+ await waitFor(() => stopped, 500).catch(() => { });
1682
+ assert.equal(stopped, false, 'stop() waits for the kick\'s tick in flight');
1683
+ strategy.release();
1684
+ await stopping;
1685
+ assert.equal(strategy.storeOpenAtFirstTickEnd, true, 'the tick in flight ended before the store closed');
1686
+ assert.equal(strategy.ticks, 1, 'the kick starts no tick once stop() has begun');
1687
+ }
1688
+ finally {
1689
+ console.error = quiet;
1690
+ strategy.release();
1691
+ if (!stopped)
1692
+ await framework.stop();
1693
+ rmSync(dir, { recursive: true, force: true });
1694
+ }
1695
+ });
1696
+ it('no pass starts while a surgery holds the store', async () => {
1697
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-surgery-hold-'));
1698
+ const strategy = new QueuedStrategy();
1699
+ const framework = await AgentFramework.create({
1700
+ storePath: join(dir, 'store'),
1701
+ membrane,
1702
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1703
+ modules: [],
1704
+ syncIntervalMs: 0,
1705
+ maintenanceIntervalMs: 10,
1706
+ });
1707
+ const internals = framework;
1708
+ internals.surgeryHold = { verb: 'rollback', agentName: 'agent', since: Date.now() };
1709
+ // Count the passes the timer starts while the store is held.
1710
+ let passes = 0;
1711
+ const runPass = internals.runQueuedMaintenance.bind(framework);
1712
+ internals.runQueuedMaintenance = () => { passes++; return runPass(); };
1713
+ try {
1714
+ framework.start();
1715
+ await waitFor(() => passes >= 3);
1716
+ assert.equal(strategy.ticks, 0);
1717
+ assert.equal(framework.getContextMaintenanceSnapshot().history.length, 0);
1718
+ internals.surgeryHold = null;
1719
+ await waitFor(() => strategy.ticks >= 2);
1720
+ }
1721
+ finally {
1722
+ internals.surgeryHold = null;
1723
+ await framework.stop();
1724
+ rmSync(dir, { recursive: true, force: true });
1725
+ }
1726
+ });
1727
+ it('a pass starts no new tick once a surgery holds the store', async () => {
1728
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-surgery-mid-pass-'));
1729
+ const strategy = new HookedTickStrategy();
1730
+ const framework = await AgentFramework.create({
1731
+ storePath: join(dir, 'store'),
1732
+ membrane,
1733
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1734
+ modules: [],
1735
+ syncIntervalMs: 0,
1736
+ maintenanceIntervalMs: 10,
1737
+ });
1738
+ const internals = framework;
1739
+ const alerts = [];
1740
+ framework.opsAlert = (kind) => alerts.push(kind);
1741
+ strategy.onTick = () => {
1742
+ internals.surgeryHold = { verb: 'rollback', agentName: 'agent', since: Date.now() };
1743
+ };
1744
+ try {
1745
+ framework.start();
1746
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
1747
+ const run = firstPass(framework).agents[0];
1748
+ assert.equal(strategy.ticks, 1);
1749
+ assert.equal(run.ticks, 1);
1750
+ assert.equal(run.error, undefined);
1751
+ // The surgery waits for the pass before it switches, so reading the
1752
+ // agent's readiness after its tick is safe.
1753
+ assert.equal(run.readyAfter, false);
1754
+ assert.equal(run.pendingAfter, 'hooked tick queued');
1755
+ assert.deepEqual(alerts, []);
1756
+ }
1757
+ finally {
1758
+ internals.surgeryHold = null;
1759
+ await framework.stop();
1760
+ rmSync(dir, { recursive: true, force: true });
1761
+ }
1762
+ });
1763
+ it('a failure a pass meets while a surgery waits for it is reported as any failure', async () => {
1764
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-surgery-failure-'));
1765
+ const strategy = new HookedTickStrategy();
1766
+ const framework = await AgentFramework.create({
1767
+ storePath: join(dir, 'store'),
1768
+ membrane,
1769
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1770
+ modules: [],
1771
+ syncIntervalMs: 0,
1772
+ maintenanceIntervalMs: 10,
1773
+ });
1774
+ const internals = framework;
1775
+ const alerts = [];
1776
+ framework.opsAlert = (kind) => alerts.push(kind);
1777
+ strategy.onTick = () => {
1778
+ internals.surgeryHold = { verb: 'rollback', agentName: 'agent', since: Date.now() };
1779
+ throw new Error('the tick failed while a surgery waited');
1780
+ };
1781
+ const logged = [];
1782
+ const consoleError = console.error;
1783
+ console.error = (...args) => { logged.push(args.map(String).join(' ')); };
1784
+ try {
1785
+ framework.start();
1786
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
1787
+ const run = firstPass(framework).agents[0];
1788
+ assert.equal(run.ticks, 0);
1789
+ assert.equal(run.error, 'the tick failed while a surgery waited');
1790
+ assert.deepEqual(alerts, ['context-maintenance-failed']);
1791
+ assert.ok(logged.some((line) => line.includes('[context-maintenance] agent=agent failed:')
1792
+ && line.includes('the tick failed while a surgery waited')));
1793
+ }
1794
+ finally {
1795
+ console.error = consoleError;
1796
+ internals.surgeryHold = null;
1797
+ await framework.stop();
1798
+ rmSync(dir, { recursive: true, force: true });
1799
+ }
1800
+ });
1801
+ it('a pass that ends before its agent is ready records what is still pending', async () => {
1802
+ const dir = mkdtempSync(join(tmpdir(), 'af-maintenance-pending-after-'));
1803
+ const strategy = new NeverReadyStrategy();
1804
+ const framework = await AgentFramework.create({
1805
+ storePath: join(dir, 'store'),
1806
+ membrane,
1807
+ agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy }],
1808
+ modules: [],
1809
+ syncIntervalMs: 0,
1810
+ maintenanceIntervalMs: 10,
1811
+ });
1812
+ try {
1813
+ framework.start();
1814
+ await waitFor(() => framework.getContextMaintenanceSnapshot().history.length >= 1);
1815
+ const run = framework.getContextMaintenanceSnapshot().history.at(-1).agents[0];
1816
+ assert.equal(run.ticks, 8);
1817
+ assert.equal(run.readyAfter, false);
1818
+ assert.equal(run.pendingBefore, 'never ready');
1819
+ assert.equal(run.pendingAfter, 'never ready');
258
1820
  }
259
1821
  finally {
260
1822
  await framework.stop();