@meetquinn/animactl 0.1.2 → 0.1.3

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 +11 -6
  2. package/dist/server/{runtime/activity-payload.d.ts → activities/format.d.ts} +4 -1
  3. package/dist/server/activities/format.d.ts.map +1 -0
  4. package/dist/server/{runtime/activity-payload.js → activities/format.js} +16 -1
  5. package/dist/server/agents/agent-skills.d.ts +7 -3
  6. package/dist/server/agents/agent-skills.d.ts.map +1 -1
  7. package/dist/server/agents/agent-skills.js +243 -38
  8. package/dist/server/agents/agent-slack.service.d.ts +15 -0
  9. package/dist/server/agents/agent-slack.service.d.ts.map +1 -1
  10. package/dist/server/agents/agent-slack.service.js +43 -6
  11. package/dist/server/agents/default-skills.d.ts +9 -0
  12. package/dist/server/agents/default-skills.d.ts.map +1 -0
  13. package/dist/server/agents/default-skills.js +52 -0
  14. package/dist/server/cli/runtime-cli.d.ts +7 -1
  15. package/dist/server/cli/runtime-cli.d.ts.map +1 -1
  16. package/dist/server/cli/runtime-cli.js +107 -3
  17. package/dist/server/cli/services-cli.d.ts.map +1 -1
  18. package/dist/server/cli/services-cli.js +39 -0
  19. package/dist/server/inbox/slack-subscriber.d.ts +2 -0
  20. package/dist/server/inbox/slack-subscriber.d.ts.map +1 -1
  21. package/dist/server/inbox/slack-subscriber.js +28 -2
  22. package/dist/server/inbox/slack-subscription.service.d.ts.map +1 -1
  23. package/dist/server/inbox/slack-subscription.service.js +13 -1
  24. package/dist/server/inbox/wake-queue.service.d.ts +3 -1
  25. package/dist/server/inbox/wake-queue.service.d.ts.map +1 -1
  26. package/dist/server/inbox/wake-queue.service.js +5 -3
  27. package/dist/server/provider-usage/http.d.ts +1 -0
  28. package/dist/server/provider-usage/http.d.ts.map +1 -1
  29. package/dist/server/provider-usage/http.js +23 -1
  30. package/dist/server/provider-usage/provider-usage.service.d.ts +1 -4
  31. package/dist/server/provider-usage/provider-usage.service.d.ts.map +1 -1
  32. package/dist/server/provider-usage/provider-usage.service.js +1 -18
  33. package/dist/server/provider-usage/providers/claude.d.ts +1 -1
  34. package/dist/server/provider-usage/providers/claude.d.ts.map +1 -1
  35. package/dist/server/provider-usage/providers/claude.js +1 -1
  36. package/dist/server/provider-usage/providers/codex.d.ts +1 -1
  37. package/dist/server/provider-usage/providers/codex.d.ts.map +1 -1
  38. package/dist/server/provider-usage/providers/codex.js +1 -1
  39. package/dist/server/provider-usage/providers/kimi.d.ts +1 -1
  40. package/dist/server/provider-usage/providers/kimi.d.ts.map +1 -1
  41. package/dist/server/provider-usage/providers/kimi.js +4 -4
  42. package/dist/server/provider-usage/result.d.ts +5 -0
  43. package/dist/server/provider-usage/result.d.ts.map +1 -0
  44. package/dist/server/provider-usage/result.js +18 -0
  45. package/dist/server/providers/active-runtime.d.ts +1 -1
  46. package/dist/server/providers/active-runtime.d.ts.map +1 -1
  47. package/dist/server/providers/claude-events.d.ts +1 -1
  48. package/dist/server/providers/claude-events.d.ts.map +1 -1
  49. package/dist/server/providers/claude-events.js +2 -2
  50. package/dist/server/providers/claude.d.ts +1 -1
  51. package/dist/server/providers/claude.d.ts.map +1 -1
  52. package/dist/server/providers/claude.js +2 -2
  53. package/dist/server/providers/codex-app-server.d.ts +1 -1
  54. package/dist/server/providers/codex-app-server.d.ts.map +1 -1
  55. package/dist/server/providers/codex-app-server.js +1 -1
  56. package/dist/server/providers/codex-events.js +1 -1
  57. package/dist/server/providers/codex.d.ts +1 -1
  58. package/dist/server/providers/codex.d.ts.map +1 -1
  59. package/dist/server/providers/codex.js +2 -2
  60. package/dist/server/{runtime/provider-contract.d.ts → providers/contract.d.ts} +1 -1
  61. package/dist/server/providers/contract.d.ts.map +1 -0
  62. package/dist/server/providers/factory.d.ts +1 -1
  63. package/dist/server/providers/factory.d.ts.map +1 -1
  64. package/dist/server/providers/kimi-events.d.ts +1 -1
  65. package/dist/server/providers/kimi-events.d.ts.map +1 -1
  66. package/dist/server/providers/kimi-events.js +1 -1
  67. package/dist/server/providers/kimi.d.ts +1 -1
  68. package/dist/server/providers/kimi.d.ts.map +1 -1
  69. package/dist/server/providers/kimi.js +3 -2
  70. package/dist/server/providers/reasoning-events.js +1 -1
  71. package/dist/server/runtime/active-run-control.d.ts +19 -0
  72. package/dist/server/runtime/active-run-control.d.ts.map +1 -0
  73. package/dist/server/runtime/active-run-control.js +89 -0
  74. package/dist/server/runtime/activity.js +1 -1
  75. package/dist/server/runtime/agent-runner.d.ts +1 -1
  76. package/dist/server/runtime/agent-runner.d.ts.map +1 -1
  77. package/dist/server/runtime/delivery-prompt.d.ts +6 -0
  78. package/dist/server/runtime/delivery-prompt.d.ts.map +1 -1
  79. package/dist/server/runtime/delivery-prompt.js +53 -34
  80. package/dist/server/runtime/followup-appender.d.ts +20 -0
  81. package/dist/server/runtime/followup-appender.d.ts.map +1 -0
  82. package/dist/server/runtime/followup-appender.js +128 -0
  83. package/dist/server/runtime/host.d.ts +2 -0
  84. package/dist/server/runtime/host.d.ts.map +1 -1
  85. package/dist/server/runtime/host.js +8 -0
  86. package/dist/server/runtime/provider-runner.d.ts +16 -0
  87. package/dist/server/runtime/provider-runner.d.ts.map +1 -0
  88. package/dist/server/runtime/provider-runner.js +86 -0
  89. package/dist/server/runtime/runtime-bridge.d.ts +1 -1
  90. package/dist/server/runtime/runtime-bridge.d.ts.map +1 -1
  91. package/dist/server/runtime/runtime-session.service.d.ts +1 -1
  92. package/dist/server/runtime/runtime-session.service.d.ts.map +1 -1
  93. package/dist/server/runtime/runtime-session.service.js +1 -1
  94. package/dist/server/runtime/runtime-worker.d.ts +2 -10
  95. package/dist/server/runtime/runtime-worker.d.ts.map +1 -1
  96. package/dist/server/runtime/runtime-worker.js +60 -286
  97. package/dist/server/runtime-management/managed-runtime.d.ts.map +1 -0
  98. package/dist/server/runtime-management/runtime-release.d.ts.map +1 -0
  99. package/dist/server/{runtime → runtime-management}/runtime-upgrade.d.ts +1 -0
  100. package/dist/server/runtime-management/runtime-upgrade.d.ts.map +1 -0
  101. package/dist/server/{runtime → runtime-management}/runtime-upgrade.js +14 -0
  102. package/dist/server/services/system.service.d.ts +41 -0
  103. package/dist/server/services/system.service.d.ts.map +1 -0
  104. package/dist/server/services/system.service.js +154 -0
  105. package/dist/server/settings/settings.service.d.ts +2 -1
  106. package/dist/server/settings/settings.service.d.ts.map +1 -1
  107. package/dist/server/settings/settings.service.js +9 -1
  108. package/dist/server/slack/shortcuts.d.ts +0 -3
  109. package/dist/server/slack/shortcuts.d.ts.map +1 -1
  110. package/dist/server/slack/shortcuts.js +0 -3
  111. package/dist/server/slack-interactions/shortcut-ids.d.ts +4 -0
  112. package/dist/server/slack-interactions/shortcut-ids.d.ts.map +1 -0
  113. package/dist/server/slack-interactions/shortcut-ids.js +3 -0
  114. package/dist/server/slack-interactions/shortcut-views.d.ts +65 -0
  115. package/dist/server/slack-interactions/shortcut-views.d.ts.map +1 -0
  116. package/dist/server/slack-interactions/shortcut-views.js +211 -0
  117. package/dist/server/{slack → slack-interactions}/shortcut.service.d.ts +2 -47
  118. package/dist/server/slack-interactions/shortcut.service.d.ts.map +1 -0
  119. package/dist/server/slack-interactions/shortcut.service.js +227 -0
  120. package/dist/server/storage/jsonl-log.d.ts +9 -0
  121. package/dist/server/storage/jsonl-log.d.ts.map +1 -1
  122. package/dist/server/storage/jsonl-log.js +36 -1
  123. package/dist/server/storage/schema/server.store.d.ts +5 -0
  124. package/dist/server/storage/schema/server.store.d.ts.map +1 -1
  125. package/dist/server/storage/schema/server.store.js +2 -1
  126. package/dist/server/storage/schema/wake-queue.store.d.ts +15 -1
  127. package/dist/server/storage/schema/wake-queue.store.d.ts.map +1 -1
  128. package/dist/server/storage/schema/wake-queue.store.js +5 -3
  129. package/dist/server/tests/agent-config.test.d.ts +2 -0
  130. package/dist/server/tests/agent-config.test.d.ts.map +1 -0
  131. package/dist/server/tests/agent-config.test.js +229 -0
  132. package/dist/server/tests/agent-runtime.test.d.ts +2 -0
  133. package/dist/server/tests/agent-runtime.test.d.ts.map +1 -0
  134. package/dist/server/tests/agent-runtime.test.js +1110 -0
  135. package/dist/server/tests/agent-seed-memory.test.d.ts +2 -0
  136. package/dist/server/tests/agent-seed-memory.test.d.ts.map +1 -0
  137. package/dist/server/tests/agent-seed-memory.test.js +16 -0
  138. package/dist/server/tests/agent-skills.test.d.ts +2 -0
  139. package/dist/server/tests/agent-skills.test.d.ts.map +1 -0
  140. package/dist/server/tests/agent-skills.test.js +140 -0
  141. package/dist/server/tests/anima-home.d.ts +2 -0
  142. package/dist/server/tests/anima-home.d.ts.map +1 -0
  143. package/dist/server/tests/anima-home.js +4 -0
  144. package/dist/server/tests/cli-file.test.d.ts +2 -0
  145. package/dist/server/tests/cli-file.test.d.ts.map +1 -0
  146. package/dist/server/tests/cli-file.test.js +596 -0
  147. package/dist/server/tests/cli-message.test.d.ts +2 -0
  148. package/dist/server/tests/cli-message.test.d.ts.map +1 -0
  149. package/dist/server/tests/cli-message.test.js +1196 -0
  150. package/dist/server/tests/client-error-routes.test.d.ts +2 -0
  151. package/dist/server/tests/client-error-routes.test.d.ts.map +1 -0
  152. package/dist/server/tests/client-error-routes.test.js +163 -0
  153. package/dist/server/tests/config.test.d.ts +2 -0
  154. package/dist/server/tests/config.test.d.ts.map +1 -0
  155. package/dist/server/tests/config.test.js +497 -0
  156. package/dist/server/tests/default-skills.test.d.ts +2 -0
  157. package/dist/server/tests/default-skills.test.d.ts.map +1 -0
  158. package/dist/server/tests/default-skills.test.js +57 -0
  159. package/dist/server/tests/helpers/inbox.d.ts +10 -0
  160. package/dist/server/tests/helpers/inbox.d.ts.map +1 -0
  161. package/dist/server/tests/helpers/inbox.js +21 -0
  162. package/dist/server/tests/helpers/slack.d.ts +11 -0
  163. package/dist/server/tests/helpers/slack.d.ts.map +1 -0
  164. package/dist/server/tests/helpers/slack.js +17 -0
  165. package/dist/server/tests/helpers/state.d.ts +19 -0
  166. package/dist/server/tests/helpers/state.d.ts.map +1 -0
  167. package/dist/server/tests/helpers/state.js +69 -0
  168. package/dist/server/tests/inbox-slack-events.test.d.ts +2 -0
  169. package/dist/server/tests/inbox-slack-events.test.d.ts.map +1 -0
  170. package/dist/server/tests/inbox-slack-events.test.js +271 -0
  171. package/dist/server/tests/inbox.test.d.ts +2 -0
  172. package/dist/server/tests/inbox.test.d.ts.map +1 -0
  173. package/dist/server/tests/inbox.test.js +144 -0
  174. package/dist/server/tests/interactive-ask.test.d.ts +2 -0
  175. package/dist/server/tests/interactive-ask.test.d.ts.map +1 -0
  176. package/dist/server/tests/interactive-ask.test.js +387 -0
  177. package/dist/server/tests/kb.test.d.ts +2 -0
  178. package/dist/server/tests/kb.test.d.ts.map +1 -0
  179. package/dist/server/tests/kb.test.js +371 -0
  180. package/dist/server/tests/message.service.test.d.ts +2 -0
  181. package/dist/server/tests/message.service.test.d.ts.map +1 -0
  182. package/dist/server/tests/message.service.test.js +151 -0
  183. package/dist/server/tests/messages.test.d.ts +2 -0
  184. package/dist/server/tests/messages.test.d.ts.map +1 -0
  185. package/dist/server/tests/messages.test.js +12 -0
  186. package/dist/server/tests/prompt-attachments.test.d.ts +2 -0
  187. package/dist/server/tests/prompt-attachments.test.d.ts.map +1 -0
  188. package/dist/server/tests/prompt-attachments.test.js +274 -0
  189. package/dist/server/tests/provider-usage.test.d.ts +2 -0
  190. package/dist/server/tests/provider-usage.test.d.ts.map +1 -0
  191. package/dist/server/tests/provider-usage.test.js +97 -0
  192. package/dist/server/tests/reminders.test.d.ts +2 -0
  193. package/dist/server/tests/reminders.test.d.ts.map +1 -0
  194. package/dist/server/tests/reminders.test.js +383 -0
  195. package/dist/server/tests/runtime-cli.test.d.ts +2 -0
  196. package/dist/server/tests/runtime-cli.test.d.ts.map +1 -0
  197. package/dist/server/tests/runtime-cli.test.js +21 -0
  198. package/dist/server/tests/runtime-upgrade.test.d.ts +2 -0
  199. package/dist/server/tests/runtime-upgrade.test.d.ts.map +1 -0
  200. package/dist/server/tests/runtime-upgrade.test.js +307 -0
  201. package/dist/server/tests/runtime-worker.test.d.ts +2 -0
  202. package/dist/server/tests/runtime-worker.test.d.ts.map +1 -0
  203. package/dist/server/tests/runtime-worker.test.js +1021 -0
  204. package/dist/server/tests/runtime.test.d.ts +2 -0
  205. package/dist/server/tests/runtime.test.d.ts.map +1 -0
  206. package/dist/server/tests/runtime.test.js +354 -0
  207. package/dist/server/tests/services.test.d.ts +2 -0
  208. package/dist/server/tests/services.test.d.ts.map +1 -0
  209. package/dist/server/tests/services.test.js +636 -0
  210. package/dist/server/tests/slack-files.test.d.ts +2 -0
  211. package/dist/server/tests/slack-files.test.d.ts.map +1 -0
  212. package/dist/server/tests/slack-files.test.js +118 -0
  213. package/dist/server/tests/slack-shortcuts.test.d.ts +2 -0
  214. package/dist/server/tests/slack-shortcuts.test.d.ts.map +1 -0
  215. package/dist/server/tests/slack-shortcuts.test.js +310 -0
  216. package/dist/server/tests/slack.test.d.ts +2 -0
  217. package/dist/server/tests/slack.test.d.ts.map +1 -0
  218. package/dist/server/tests/slack.test.js +213 -0
  219. package/dist/server/tests/state-cache.test.d.ts +2 -0
  220. package/dist/server/tests/state-cache.test.d.ts.map +1 -0
  221. package/dist/server/tests/state-cache.test.js +184 -0
  222. package/dist/server/tests/subscriptions.test.d.ts +2 -0
  223. package/dist/server/tests/subscriptions.test.d.ts.map +1 -0
  224. package/dist/server/tests/subscriptions.test.js +292 -0
  225. package/dist/server/tests/system-service.test.d.ts +2 -0
  226. package/dist/server/tests/system-service.test.d.ts.map +1 -0
  227. package/dist/server/tests/system-service.test.js +20 -0
  228. package/dist/server/tests/url-routes.test.d.ts +2 -0
  229. package/dist/server/tests/url-routes.test.d.ts.map +1 -0
  230. package/dist/server/tests/url-routes.test.js +217 -0
  231. package/dist/server/tests/web-api.test.d.ts +2 -0
  232. package/dist/server/tests/web-api.test.d.ts.map +1 -0
  233. package/dist/server/tests/web-api.test.js +1759 -0
  234. package/dist/server/web/app.d.ts.map +1 -1
  235. package/dist/server/web/app.js +2 -0
  236. package/dist/server/web/client-error-routes.d.ts +3 -0
  237. package/dist/server/web/client-error-routes.d.ts.map +1 -0
  238. package/dist/server/web/client-error-routes.js +101 -0
  239. package/dist/server/web/system-routes.d.ts.map +1 -1
  240. package/dist/server/web/system-routes.js +21 -131
  241. package/dist/shared/agent-config.d.ts +8 -18
  242. package/dist/shared/agent-config.d.ts.map +1 -1
  243. package/dist/shared/agent-config.js +3 -4
  244. package/dist/shared/inbox.d.ts +27 -0
  245. package/dist/shared/inbox.d.ts.map +1 -1
  246. package/dist/shared/inbox.js +1 -0
  247. package/dist/shared/server-info.d.ts +6 -5
  248. package/dist/shared/server-info.d.ts.map +1 -1
  249. package/dist/shared/server-info.js +3 -1
  250. package/dist/shared/server-settings.d.ts +6 -0
  251. package/dist/shared/server-settings.d.ts.map +1 -1
  252. package/dist/shared/server-settings.js +1 -0
  253. package/dist/shared/skills.d.ts +24 -3
  254. package/dist/shared/skills.d.ts.map +1 -1
  255. package/dist/web/assets/activity-DHjip8tY.js +7 -0
  256. package/dist/web/assets/clipboard-D2NSC52v.js +1 -0
  257. package/dist/web/assets/{external-link-Dt0qTpmV.js → external-link-Bhr1WdRS.js} +1 -1
  258. package/dist/web/assets/{file-text-Dd9ys6py.js → file-text-CYMA_SMA.js} +1 -1
  259. package/dist/web/assets/{index-CzAQnbuO.js → index-CVpew409.js} +5 -5
  260. package/dist/web/assets/index-mh0uJTkY.css +2 -0
  261. package/dist/web/assets/kb-DDVpYkT9.js +35 -0
  262. package/dist/web/assets/profile-CBy6GNzp.js +1 -0
  263. package/dist/web/assets/reminders-Bbwb9QNT.js +1 -0
  264. package/dist/web/favicon.svg +1 -1
  265. package/dist/web/index.html +2 -2
  266. package/package.json +1 -1
  267. package/templates/runtime-standing-prompt.md +16 -2
  268. package/templates/skills/find-skills/SKILL.md +133 -0
  269. package/dist/server/runtime/activity-payload.d.ts.map +0 -1
  270. package/dist/server/runtime/activity-text.d.ts +0 -4
  271. package/dist/server/runtime/activity-text.d.ts.map +0 -1
  272. package/dist/server/runtime/activity-text.js +0 -16
  273. package/dist/server/runtime/managed-runtime.d.ts.map +0 -1
  274. package/dist/server/runtime/provider-contract.d.ts.map +0 -1
  275. package/dist/server/runtime/runtime-release.d.ts.map +0 -1
  276. package/dist/server/runtime/runtime-upgrade.d.ts.map +0 -1
  277. package/dist/server/slack/shortcut.service.d.ts.map +0 -1
  278. package/dist/server/slack/shortcut.service.js +0 -493
  279. package/dist/web/assets/activity-BP0C81IV.js +0 -7
  280. package/dist/web/assets/globe-DmDZSgDP.js +0 -1
  281. package/dist/web/assets/index-DKecrvN0.css +0 -2
  282. package/dist/web/assets/kb-3wFeP2Jt.js +0 -32
  283. package/dist/web/assets/profile-D7wPZXW_.js +0 -1
  284. package/dist/web/assets/reminders-DOhroVwo.js +0 -1
  285. /package/dist/server/{runtime/provider-contract.js → providers/contract.js} +0 -0
  286. /package/dist/server/{runtime → runtime-management}/managed-runtime.d.ts +0 -0
  287. /package/dist/server/{runtime → runtime-management}/managed-runtime.js +0 -0
  288. /package/dist/server/{runtime → runtime-management}/runtime-release.d.ts +0 -0
  289. /package/dist/server/{runtime → runtime-management}/runtime-release.js +0 -0
@@ -0,0 +1,1021 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { withAnimaHome } from './anima-home.js';
7
+ import { makeSlackEvent } from './helpers/slack.js';
8
+ import { makeReminderInboxItem } from './helpers/inbox.js';
9
+ import { WakeQueueService } from '../inbox/wake-queue.service.js';
10
+ import { allActivities, loadState } from './helpers/state.js';
11
+ import { AgentRuntimeWorker } from '../runtime/runtime-worker.js';
12
+ import { clearRestartDrain, requestRestartDrain } from '../services/restart-drain.js';
13
+ import { runtimeContextForItemId } from '../runtime/context.js';
14
+ import { findActiveRuntimeItem } from '../runtime/active-item.js';
15
+ import { addProcessingReaction, removeProcessingReactions } from '../runtime/processing-reactions.js';
16
+ async function enqueueInbox(event, options) {
17
+ await ensureTestAgentConfig(options);
18
+ const decision = await new WakeQueueService(options.agentId).enqueue(event);
19
+ return {
20
+ ...decision,
21
+ ctx: await runtimeContextForItemId(decision.item.id, options),
22
+ };
23
+ }
24
+ const queueFor = (agentId) => new WakeQueueService(agentId);
25
+ async function ensureTestAgentConfig(options) {
26
+ const agentDir = join(options.stateDir, 'agents', options.agentId);
27
+ await mkdir(agentDir, { recursive: true });
28
+ await writeFile(join(agentDir, 'config.json'), `${JSON.stringify({ id: options.agentId }, null, 2)}\n`, 'utf8');
29
+ }
30
+ test('queued Slack listener persists work for a separate runtime worker', async () => {
31
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-queued-worker-test-'));
32
+ const runtime = new ControlledRuntime();
33
+ const reactionCalls = [];
34
+ const reactionClient = {
35
+ add: async (reaction) => {
36
+ reactionCalls.push(`add:${reaction.channel}:${reaction.timestamp}:${reaction.name}`);
37
+ },
38
+ remove: async (reaction) => {
39
+ reactionCalls.push(`remove:${reaction.channel}:${reaction.timestamp}:${reaction.name}`);
40
+ },
41
+ };
42
+ const coordinator = ({ agentId: 'scout', stateDir });
43
+ let worker;
44
+ try {
45
+ await withAnimaHome(stateDir, async () => {
46
+ worker = new AgentRuntimeWorker({
47
+ agentId: 'scout',
48
+ agentRuntime: runtime,
49
+ queue: queueFor('scout'),
50
+ onItemSettled: (context) => removeProcessingReactions({ context, reactionClient }),
51
+ stateDir,
52
+ workerId: 'test-worker',
53
+ }, silentLogger);
54
+ const decision = await enqueueInbox(makeSlackEvent({
55
+ channelId: 'D-user',
56
+ eventId: 'evt-queued',
57
+ teamId: 'T-demo',
58
+ text: 'queued',
59
+ ts: '1770000010.000001',
60
+ userId: 'U1',
61
+ }), coordinator);
62
+ assert.equal(decision.queued, true);
63
+ assert.equal((await queueFor('scout').listRunnable())[0]?.handling.status, 'queued');
64
+ const drain = worker.drainOnce();
65
+ await waitFor(() => runtime.calls.length === 1);
66
+ assert.match(runtime.calls[0]?.prompt ?? '', /queued/);
67
+ assert.equal((await queueFor('scout').listRunnable())[0]?.handling.status, 'running');
68
+ runtime.finishNext();
69
+ assert.equal(await drain, 1);
70
+ assert.equal((await queueFor('scout').listRunnable())[0]?.handling.status, 'completed');
71
+ assert.equal(allActivities(await loadState()).some((activity) => activity.type === 'runtime.event'
72
+ && activity.payload?.['eventType'] === 'runtime.restart_resumed'), false);
73
+ assert.deepEqual(reactionCalls, ['remove:D-user:1770000010.000001:eyes']);
74
+ });
75
+ }
76
+ finally {
77
+ await worker?.close();
78
+ await rm(stateDir, { force: true, recursive: true });
79
+ }
80
+ });
81
+ test('runtime worker drain close lets active item finish before clearing audit pointer', async () => {
82
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-drain-close-test-'));
83
+ const runtime = new ControlledRuntime();
84
+ const coordinator = { agentId: 'scout', stateDir };
85
+ let worker;
86
+ try {
87
+ await withAnimaHome(stateDir, async () => {
88
+ worker = new AgentRuntimeWorker({
89
+ agentId: 'scout',
90
+ agentRuntime: runtime,
91
+ queue: queueFor('scout'),
92
+ pollIntervalMs: 10_000,
93
+ stateDir,
94
+ workerId: 'test-worker',
95
+ }, silentLogger);
96
+ const decision = await enqueueInbox(makeSlackEvent({
97
+ channelId: 'D-user',
98
+ eventId: 'evt-drain-close',
99
+ teamId: 'T-demo',
100
+ text: 'finish before recycle',
101
+ ts: '1770000010.000001',
102
+ userId: 'U1',
103
+ }), coordinator);
104
+ const drain = worker.drainOnce();
105
+ await waitFor(() => runtime.calls.length === 1);
106
+ assert.equal((await findActiveRuntimeItem('scout'))?.itemId, decision.ctx.item.id);
107
+ const close = worker.close({ drainActive: true });
108
+ await new Promise((resolve) => setTimeout(resolve, 30));
109
+ assert.equal((await findActiveRuntimeItem('scout'))?.itemId, decision.ctx.item.id);
110
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'running');
111
+ runtime.finishNext();
112
+ await close;
113
+ await drain;
114
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'completed');
115
+ assert.equal(await findActiveRuntimeItem('scout'), undefined);
116
+ worker = undefined;
117
+ });
118
+ }
119
+ finally {
120
+ await worker?.close();
121
+ await rm(stateDir, { force: true, recursive: true });
122
+ }
123
+ });
124
+ test('runtime worker appends queued follow-up messages into an active runtime', async () => {
125
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-followup-test-'));
126
+ const runtime = new FollowupRuntime();
127
+ const reactionCalls = [];
128
+ const settledTurnIds = [];
129
+ const reactionClient = {
130
+ add: async (reaction) => {
131
+ reactionCalls.push(`add:${reaction.channel}:${reaction.timestamp}:${reaction.name}`);
132
+ },
133
+ remove: async (reaction) => {
134
+ reactionCalls.push(`remove:${reaction.channel}:${reaction.timestamp}:${reaction.name}`);
135
+ },
136
+ };
137
+ const coordinator = ({
138
+ agentId: 'scout',
139
+ stateDir,
140
+ });
141
+ let worker;
142
+ try {
143
+ await withAnimaHome(stateDir, async () => {
144
+ worker = new AgentRuntimeWorker({
145
+ agentId: 'scout',
146
+ agentRuntime: runtime,
147
+ queue: queueFor('scout'),
148
+ onItemStarted: async (context) => {
149
+ await addProcessingReaction({ context, reactionClient });
150
+ },
151
+ onItemFollowupAppended: async (_activeContext, context) => {
152
+ await addProcessingReaction({ context, reactionClient });
153
+ },
154
+ onItemSettled: async (context) => {
155
+ await removeProcessingReactions({ context, reactionClient });
156
+ settledTurnIds.push(context.item.id);
157
+ },
158
+ pollIntervalMs: 10_000,
159
+ stateDir,
160
+ workerId: 'test-worker',
161
+ }, silentLogger);
162
+ const first = await enqueueInbox(makeSlackEvent({
163
+ channelId: 'D-user',
164
+ eventId: 'evt-first',
165
+ teamId: 'T-demo',
166
+ text: 'first',
167
+ ts: '1770000010.000001',
168
+ userId: 'U1',
169
+ }), coordinator);
170
+ const drain = worker.drainOnce();
171
+ await waitFor(() => runtime.calls.length === 1);
172
+ const second = await enqueueInbox(makeSlackEvent({
173
+ channelId: 'D-user',
174
+ eventId: 'evt-second',
175
+ teamId: 'T-demo',
176
+ text: 'second',
177
+ ts: '1770000011.000001',
178
+ userId: 'U1',
179
+ }), coordinator);
180
+ await waitFor(() => runtime.followups.length === 1);
181
+ assert.equal(runtime.followups[0]?.activeItemId, first.ctx.item.id);
182
+ assert.equal(runtime.followups[0]?.itemId, second.ctx.item.id);
183
+ await waitForInboxItemStatus('scout', second.ctx.item.id, 'completed');
184
+ await waitFor(() => reactionCalls.includes('add:D-user:1770000011.000001:eyes'));
185
+ assert.deepEqual(reactionCalls, [
186
+ 'add:D-user:1770000010.000001:eyes',
187
+ 'add:D-user:1770000011.000001:eyes',
188
+ ]);
189
+ assert.deepEqual(settledTurnIds, []);
190
+ const third = await enqueueInbox(makeSlackEvent({
191
+ channelId: 'D-user',
192
+ eventId: 'evt-third',
193
+ teamId: 'T-demo',
194
+ text: 'third',
195
+ ts: '1770000012.000001',
196
+ userId: 'U1',
197
+ }), coordinator);
198
+ await waitFor(() => runtime.followups.length === 2);
199
+ assert.equal(runtime.followups[1]?.activeItemId, first.ctx.item.id);
200
+ assert.equal(runtime.followups[1]?.itemId, third.ctx.item.id);
201
+ await waitForInboxItemStatus('scout', third.ctx.item.id, 'completed');
202
+ await waitFor(() => reactionCalls.includes('add:D-user:1770000012.000001:eyes'));
203
+ assert.deepEqual(reactionCalls, [
204
+ 'add:D-user:1770000010.000001:eyes',
205
+ 'add:D-user:1770000011.000001:eyes',
206
+ 'add:D-user:1770000012.000001:eyes',
207
+ ]);
208
+ assert.deepEqual(settledTurnIds, []);
209
+ runtime.finishNext();
210
+ assert.equal(await drain, 1);
211
+ assert.equal((await queueFor('scout').listRunnable()).find((item) => item.id === first.ctx.item.id)?.handling.status, 'completed');
212
+ await waitFor(() => settledTurnIds.length === 3);
213
+ assert.deepEqual(reactionCalls, [
214
+ 'add:D-user:1770000010.000001:eyes',
215
+ 'add:D-user:1770000011.000001:eyes',
216
+ 'add:D-user:1770000012.000001:eyes',
217
+ 'remove:D-user:1770000010.000001:eyes',
218
+ 'remove:D-user:1770000011.000001:eyes',
219
+ 'remove:D-user:1770000012.000001:eyes',
220
+ ]);
221
+ assert.deepEqual(settledTurnIds, [first.ctx.item.id, second.ctx.item.id, third.ctx.item.id]);
222
+ });
223
+ }
224
+ finally {
225
+ await worker?.close();
226
+ await rm(stateDir, { force: true, recursive: true });
227
+ }
228
+ });
229
+ test('runtime worker appends newly queued inbound follow-ups into an active runtime', async () => {
230
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-immediate-followup-test-'));
231
+ const runtime = new FollowupRuntime();
232
+ const followupSignals = [];
233
+ const coordinator = ({
234
+ agentId: 'scout',
235
+ stateDir,
236
+ });
237
+ let worker;
238
+ try {
239
+ await withAnimaHome(stateDir, async () => {
240
+ worker = new AgentRuntimeWorker({
241
+ agentId: 'scout',
242
+ agentRuntime: runtime,
243
+ queue: queueFor('scout'),
244
+ onItemFollowupAppended: async (activeContext, context) => {
245
+ followupSignals.push(`${activeContext.item.id}:${context.item.id}`);
246
+ },
247
+ pollIntervalMs: 10_000,
248
+ stateDir,
249
+ workerId: 'test-worker',
250
+ }, silentLogger);
251
+ const first = await enqueueInbox(makeSlackEvent({
252
+ channelId: 'D-user',
253
+ eventId: 'evt-immediate-first',
254
+ teamId: 'T-demo',
255
+ text: 'first',
256
+ ts: '1770000010.000001',
257
+ userId: 'U1',
258
+ }), coordinator);
259
+ assert.equal(first.queued, true);
260
+ const drain = worker.drainOnce();
261
+ await waitFor(() => runtime.calls.length === 1);
262
+ const second = await enqueueInbox(makeSlackEvent({
263
+ channelId: 'D-user',
264
+ eventId: 'evt-immediate-second',
265
+ teamId: 'T-demo',
266
+ text: 'second',
267
+ ts: '1770000011.000001',
268
+ userId: 'U1',
269
+ }), coordinator);
270
+ assert.equal(second.queued, true);
271
+ await waitFor(() => runtime.followups.length === 1);
272
+ assert.equal(runtime.followups[0]?.activeItemId, first.ctx.item.id);
273
+ assert.equal(runtime.followups[0]?.itemId, second.ctx.item.id);
274
+ await waitFor(() => followupSignals.length === 1);
275
+ assert.deepEqual(followupSignals, [`${first.ctx.item.id}:${second.ctx.item.id}`]);
276
+ await waitForInboxItemStatus('scout', second.ctx.item.id, 'completed');
277
+ runtime.finishNext();
278
+ assert.equal(await drain, 1);
279
+ assert.equal((await queueFor('scout').find(first.ctx.item.id))?.handling.status, 'completed');
280
+ });
281
+ }
282
+ finally {
283
+ await worker?.close();
284
+ await rm(stateDir, { force: true, recursive: true });
285
+ }
286
+ });
287
+ test('runtime worker queues inbound work while active when follow-up append is rejected', async () => {
288
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-no-followup-test-'));
289
+ const runtime = new ControlledRuntime();
290
+ const coordinator = ({
291
+ agentId: 'scout',
292
+ stateDir,
293
+ });
294
+ let worker;
295
+ try {
296
+ await withAnimaHome(stateDir, async () => {
297
+ worker = new AgentRuntimeWorker({
298
+ agentId: 'scout',
299
+ agentRuntime: runtime,
300
+ queue: queueFor('scout'),
301
+ pollIntervalMs: 10_000,
302
+ stateDir,
303
+ workerId: 'test-worker',
304
+ }, silentLogger);
305
+ const first = await enqueueInbox(makeSlackEvent({
306
+ channelId: 'D-user',
307
+ eventId: 'evt-no-followup-first',
308
+ teamId: 'T-demo',
309
+ text: 'first',
310
+ ts: '1770000010.000001',
311
+ userId: 'U1',
312
+ }), coordinator);
313
+ const drain = worker.drainOnce();
314
+ await waitFor(() => runtime.calls.length === 1);
315
+ const second = await enqueueInbox(makeSlackEvent({
316
+ channelId: 'D-user',
317
+ eventId: 'evt-no-followup-second',
318
+ teamId: 'T-demo',
319
+ text: 'second',
320
+ ts: '1770000011.000001',
321
+ userId: 'U1',
322
+ }), coordinator);
323
+ assert.equal(second.queued, true);
324
+ assert.equal((await queueFor('scout').find(second.ctx.item.id))?.handling.status, 'queued');
325
+ runtime.finishNext();
326
+ await waitFor(() => runtime.calls.length === 2);
327
+ runtime.finishNext();
328
+ assert.equal(await drain, 2);
329
+ assert.equal((await queueFor('scout').find(first.ctx.item.id))?.handling.status, 'completed');
330
+ assert.equal((await queueFor('scout').find(second.ctx.item.id))?.handling.status, 'completed');
331
+ });
332
+ }
333
+ finally {
334
+ await worker?.close();
335
+ await rm(stateDir, { force: true, recursive: true });
336
+ }
337
+ });
338
+ test('runtime worker records pending when follow-up append is rejected', async () => {
339
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-followup-reject-test-'));
340
+ const runtime = new RejectingFollowupRuntime();
341
+ const coordinator = ({
342
+ agentId: 'scout',
343
+ stateDir,
344
+ });
345
+ let worker;
346
+ try {
347
+ await withAnimaHome(stateDir, async () => {
348
+ worker = new AgentRuntimeWorker({
349
+ agentId: 'scout',
350
+ agentRuntime: runtime,
351
+ queue: queueFor('scout'),
352
+ pollIntervalMs: 10_000,
353
+ stateDir,
354
+ workerId: 'test-worker',
355
+ }, silentLogger);
356
+ await enqueueInbox(makeSlackEvent({
357
+ channelId: 'D-user',
358
+ eventId: 'evt-reject-first',
359
+ teamId: 'T-demo',
360
+ text: 'first',
361
+ ts: '1770000010.000001',
362
+ userId: 'U1',
363
+ }), coordinator);
364
+ const drain = worker.drainOnce();
365
+ await waitFor(() => runtime.calls.length === 1);
366
+ const second = await enqueueInbox(makeSlackEvent({
367
+ channelId: 'D-user',
368
+ eventId: 'evt-reject-second',
369
+ teamId: 'T-demo',
370
+ text: 'second',
371
+ ts: '1770000011.000001',
372
+ userId: 'U1',
373
+ }), coordinator);
374
+ assert.equal(second.queued, true);
375
+ await waitFor(() => runtime.followups.length === 1);
376
+ await waitForInboxItemStatus('scout', second.ctx.item.id, 'queued');
377
+ await waitForAsync(async () => allActivities(await loadState()).some((activity) => activity.type === 'runtime.pending'));
378
+ const pending = allActivities(await loadState()).find((activity) => activity.type === 'runtime.pending');
379
+ assert.equal(pending?.payload?.['reason'], 'followup_rejected');
380
+ runtime.finishNext();
381
+ await waitFor(() => runtime.calls.length === 2);
382
+ runtime.finishNext();
383
+ assert.equal(await drain, 2);
384
+ assert.equal((await queueFor('scout').find(second.ctx.item.id))?.handling.status, 'completed');
385
+ });
386
+ }
387
+ finally {
388
+ await worker?.close();
389
+ await rm(stateDir, { force: true, recursive: true });
390
+ }
391
+ });
392
+ test('runtime worker appends queued reminder wakes into an active runtime', async () => {
393
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-reminder-followup-test-'));
394
+ const runtime = new FollowupRuntime();
395
+ const coordinator = ({
396
+ agentId: 'scout',
397
+ stateDir,
398
+ });
399
+ let worker;
400
+ try {
401
+ await withAnimaHome(stateDir, async () => {
402
+ worker = new AgentRuntimeWorker({
403
+ agentId: 'scout',
404
+ agentRuntime: runtime,
405
+ queue: queueFor('scout'),
406
+ pollIntervalMs: 10_000,
407
+ stateDir,
408
+ workerId: 'test-worker',
409
+ }, silentLogger);
410
+ const first = await enqueueInbox(makeSlackEvent({
411
+ channelId: 'D-user',
412
+ eventId: 'evt-reminder-followup-first',
413
+ teamId: 'T-demo',
414
+ text: 'first',
415
+ ts: '1770000010.000001',
416
+ userId: 'U1',
417
+ }), coordinator);
418
+ const drain = worker.drainOnce();
419
+ await waitFor(() => runtime.calls.length === 1);
420
+ const reminder = await enqueueInbox(makeReminderInboxItem({
421
+ eventId: 'evt-reminder-followup-second',
422
+ reminderId: 'reminder-followup',
423
+ timestamp: '2026-05-18T17:00:00.000Z',
424
+ }), coordinator);
425
+ assert.equal(reminder.queued, true);
426
+ await waitFor(() => runtime.followups.length === 1);
427
+ assert.equal(runtime.followups[0]?.activeItemId, first.ctx.item.id);
428
+ assert.equal(runtime.followups[0]?.itemId, reminder.ctx.item.id);
429
+ runtime.finishNext();
430
+ assert.equal(await drain, 1);
431
+ });
432
+ }
433
+ finally {
434
+ await worker?.close();
435
+ await rm(stateDir, { force: true, recursive: true });
436
+ }
437
+ });
438
+ test('runtime worker reclaims items owned by an exited worker', async () => {
439
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-slack-worker-recovery-test-'));
440
+ const runtime = new ControlledRuntime();
441
+ const coordinator = ({
442
+ agentId: 'scout',
443
+ stateDir,
444
+ });
445
+ let worker;
446
+ try {
447
+ await withAnimaHome(stateDir, async () => {
448
+ worker = new AgentRuntimeWorker({
449
+ agentId: 'scout',
450
+ agentRuntime: runtime,
451
+ queue: queueFor('scout'),
452
+ stateDir,
453
+ workerId: 'new-worker',
454
+ workerIsAlive: (workerId) => workerId !== 'dead-worker',
455
+ }, silentLogger);
456
+ const decision = await enqueueInbox(makeSlackEvent({
457
+ channelId: 'D-user',
458
+ eventId: 'evt-recover',
459
+ teamId: 'T-demo',
460
+ text: 'recover',
461
+ ts: '1770000010.000001',
462
+ userId: 'U1',
463
+ }), coordinator);
464
+ await queueFor('scout').claimNext('dead-worker');
465
+ assert.equal((await queueFor('scout').listRunnable())[0]?.handling.status, 'running');
466
+ const drain = worker.drainOnce();
467
+ await waitFor(() => runtime.calls.length === 1);
468
+ assert.equal(runtime.calls[0]?.itemId, decision.ctx.item.id);
469
+ runtime.finishNext();
470
+ assert.equal(await drain, 1);
471
+ const recovered = (await queueFor('scout').listRunnable())[0];
472
+ assert.equal(recovered?.handling.status, 'completed');
473
+ assert.equal(recovered?.handling.workerId, 'new-worker');
474
+ });
475
+ }
476
+ finally {
477
+ await worker?.close();
478
+ await rm(stateDir, { force: true, recursive: true });
479
+ }
480
+ });
481
+ test('runtime worker injects provider env while preserving Anima-managed env', async () => {
482
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-env-test-'));
483
+ const runtime = new ControlledRuntime({
484
+ ANIMA_AGENT_ID: 'bad-agent',
485
+ ANIMA_HOME: '/bad/home',
486
+ CUSTOM_LAUNCH_FLAG: 'enabled',
487
+ PATH: '/custom/bin',
488
+ });
489
+ const coordinator = { agentId: 'scout', stateDir };
490
+ let worker;
491
+ try {
492
+ await withAnimaHome(stateDir, async () => {
493
+ worker = new AgentRuntimeWorker({
494
+ agentId: 'scout',
495
+ agentRuntime: runtime,
496
+ queue: queueFor('scout'),
497
+ stateDir,
498
+ workerId: 'test-worker',
499
+ }, silentLogger);
500
+ await enqueueInbox(makeSlackEvent({
501
+ channelId: 'D-user',
502
+ eventId: 'evt-env',
503
+ teamId: 'T-demo',
504
+ text: 'env',
505
+ ts: '1770000010.000001',
506
+ userId: 'U1',
507
+ }), coordinator);
508
+ const drain = worker.drainOnce();
509
+ await waitFor(() => runtime.calls.length === 1);
510
+ runtime.finishNext();
511
+ await drain;
512
+ const env = runtime.calls[0]?.env;
513
+ assert.equal(env?.CUSTOM_LAUNCH_FLAG, 'enabled');
514
+ assert.equal(env?.ANIMA_AGENT_ID, 'scout');
515
+ assert.equal(env?.ANIMA_HOME, stateDir);
516
+ assert.match(env?.PATH ?? '', /^.*bin:\/custom\/bin$/);
517
+ });
518
+ }
519
+ finally {
520
+ await worker?.close();
521
+ await rm(stateDir, { force: true, recursive: true });
522
+ }
523
+ });
524
+ class ControlledRuntime {
525
+ env;
526
+ kind = 'controlled';
527
+ calls = [];
528
+ completed = 0;
529
+ resolvers = [];
530
+ constructor(env) {
531
+ this.env = env;
532
+ }
533
+ async run(input) {
534
+ this.calls.push(input);
535
+ await new Promise((resolve) => {
536
+ this.resolvers.push(resolve);
537
+ });
538
+ this.completed += 1;
539
+ return { text: `completed ${input.itemId}` };
540
+ }
541
+ async appendToActiveRun(_input) {
542
+ return { accepted: false };
543
+ }
544
+ async close() {
545
+ while (this.resolvers.length > 0)
546
+ this.resolvers.shift()?.();
547
+ }
548
+ finishNext() {
549
+ const resolve = this.resolvers.shift();
550
+ assert.ok(resolve, 'Expected an active runtime call');
551
+ resolve();
552
+ }
553
+ }
554
+ class FollowupRuntime extends ControlledRuntime {
555
+ followups = [];
556
+ async appendToActiveRun(input) {
557
+ this.followups.push(input);
558
+ return { accepted: true, text: `appended ${input.itemId}` };
559
+ }
560
+ }
561
+ class RejectingFollowupRuntime extends ControlledRuntime {
562
+ followups = [];
563
+ async appendToActiveRun(input) {
564
+ this.followups.push(input);
565
+ return { accepted: false };
566
+ }
567
+ }
568
+ async function waitFor(predicate, timeoutMs = 1000) {
569
+ const startedAt = Date.now();
570
+ while (!predicate()) {
571
+ if (Date.now() - startedAt > timeoutMs) {
572
+ throw new Error('Timed out waiting for condition');
573
+ }
574
+ await new Promise((resolve) => setTimeout(resolve, 10));
575
+ }
576
+ }
577
+ async function waitForAsync(predicate, timeoutMs = 1000) {
578
+ const startedAt = Date.now();
579
+ while (!(await predicate())) {
580
+ if (Date.now() - startedAt > timeoutMs) {
581
+ throw new Error('Timed out waiting for condition');
582
+ }
583
+ await new Promise((resolve) => setTimeout(resolve, 10));
584
+ }
585
+ }
586
+ async function waitForInboxItemStatus(agentId, itemId, status, timeoutMs = 1000) {
587
+ const startedAt = Date.now();
588
+ while (true) {
589
+ const item = (await queueFor(agentId).listRunnable()).find((candidate) => candidate.id === itemId);
590
+ if (item?.handling.status === status)
591
+ return;
592
+ if (Date.now() - startedAt > timeoutMs) {
593
+ throw new Error(`Timed out waiting for item ${itemId} to reach ${status}; current status is ${item?.handling.status ?? 'missing'}`);
594
+ }
595
+ await new Promise((resolve) => setTimeout(resolve, 10));
596
+ }
597
+ }
598
+ const silentLogger = {
599
+ error: () => { },
600
+ log: () => { },
601
+ };
602
+ class AbortableRuntime {
603
+ kind = 'abortable';
604
+ calls = [];
605
+ async run(input) {
606
+ this.calls.push(input);
607
+ return new Promise((_, reject) => {
608
+ if (input.signal?.aborted) {
609
+ reject(new Error(`aborted: ${String(input.signal.reason)}`));
610
+ return;
611
+ }
612
+ input.signal?.addEventListener('abort', () => {
613
+ reject(new Error(`aborted: ${String(input.signal?.reason)}`));
614
+ }, { once: true });
615
+ });
616
+ }
617
+ async appendToActiveRun(_input) {
618
+ return { accepted: false };
619
+ }
620
+ }
621
+ class DrainableRuntime extends AbortableRuntime {
622
+ drainCalls = [];
623
+ async requestDrain(input) {
624
+ this.drainCalls.push(input);
625
+ }
626
+ }
627
+ class ActivityBeforeFinishRuntime {
628
+ kind = 'activity-runtime';
629
+ calls = [];
630
+ async run(input) {
631
+ this.calls.push(input);
632
+ await new Promise((resolve) => setTimeout(resolve, 150));
633
+ if (input.signal?.aborted)
634
+ throw new Error(`aborted: ${String(input.signal.reason)}`);
635
+ await input.effects.recordOutput('stdout', 'still running');
636
+ await new Promise((resolve) => setTimeout(resolve, 150));
637
+ if (input.signal?.aborted)
638
+ throw new Error(`aborted: ${String(input.signal.reason)}`);
639
+ return { text: 'finished after activity' };
640
+ }
641
+ async appendToActiveRun(_input) {
642
+ return { accepted: false };
643
+ }
644
+ }
645
+ class CrashThenSuccessRuntime {
646
+ failuresBeforeSuccess;
647
+ kind = 'codex-cli';
648
+ calls = [];
649
+ constructor(failuresBeforeSuccess) {
650
+ this.failuresBeforeSuccess = failuresBeforeSuccess;
651
+ }
652
+ async run(input) {
653
+ this.calls.push(input);
654
+ if (this.calls.length <= this.failuresBeforeSuccess) {
655
+ throw new Error('Codex app-server runtime exited before completing active requests');
656
+ }
657
+ return { text: 'recovered' };
658
+ }
659
+ async appendToActiveRun(_input) {
660
+ return { accepted: false };
661
+ }
662
+ }
663
+ class FatalProviderRuntime {
664
+ kind = 'claude-code';
665
+ calls = [];
666
+ async run(input) {
667
+ this.calls.push(input);
668
+ throw new Error('Invalid API key');
669
+ }
670
+ async appendToActiveRun(_input) {
671
+ return { accepted: false };
672
+ }
673
+ }
674
+ test('runtime worker leaves queued work untouched while a restart drain marker is active', async () => {
675
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-restart-drain-marker-test-'));
676
+ const runtime = new ControlledRuntime();
677
+ const coordinator = { agentId: 'scout', stateDir };
678
+ let worker;
679
+ try {
680
+ await withAnimaHome(stateDir, async () => {
681
+ await requestRestartDrain(60_000);
682
+ worker = new AgentRuntimeWorker({
683
+ agentId: 'scout',
684
+ agentRuntime: runtime,
685
+ queue: queueFor('scout'),
686
+ stateDir,
687
+ workerId: 'test-worker',
688
+ }, silentLogger);
689
+ const decision = await enqueueInbox(makeSlackEvent({
690
+ channelId: 'D-user',
691
+ eventId: 'evt-restart-drain-marker',
692
+ teamId: 'T-demo',
693
+ text: 'stay queued',
694
+ ts: '1770000010.000001',
695
+ userId: 'U1',
696
+ }), coordinator);
697
+ assert.equal(await worker.drainOnce(), 0);
698
+ assert.equal(runtime.calls.length, 0);
699
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'queued');
700
+ });
701
+ }
702
+ finally {
703
+ await withAnimaHome(stateDir, async () => clearRestartDrain().catch(() => undefined));
704
+ await worker?.close();
705
+ await rm(stateDir, { force: true, recursive: true });
706
+ }
707
+ });
708
+ test('runtime worker drains a running item for restart without marking it failed', async () => {
709
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-restart-drain-test-'));
710
+ const runtime = new DrainableRuntime();
711
+ const coordinator = { agentId: 'scout', stateDir };
712
+ let worker;
713
+ try {
714
+ await withAnimaHome(stateDir, async () => {
715
+ worker = new AgentRuntimeWorker({
716
+ agentId: 'scout',
717
+ agentRuntime: runtime,
718
+ idleTimeoutMs: 1_000,
719
+ queue: queueFor('scout'),
720
+ pollIntervalMs: 10_000,
721
+ stateDir,
722
+ workerId: 'test-worker',
723
+ }, silentLogger);
724
+ const decision = await enqueueInbox(makeSlackEvent({
725
+ channelId: 'D-user',
726
+ eventId: 'evt-restart-drain',
727
+ teamId: 'T-demo',
728
+ text: 'drain me',
729
+ ts: '1770000010.000001',
730
+ userId: 'U1',
731
+ }), coordinator);
732
+ const drain = worker.drainOnce();
733
+ await waitFor(() => runtime.calls.length === 1);
734
+ await requestRestartDrain(60_000);
735
+ await queueFor('scout').requestDrain({ itemId: decision.ctx.item.id, timeoutMs: 1_000 });
736
+ await waitForInboxItemStatus('scout', decision.ctx.item.id, 'queued', 3_000);
737
+ assert.equal(await drain, 1);
738
+ const item = await queueFor('scout').find(decision.ctx.item.id);
739
+ assert.equal(item?.handling.status, 'queued');
740
+ assert.equal(item?.handling.drainRequestedAt, undefined);
741
+ assert.equal(item?.handling.drainTimeoutMs, undefined);
742
+ assert.equal(item?.handling.resumeReason, 'runtime_restart');
743
+ assert.equal(runtime.drainCalls.length, 1);
744
+ assert.equal(runtime.drainCalls[0]?.activeItemId, decision.ctx.item.id);
745
+ let activities = allActivities(await loadState());
746
+ assert.equal(activities.some((activity) => activity.type === 'runtime.failed'), false);
747
+ const aborted = activities.find((activity) => activity.type === 'runtime.aborted');
748
+ assert.equal(aborted?.payload?.['reason'], 'restart_drain');
749
+ await worker?.close();
750
+ worker = undefined;
751
+ await clearRestartDrain();
752
+ const resumedRuntime = new ControlledRuntime();
753
+ worker = new AgentRuntimeWorker({
754
+ agentId: 'scout',
755
+ agentRuntime: resumedRuntime,
756
+ queue: queueFor('scout'),
757
+ pollIntervalMs: 10_000,
758
+ stateDir,
759
+ workerId: 'resumed-worker',
760
+ }, silentLogger);
761
+ const resumeDrain = worker.drainOnce();
762
+ await waitFor(() => resumedRuntime.calls.length === 1);
763
+ assert.equal(resumedRuntime.calls[0]?.itemId, decision.ctx.item.id);
764
+ assert.equal(resumedRuntime.calls[0]?.prompt, [
765
+ 'Anima system message: runtime restarted while this task was in progress.',
766
+ 'Continue the same task from the current session; do not repeat completed external side effects.',
767
+ ].join('\n'));
768
+ assert.doesNotMatch(resumedRuntime.calls[0]?.prompt ?? '', /drain me/);
769
+ resumedRuntime.finishNext();
770
+ assert.equal(await resumeDrain, 1);
771
+ activities = allActivities(await loadState());
772
+ assert.equal(activities.some((activity) => activity.type === 'runtime.failed'), false);
773
+ const resumeEvents = activities.filter((activity) => activity.type === 'runtime.event'
774
+ && activity.payload?.['eventType'] === 'runtime.restart_resumed');
775
+ assert.equal(resumeEvents.length, 1);
776
+ assert.equal(resumeEvents[0]?.payload?.['message'], 'Resumed after restart');
777
+ assert.equal(resumeEvents[0]?.payload?.['itemId'], decision.ctx.item.id);
778
+ });
779
+ }
780
+ finally {
781
+ await withAnimaHome(stateDir, async () => clearRestartDrain().catch(() => undefined));
782
+ await worker?.close();
783
+ await rm(stateDir, { force: true, recursive: true });
784
+ }
785
+ });
786
+ test('runtime worker stops a item when queue requestStop sets stopRequestedAt', async () => {
787
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-stop-test-'));
788
+ const runtime = new AbortableRuntime();
789
+ const coordinator = { agentId: 'scout', stateDir };
790
+ let worker;
791
+ try {
792
+ await withAnimaHome(stateDir, async () => {
793
+ worker = new AgentRuntimeWorker({
794
+ agentId: 'scout',
795
+ agentRuntime: runtime,
796
+ queue: queueFor('scout'),
797
+ idleTimeoutMs: 60_000,
798
+ pollIntervalMs: 10_000,
799
+ stateDir,
800
+ workerId: 'test-worker',
801
+ }, silentLogger);
802
+ const decision = await enqueueInbox(makeSlackEvent({
803
+ channelId: 'D-user',
804
+ eventId: 'evt-stop',
805
+ teamId: 'T-demo',
806
+ text: 'stop me',
807
+ ts: '1770000010.000001',
808
+ userId: 'U1',
809
+ }), coordinator);
810
+ const drain = worker.drainOnce();
811
+ await waitFor(() => runtime.calls.length === 1);
812
+ await queueFor('scout').requestStop(decision.ctx.item.id);
813
+ await waitForInboxItemStatus('scout', decision.ctx.item.id, 'failed', 5_000);
814
+ await drain;
815
+ const item = await queueFor('scout').find(decision.ctx.item.id);
816
+ assert.ok(item?.handling.stopRequestedAt, 'expected stopRequestedAt to be set');
817
+ });
818
+ }
819
+ finally {
820
+ await worker?.close();
821
+ await rm(stateDir, { force: true, recursive: true });
822
+ }
823
+ });
824
+ test('runtime worker idle watchdog aborts a item that produces no activity', async () => {
825
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-idle-test-'));
826
+ const runtime = new AbortableRuntime();
827
+ const coordinator = { agentId: 'scout', stateDir };
828
+ let worker;
829
+ try {
830
+ await withAnimaHome(stateDir, async () => {
831
+ worker = new AgentRuntimeWorker({
832
+ agentId: 'scout',
833
+ agentRuntime: runtime,
834
+ queue: queueFor('scout'),
835
+ idleTimeoutMs: 200,
836
+ pollIntervalMs: 10_000,
837
+ stateDir,
838
+ workerId: 'test-worker',
839
+ }, silentLogger);
840
+ const decision = await enqueueInbox(makeSlackEvent({
841
+ channelId: 'D-user',
842
+ eventId: 'evt-idle',
843
+ teamId: 'T-demo',
844
+ text: 'idle stuck',
845
+ ts: '1770000010.000001',
846
+ userId: 'U1',
847
+ }), coordinator);
848
+ const drain = worker.drainOnce();
849
+ await waitForInboxItemStatus('scout', decision.ctx.item.id, 'failed', 5_000);
850
+ await drain;
851
+ assert.equal(runtime.calls.length, 1);
852
+ const activities = allActivities(await loadState());
853
+ const aborted = activities.find((activity) => activity.type === 'runtime.aborted');
854
+ assert.equal(aborted?.payload?.['reason'], 'idle_timeout');
855
+ assert.equal(aborted?.payload?.['timeoutMs'], 200);
856
+ assert.equal(activities.some((activity) => activity.type === 'runtime.event' && activity.payload?.['eventType'] === 'provider.crash.retry'), false);
857
+ });
858
+ }
859
+ finally {
860
+ await worker?.close();
861
+ await rm(stateDir, { force: true, recursive: true });
862
+ }
863
+ });
864
+ test('runtime worker idle watchdog resets on provider activity effects', async () => {
865
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-idle-activity-test-'));
866
+ const runtime = new ActivityBeforeFinishRuntime();
867
+ const coordinator = { agentId: 'scout', stateDir };
868
+ let worker;
869
+ try {
870
+ await withAnimaHome(stateDir, async () => {
871
+ worker = new AgentRuntimeWorker({
872
+ agentId: 'scout',
873
+ agentRuntime: runtime,
874
+ queue: queueFor('scout'),
875
+ idleTimeoutMs: 220,
876
+ pollIntervalMs: 10_000,
877
+ stateDir,
878
+ workerId: 'test-worker',
879
+ }, silentLogger);
880
+ const decision = await enqueueInbox(makeSlackEvent({
881
+ channelId: 'D-user',
882
+ eventId: 'evt-idle-activity',
883
+ teamId: 'T-demo',
884
+ text: 'long running but active',
885
+ ts: '1770000010.000001',
886
+ userId: 'U1',
887
+ }), coordinator);
888
+ const drain = worker.drainOnce();
889
+ assert.equal(await drain, 1);
890
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'completed');
891
+ const activities = allActivities(await loadState());
892
+ assert.ok(activities.some((activity) => activity.type === 'runtime.output'));
893
+ assert.equal(activities.some((activity) => activity.type === 'runtime.aborted'), false);
894
+ });
895
+ }
896
+ finally {
897
+ await worker?.close();
898
+ await rm(stateDir, { force: true, recursive: true });
899
+ }
900
+ });
901
+ test('runtime worker retries provider process crashes and continues same item', async () => {
902
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-provider-retry-test-'));
903
+ const runtime = new CrashThenSuccessRuntime(1);
904
+ const coordinator = { agentId: 'scout', stateDir };
905
+ let worker;
906
+ try {
907
+ await withAnimaHome(stateDir, async () => {
908
+ worker = new AgentRuntimeWorker({
909
+ agentId: 'scout',
910
+ agentRuntime: runtime,
911
+ queue: queueFor('scout'),
912
+ pollIntervalMs: 10_000,
913
+ stateDir,
914
+ workerId: 'test-worker',
915
+ }, silentLogger);
916
+ const decision = await enqueueInbox(makeSlackEvent({
917
+ channelId: 'D-user',
918
+ eventId: 'evt-provider-retry',
919
+ teamId: 'T-demo',
920
+ text: 'recover this',
921
+ ts: '1770000010.000001',
922
+ userId: 'U1',
923
+ }), coordinator);
924
+ const drain = worker.drainOnce();
925
+ assert.equal(await drain, 1);
926
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'completed');
927
+ assert.equal(runtime.calls.length, 2);
928
+ assert.match(runtime.calls[1]?.prompt ?? '', /previous provider process crashed/);
929
+ assert.match(runtime.calls[1]?.prompt ?? '', /Do not repeat completed external side effects/);
930
+ const activities = allActivities(await loadState());
931
+ assert.ok(activities.some((activity) => activity.type === 'runtime.event' && activity.payload?.['eventType'] === 'provider.crash.retry'));
932
+ assert.equal(activities.some((activity) => activity.type === 'runtime.failed'), false);
933
+ });
934
+ }
935
+ finally {
936
+ await worker?.close();
937
+ await rm(stateDir, { force: true, recursive: true });
938
+ }
939
+ });
940
+ test('runtime worker records provider failure after retry exhaustion', async () => {
941
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-provider-retry-exhausted-test-'));
942
+ const runtime = new CrashThenSuccessRuntime(10);
943
+ const coordinator = { agentId: 'scout', stateDir };
944
+ let worker;
945
+ try {
946
+ await withAnimaHome(stateDir, async () => {
947
+ worker = new AgentRuntimeWorker({
948
+ agentId: 'scout',
949
+ agentRuntime: runtime,
950
+ queue: queueFor('scout'),
951
+ pollIntervalMs: 10_000,
952
+ stateDir,
953
+ workerId: 'test-worker',
954
+ }, silentLogger);
955
+ const decision = await enqueueInbox(makeSlackEvent({
956
+ channelId: 'D-user',
957
+ eventId: 'evt-provider-retry-exhausted',
958
+ teamId: 'T-demo',
959
+ text: 'fail this',
960
+ ts: '1770000010.000001',
961
+ userId: 'U1',
962
+ }), coordinator);
963
+ const drain = worker.drainOnce();
964
+ assert.equal(await drain, 1);
965
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'failed');
966
+ assert.equal(runtime.calls.length, 4);
967
+ const activities = allActivities(await loadState());
968
+ assert.equal(activities.filter((activity) => activity.type === 'runtime.event' && activity.payload?.['eventType'] === 'provider.crash.retry').length, 3);
969
+ const failed = activities.find((activity) => activity.type === 'runtime.failed');
970
+ assert.equal(failed?.payload?.['failureSource'], 'provider');
971
+ assert.equal(failed?.payload?.['providerReason'], 'process_crash');
972
+ assert.equal(failed?.payload?.['retryAttempts'], 3);
973
+ assert.equal(failed?.payload?.['maxRetries'], 3);
974
+ assert.equal(failed?.payload?.['retryable'], false);
975
+ });
976
+ }
977
+ finally {
978
+ await worker?.close();
979
+ await rm(stateDir, { force: true, recursive: true });
980
+ }
981
+ });
982
+ test('runtime worker records non-crash provider errors without retrying', async () => {
983
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-worker-provider-noncrash-test-'));
984
+ const runtime = new FatalProviderRuntime();
985
+ const coordinator = { agentId: 'scout', stateDir };
986
+ let worker;
987
+ try {
988
+ await withAnimaHome(stateDir, async () => {
989
+ worker = new AgentRuntimeWorker({
990
+ agentId: 'scout',
991
+ agentRuntime: runtime,
992
+ queue: queueFor('scout'),
993
+ pollIntervalMs: 10_000,
994
+ stateDir,
995
+ workerId: 'test-worker',
996
+ }, silentLogger);
997
+ const decision = await enqueueInbox(makeSlackEvent({
998
+ channelId: 'D-user',
999
+ eventId: 'evt-provider-noncrash',
1000
+ teamId: 'T-demo',
1001
+ text: 'bad key',
1002
+ ts: '1770000010.000001',
1003
+ userId: 'U1',
1004
+ }), coordinator);
1005
+ const drain = worker.drainOnce();
1006
+ assert.equal(await drain, 1);
1007
+ assert.equal((await queueFor('scout').find(decision.ctx.item.id))?.handling.status, 'failed');
1008
+ assert.equal(runtime.calls.length, 1);
1009
+ const activities = allActivities(await loadState());
1010
+ assert.equal(activities.some((activity) => activity.type === 'runtime.event' && activity.payload?.['eventType'] === 'provider.crash.retry'), false);
1011
+ const failed = activities.find((activity) => activity.type === 'runtime.failed');
1012
+ assert.equal(failed?.payload?.['failureSource'], 'provider');
1013
+ assert.equal(failed?.payload?.['providerReason'], 'provider_error');
1014
+ assert.equal(failed?.payload?.['retryable'], false);
1015
+ });
1016
+ }
1017
+ finally {
1018
+ await worker?.close();
1019
+ await rm(stateDir, { force: true, recursive: true });
1020
+ }
1021
+ });