@rozek/nanoclaw 0.0.1

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 (306) hide show
  1. package/.claude/settings.json +1 -0
  2. package/.claude/skills/add-compact/SKILL.md +135 -0
  3. package/.claude/skills/add-discord/SKILL.md +203 -0
  4. package/.claude/skills/add-gmail/SKILL.md +220 -0
  5. package/.claude/skills/add-image-vision/SKILL.md +94 -0
  6. package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
  7. package/.claude/skills/add-parallel/SKILL.md +290 -0
  8. package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
  9. package/.claude/skills/add-reactions/SKILL.md +117 -0
  10. package/.claude/skills/add-slack/SKILL.md +207 -0
  11. package/.claude/skills/add-telegram/SKILL.md +222 -0
  12. package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
  13. package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
  14. package/.claude/skills/add-whatsapp/SKILL.md +372 -0
  15. package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
  16. package/.claude/skills/customize/SKILL.md +110 -0
  17. package/.claude/skills/debug/SKILL.md +349 -0
  18. package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
  19. package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
  20. package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
  21. package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
  22. package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
  23. package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
  24. package/.claude/skills/setup/SKILL.md +218 -0
  25. package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
  26. package/.claude/skills/update-skills/SKILL.md +130 -0
  27. package/.claude/skills/use-local-whisper/SKILL.md +152 -0
  28. package/.claude/skills/x-integration/SKILL.md +417 -0
  29. package/.claude/skills/x-integration/agent.ts +243 -0
  30. package/.claude/skills/x-integration/host.ts +159 -0
  31. package/.claude/skills/x-integration/lib/browser.ts +148 -0
  32. package/.claude/skills/x-integration/lib/config.ts +62 -0
  33. package/.claude/skills/x-integration/scripts/like.ts +56 -0
  34. package/.claude/skills/x-integration/scripts/post.ts +66 -0
  35. package/.claude/skills/x-integration/scripts/quote.ts +80 -0
  36. package/.claude/skills/x-integration/scripts/reply.ts +74 -0
  37. package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
  38. package/.claude/skills/x-integration/scripts/setup.ts +87 -0
  39. package/.env.example +1 -0
  40. package/.github/CODEOWNERS +10 -0
  41. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  42. package/.github/workflows/bump-version.yml +32 -0
  43. package/.github/workflows/ci.yml +25 -0
  44. package/.github/workflows/merge-forward-skills.yml +160 -0
  45. package/.github/workflows/update-tokens.yml +42 -0
  46. package/.husky/pre-commit +1 -0
  47. package/.mcp.json +3 -0
  48. package/.nvmrc +1 -0
  49. package/.prettierrc +3 -0
  50. package/CHANGELOG.md +8 -0
  51. package/CLAUDE.md +64 -0
  52. package/CONTRIBUTING.md +23 -0
  53. package/CONTRIBUTORS.md +15 -0
  54. package/LICENSE +21 -0
  55. package/NanoClaw_with_Web-Support.md +325 -0
  56. package/README.md +261 -0
  57. package/README_zh.md +200 -0
  58. package/assets/nanoclaw-favicon.png +0 -0
  59. package/assets/nanoclaw-icon.png +0 -0
  60. package/assets/nanoclaw-logo-dark.png +0 -0
  61. package/assets/nanoclaw-logo.png +0 -0
  62. package/assets/nanoclaw-profile.jpeg +0 -0
  63. package/assets/nanoclaw-sales.png +0 -0
  64. package/assets/social-preview.jpg +0 -0
  65. package/config-examples/mount-allowlist.json +25 -0
  66. package/container/Dockerfile +70 -0
  67. package/container/agent-runner/package.json +21 -0
  68. package/container/agent-runner/src/index.ts +774 -0
  69. package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
  70. package/container/agent-runner/tsconfig.json +15 -0
  71. package/container/build.sh +23 -0
  72. package/container/skills/agent-browser/SKILL.md +159 -0
  73. package/container/skills/capabilities/SKILL.md +100 -0
  74. package/container/skills/cwd/SKILL.md +32 -0
  75. package/container/skills/pwd/SKILL.md +19 -0
  76. package/container/skills/status/SKILL.md +104 -0
  77. package/dist/channels/index.d.ts +2 -0
  78. package/dist/channels/index.d.ts.map +1 -0
  79. package/dist/channels/index.js +10 -0
  80. package/dist/channels/index.js.map +1 -0
  81. package/dist/channels/registry.d.ts +13 -0
  82. package/dist/channels/registry.d.ts.map +1 -0
  83. package/dist/channels/registry.js +11 -0
  84. package/dist/channels/registry.js.map +1 -0
  85. package/dist/channels/registry.test.d.ts +2 -0
  86. package/dist/channels/registry.test.d.ts.map +1 -0
  87. package/dist/channels/registry.test.js +32 -0
  88. package/dist/channels/registry.test.js.map +1 -0
  89. package/dist/channels/web.d.ts +2 -0
  90. package/dist/channels/web.d.ts.map +1 -0
  91. package/dist/channels/web.js +1843 -0
  92. package/dist/channels/web.js.map +1 -0
  93. package/dist/cli.d.ts +11 -0
  94. package/dist/cli.d.ts.map +1 -0
  95. package/dist/cli.js +182 -0
  96. package/dist/cli.js.map +1 -0
  97. package/dist/config.d.ts +19 -0
  98. package/dist/config.d.ts.map +1 -0
  99. package/dist/config.js +36 -0
  100. package/dist/config.js.map +1 -0
  101. package/dist/container-runner.d.ts +44 -0
  102. package/dist/container-runner.d.ts.map +1 -0
  103. package/dist/container-runner.js +511 -0
  104. package/dist/container-runner.js.map +1 -0
  105. package/dist/container-runner.test.d.ts +2 -0
  106. package/dist/container-runner.test.d.ts.map +1 -0
  107. package/dist/container-runner.test.js +150 -0
  108. package/dist/container-runner.test.js.map +1 -0
  109. package/dist/container-runtime.d.ts +22 -0
  110. package/dist/container-runtime.d.ts.map +1 -0
  111. package/dist/container-runtime.js +96 -0
  112. package/dist/container-runtime.js.map +1 -0
  113. package/dist/container-runtime.test.d.ts +2 -0
  114. package/dist/container-runtime.test.d.ts.map +1 -0
  115. package/dist/container-runtime.test.js +93 -0
  116. package/dist/container-runtime.test.js.map +1 -0
  117. package/dist/credential-proxy.d.ts +21 -0
  118. package/dist/credential-proxy.d.ts.map +1 -0
  119. package/dist/credential-proxy.js +95 -0
  120. package/dist/credential-proxy.js.map +1 -0
  121. package/dist/credential-proxy.test.d.ts +2 -0
  122. package/dist/credential-proxy.test.d.ts.map +1 -0
  123. package/dist/credential-proxy.test.js +134 -0
  124. package/dist/credential-proxy.test.js.map +1 -0
  125. package/dist/db.d.ts +115 -0
  126. package/dist/db.d.ts.map +1 -0
  127. package/dist/db.js +549 -0
  128. package/dist/db.js.map +1 -0
  129. package/dist/db.test.d.ts +2 -0
  130. package/dist/db.test.d.ts.map +1 -0
  131. package/dist/db.test.js +360 -0
  132. package/dist/db.test.js.map +1 -0
  133. package/dist/env.d.ts +8 -0
  134. package/dist/env.d.ts.map +1 -0
  135. package/dist/env.js +42 -0
  136. package/dist/env.js.map +1 -0
  137. package/dist/formatting.test.d.ts +2 -0
  138. package/dist/formatting.test.d.ts.map +1 -0
  139. package/dist/formatting.test.js +183 -0
  140. package/dist/formatting.test.js.map +1 -0
  141. package/dist/group-folder.d.ts +5 -0
  142. package/dist/group-folder.d.ts.map +1 -0
  143. package/dist/group-folder.js +44 -0
  144. package/dist/group-folder.js.map +1 -0
  145. package/dist/group-folder.test.d.ts +2 -0
  146. package/dist/group-folder.test.d.ts.map +1 -0
  147. package/dist/group-folder.test.js +29 -0
  148. package/dist/group-folder.test.js.map +1 -0
  149. package/dist/group-queue.d.ts +40 -0
  150. package/dist/group-queue.d.ts.map +1 -0
  151. package/dist/group-queue.js +276 -0
  152. package/dist/group-queue.js.map +1 -0
  153. package/dist/group-queue.test.d.ts +2 -0
  154. package/dist/group-queue.test.d.ts.map +1 -0
  155. package/dist/group-queue.test.js +341 -0
  156. package/dist/group-queue.test.js.map +1 -0
  157. package/dist/index.d.ts +13 -0
  158. package/dist/index.d.ts.map +1 -0
  159. package/dist/index.js +592 -0
  160. package/dist/index.js.map +1 -0
  161. package/dist/ipc-auth.test.d.ts +2 -0
  162. package/dist/ipc-auth.test.d.ts.map +1 -0
  163. package/dist/ipc-auth.test.js +434 -0
  164. package/dist/ipc-auth.test.js.map +1 -0
  165. package/dist/ipc.d.ts +32 -0
  166. package/dist/ipc.d.ts.map +1 -0
  167. package/dist/ipc.js +311 -0
  168. package/dist/ipc.js.map +1 -0
  169. package/dist/logger.d.ts +3 -0
  170. package/dist/logger.d.ts.map +1 -0
  171. package/dist/logger.js +14 -0
  172. package/dist/logger.js.map +1 -0
  173. package/dist/mount-security.d.ts +34 -0
  174. package/dist/mount-security.d.ts.map +1 -0
  175. package/dist/mount-security.js +325 -0
  176. package/dist/mount-security.js.map +1 -0
  177. package/dist/remote-control.d.ts +32 -0
  178. package/dist/remote-control.d.ts.map +1 -0
  179. package/dist/remote-control.js +185 -0
  180. package/dist/remote-control.js.map +1 -0
  181. package/dist/remote-control.test.d.ts +2 -0
  182. package/dist/remote-control.test.d.ts.map +1 -0
  183. package/dist/remote-control.test.js +321 -0
  184. package/dist/remote-control.test.js.map +1 -0
  185. package/dist/router.d.ts +8 -0
  186. package/dist/router.d.ts.map +1 -0
  187. package/dist/router.js +37 -0
  188. package/dist/router.js.map +1 -0
  189. package/dist/routing.test.d.ts +2 -0
  190. package/dist/routing.test.d.ts.map +1 -0
  191. package/dist/routing.test.js +81 -0
  192. package/dist/routing.test.js.map +1 -0
  193. package/dist/sender-allowlist.d.ts +14 -0
  194. package/dist/sender-allowlist.d.ts.map +1 -0
  195. package/dist/sender-allowlist.js +79 -0
  196. package/dist/sender-allowlist.js.map +1 -0
  197. package/dist/sender-allowlist.test.d.ts +2 -0
  198. package/dist/sender-allowlist.test.d.ts.map +1 -0
  199. package/dist/sender-allowlist.test.js +186 -0
  200. package/dist/sender-allowlist.test.js.map +1 -0
  201. package/dist/session-commands.d.ts +47 -0
  202. package/dist/session-commands.d.ts.map +1 -0
  203. package/dist/session-commands.js +104 -0
  204. package/dist/session-commands.js.map +1 -0
  205. package/dist/session-commands.test.d.ts +2 -0
  206. package/dist/session-commands.test.d.ts.map +1 -0
  207. package/dist/session-commands.test.js +194 -0
  208. package/dist/session-commands.test.js.map +1 -0
  209. package/dist/task-scheduler.d.ts +22 -0
  210. package/dist/task-scheduler.d.ts.map +1 -0
  211. package/dist/task-scheduler.js +241 -0
  212. package/dist/task-scheduler.js.map +1 -0
  213. package/dist/task-scheduler.test.d.ts +2 -0
  214. package/dist/task-scheduler.test.d.ts.map +1 -0
  215. package/dist/task-scheduler.test.js +107 -0
  216. package/dist/task-scheduler.test.js.map +1 -0
  217. package/dist/timezone.d.ts +6 -0
  218. package/dist/timezone.d.ts.map +1 -0
  219. package/dist/timezone.js +17 -0
  220. package/dist/timezone.js.map +1 -0
  221. package/dist/timezone.test.d.ts +2 -0
  222. package/dist/timezone.test.d.ts.map +1 -0
  223. package/dist/timezone.test.js +23 -0
  224. package/dist/timezone.test.js.map +1 -0
  225. package/dist/types.d.ts +79 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
  230. package/docs/DEBUG_CHECKLIST.md +143 -0
  231. package/docs/REQUIREMENTS.md +196 -0
  232. package/docs/SDK_DEEP_DIVE.md +643 -0
  233. package/docs/SECURITY.md +122 -0
  234. package/docs/SPEC.md +785 -0
  235. package/docs/docker-sandboxes.md +359 -0
  236. package/docs/nanoclaw-architecture-final.md +1063 -0
  237. package/docs/nanorepo-architecture.md +168 -0
  238. package/docs/skills-as-branches.md +662 -0
  239. package/groups/global/CLAUDE.md +58 -0
  240. package/groups/main/CLAUDE.md +246 -0
  241. package/launchd/com.nanoclaw.plist +32 -0
  242. package/package.json +45 -0
  243. package/repo-tokens/README.md +113 -0
  244. package/repo-tokens/action.yml +186 -0
  245. package/repo-tokens/badge.svg +23 -0
  246. package/repo-tokens/examples/green.svg +14 -0
  247. package/repo-tokens/examples/red.svg +14 -0
  248. package/repo-tokens/examples/yellow-green.svg +14 -0
  249. package/repo-tokens/examples/yellow.svg +14 -0
  250. package/scripts/run-migrations.ts +105 -0
  251. package/setup/container.ts +144 -0
  252. package/setup/environment.test.ts +121 -0
  253. package/setup/environment.ts +94 -0
  254. package/setup/groups.ts +229 -0
  255. package/setup/index.ts +58 -0
  256. package/setup/mounts.ts +115 -0
  257. package/setup/platform.test.ts +120 -0
  258. package/setup/platform.ts +132 -0
  259. package/setup/register.test.ts +257 -0
  260. package/setup/register.ts +177 -0
  261. package/setup/service.test.ts +187 -0
  262. package/setup/service.ts +362 -0
  263. package/setup/status.ts +16 -0
  264. package/setup/verify.ts +192 -0
  265. package/setup.sh +161 -0
  266. package/src/channels/index.ts +15 -0
  267. package/src/channels/registry.test.ts +42 -0
  268. package/src/channels/registry.ts +32 -0
  269. package/src/channels/web.ts +1931 -0
  270. package/src/cli.ts +209 -0
  271. package/src/config.ts +73 -0
  272. package/src/container-runner.test.ts +210 -0
  273. package/src/container-runner.ts +768 -0
  274. package/src/container-runtime.test.ts +149 -0
  275. package/src/container-runtime.ts +127 -0
  276. package/src/credential-proxy.test.ts +192 -0
  277. package/src/credential-proxy.ts +125 -0
  278. package/src/db.test.ts +484 -0
  279. package/src/db.ts +803 -0
  280. package/src/env.ts +42 -0
  281. package/src/formatting.test.ts +256 -0
  282. package/src/group-folder.test.ts +43 -0
  283. package/src/group-folder.ts +44 -0
  284. package/src/group-queue.test.ts +484 -0
  285. package/src/group-queue.ts +379 -0
  286. package/src/index.ts +832 -0
  287. package/src/ipc-auth.test.ts +679 -0
  288. package/src/ipc.ts +461 -0
  289. package/src/logger.ts +16 -0
  290. package/src/mount-security.ts +419 -0
  291. package/src/remote-control.test.ts +397 -0
  292. package/src/remote-control.ts +224 -0
  293. package/src/router.ts +52 -0
  294. package/src/routing.test.ts +170 -0
  295. package/src/sender-allowlist.test.ts +216 -0
  296. package/src/sender-allowlist.ts +128 -0
  297. package/src/session-commands.test.ts +247 -0
  298. package/src/session-commands.ts +163 -0
  299. package/src/task-scheduler.test.ts +129 -0
  300. package/src/task-scheduler.ts +328 -0
  301. package/src/timezone.test.ts +29 -0
  302. package/src/timezone.ts +16 -0
  303. package/src/types.ts +109 -0
  304. package/tsconfig.json +20 -0
  305. package/vitest.config.ts +7 -0
  306. package/vitest.skills.config.ts +7 -0
@@ -0,0 +1,484 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+
3
+ import { GroupQueue } from './group-queue.js';
4
+
5
+ // Mock config to control concurrency limit
6
+ vi.mock('./config.js', () => ({
7
+ DATA_DIR: '/tmp/nanoclaw-test-data',
8
+ MAX_CONCURRENT_CONTAINERS: 2,
9
+ }));
10
+
11
+ // Mock fs operations used by sendMessage/closeStdin
12
+ vi.mock('fs', async () => {
13
+ const actual = await vi.importActual<typeof import('fs')>('fs');
14
+ return {
15
+ ...actual,
16
+ default: {
17
+ ...actual,
18
+ mkdirSync: vi.fn(),
19
+ writeFileSync: vi.fn(),
20
+ renameSync: vi.fn(),
21
+ },
22
+ };
23
+ });
24
+
25
+ describe('GroupQueue', () => {
26
+ let queue: GroupQueue;
27
+
28
+ beforeEach(() => {
29
+ vi.useFakeTimers();
30
+ queue = new GroupQueue();
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ // --- Single group at a time ---
38
+
39
+ it('only runs one container per group at a time', async () => {
40
+ let concurrentCount = 0;
41
+ let maxConcurrent = 0;
42
+
43
+ const processMessages = vi.fn(async (groupJid: string) => {
44
+ concurrentCount++;
45
+ maxConcurrent = Math.max(maxConcurrent, concurrentCount);
46
+ // Simulate async work
47
+ await new Promise((resolve) => setTimeout(resolve, 100));
48
+ concurrentCount--;
49
+ return true;
50
+ });
51
+
52
+ queue.setProcessMessagesFn(processMessages);
53
+
54
+ // Enqueue two messages for the same group
55
+ queue.enqueueMessageCheck('group1@g.us');
56
+ queue.enqueueMessageCheck('group1@g.us');
57
+
58
+ // Advance timers to let the first process complete
59
+ await vi.advanceTimersByTimeAsync(200);
60
+
61
+ // Second enqueue should have been queued, not concurrent
62
+ expect(maxConcurrent).toBe(1);
63
+ });
64
+
65
+ // --- Global concurrency limit ---
66
+
67
+ it('respects global concurrency limit', async () => {
68
+ let activeCount = 0;
69
+ let maxActive = 0;
70
+ const completionCallbacks: Array<() => void> = [];
71
+
72
+ const processMessages = vi.fn(async (groupJid: string) => {
73
+ activeCount++;
74
+ maxActive = Math.max(maxActive, activeCount);
75
+ await new Promise<void>((resolve) => completionCallbacks.push(resolve));
76
+ activeCount--;
77
+ return true;
78
+ });
79
+
80
+ queue.setProcessMessagesFn(processMessages);
81
+
82
+ // Enqueue 3 groups (limit is 2)
83
+ queue.enqueueMessageCheck('group1@g.us');
84
+ queue.enqueueMessageCheck('group2@g.us');
85
+ queue.enqueueMessageCheck('group3@g.us');
86
+
87
+ // Let promises settle
88
+ await vi.advanceTimersByTimeAsync(10);
89
+
90
+ // Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2)
91
+ expect(maxActive).toBe(2);
92
+ expect(activeCount).toBe(2);
93
+
94
+ // Complete one — third should start
95
+ completionCallbacks[0]();
96
+ await vi.advanceTimersByTimeAsync(10);
97
+
98
+ expect(processMessages).toHaveBeenCalledTimes(3);
99
+ });
100
+
101
+ // --- Tasks prioritized over messages ---
102
+
103
+ it('drains tasks before messages for same group', async () => {
104
+ const executionOrder: string[] = [];
105
+ let resolveFirst: () => void;
106
+
107
+ const processMessages = vi.fn(async (groupJid: string) => {
108
+ if (executionOrder.length === 0) {
109
+ // First call: block until we release it
110
+ await new Promise<void>((resolve) => {
111
+ resolveFirst = resolve;
112
+ });
113
+ }
114
+ executionOrder.push('messages');
115
+ return true;
116
+ });
117
+
118
+ queue.setProcessMessagesFn(processMessages);
119
+
120
+ // Start processing messages (takes the active slot)
121
+ queue.enqueueMessageCheck('group1@g.us');
122
+ await vi.advanceTimersByTimeAsync(10);
123
+
124
+ // While active, enqueue both a task and pending messages
125
+ const taskFn = vi.fn(async () => {
126
+ executionOrder.push('task');
127
+ });
128
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
129
+ queue.enqueueMessageCheck('group1@g.us');
130
+
131
+ // Release the first processing
132
+ resolveFirst!();
133
+ await vi.advanceTimersByTimeAsync(10);
134
+
135
+ // Task should have run before the second message check
136
+ expect(executionOrder[0]).toBe('messages'); // first call
137
+ expect(executionOrder[1]).toBe('task'); // task runs first in drain
138
+ // Messages would run after task completes
139
+ });
140
+
141
+ // --- Retry with backoff on failure ---
142
+
143
+ it('retries with exponential backoff on failure', async () => {
144
+ let callCount = 0;
145
+
146
+ const processMessages = vi.fn(async () => {
147
+ callCount++;
148
+ return false; // failure
149
+ });
150
+
151
+ queue.setProcessMessagesFn(processMessages);
152
+ queue.enqueueMessageCheck('group1@g.us');
153
+
154
+ // First call happens immediately
155
+ await vi.advanceTimersByTimeAsync(10);
156
+ expect(callCount).toBe(1);
157
+
158
+ // First retry after 5000ms (BASE_RETRY_MS * 2^0)
159
+ await vi.advanceTimersByTimeAsync(5000);
160
+ await vi.advanceTimersByTimeAsync(10);
161
+ expect(callCount).toBe(2);
162
+
163
+ // Second retry after 10000ms (BASE_RETRY_MS * 2^1)
164
+ await vi.advanceTimersByTimeAsync(10000);
165
+ await vi.advanceTimersByTimeAsync(10);
166
+ expect(callCount).toBe(3);
167
+ });
168
+
169
+ // --- Shutdown prevents new enqueues ---
170
+
171
+ it('prevents new enqueues after shutdown', async () => {
172
+ const processMessages = vi.fn(async () => true);
173
+ queue.setProcessMessagesFn(processMessages);
174
+
175
+ await queue.shutdown(1000);
176
+
177
+ queue.enqueueMessageCheck('group1@g.us');
178
+ await vi.advanceTimersByTimeAsync(100);
179
+
180
+ expect(processMessages).not.toHaveBeenCalled();
181
+ });
182
+
183
+ // --- Max retries exceeded ---
184
+
185
+ it('stops retrying after MAX_RETRIES and resets', async () => {
186
+ let callCount = 0;
187
+
188
+ const processMessages = vi.fn(async () => {
189
+ callCount++;
190
+ return false; // always fail
191
+ });
192
+
193
+ queue.setProcessMessagesFn(processMessages);
194
+ queue.enqueueMessageCheck('group1@g.us');
195
+
196
+ // Run through all 5 retries (MAX_RETRIES = 5)
197
+ // Initial call
198
+ await vi.advanceTimersByTimeAsync(10);
199
+ expect(callCount).toBe(1);
200
+
201
+ // Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms
202
+ const retryDelays = [5000, 10000, 20000, 40000, 80000];
203
+ for (let i = 0; i < retryDelays.length; i++) {
204
+ await vi.advanceTimersByTimeAsync(retryDelays[i] + 10);
205
+ expect(callCount).toBe(i + 2);
206
+ }
207
+
208
+ // After 5 retries (6 total calls), should stop — no more retries
209
+ const countAfterMaxRetries = callCount;
210
+ await vi.advanceTimersByTimeAsync(200000); // Wait a long time
211
+ expect(callCount).toBe(countAfterMaxRetries);
212
+ });
213
+
214
+ // --- Waiting groups get drained when slots free up ---
215
+
216
+ it('drains waiting groups when active slots free up', async () => {
217
+ const processed: string[] = [];
218
+ const completionCallbacks: Array<() => void> = [];
219
+
220
+ const processMessages = vi.fn(async (groupJid: string) => {
221
+ processed.push(groupJid);
222
+ await new Promise<void>((resolve) => completionCallbacks.push(resolve));
223
+ return true;
224
+ });
225
+
226
+ queue.setProcessMessagesFn(processMessages);
227
+
228
+ // Fill both slots
229
+ queue.enqueueMessageCheck('group1@g.us');
230
+ queue.enqueueMessageCheck('group2@g.us');
231
+ await vi.advanceTimersByTimeAsync(10);
232
+
233
+ // Queue a third
234
+ queue.enqueueMessageCheck('group3@g.us');
235
+ await vi.advanceTimersByTimeAsync(10);
236
+
237
+ expect(processed).toEqual(['group1@g.us', 'group2@g.us']);
238
+
239
+ // Free up a slot
240
+ completionCallbacks[0]();
241
+ await vi.advanceTimersByTimeAsync(10);
242
+
243
+ expect(processed).toContain('group3@g.us');
244
+ });
245
+
246
+ // --- Running task dedup (Issue #138) ---
247
+
248
+ it('rejects duplicate enqueue of a currently-running task', async () => {
249
+ let resolveTask: () => void;
250
+ let taskCallCount = 0;
251
+
252
+ const taskFn = vi.fn(async () => {
253
+ taskCallCount++;
254
+ await new Promise<void>((resolve) => {
255
+ resolveTask = resolve;
256
+ });
257
+ });
258
+
259
+ // Start the task (runs immediately — slot available)
260
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
261
+ await vi.advanceTimersByTimeAsync(10);
262
+ expect(taskCallCount).toBe(1);
263
+
264
+ // Scheduler poll re-discovers the same task while it's running —
265
+ // this must be silently dropped
266
+ const dupFn = vi.fn(async () => {});
267
+ queue.enqueueTask('group1@g.us', 'task-1', dupFn);
268
+ await vi.advanceTimersByTimeAsync(10);
269
+
270
+ // Duplicate was NOT queued
271
+ expect(dupFn).not.toHaveBeenCalled();
272
+
273
+ // Complete the original task
274
+ resolveTask!();
275
+ await vi.advanceTimersByTimeAsync(10);
276
+
277
+ // Only one execution total
278
+ expect(taskCallCount).toBe(1);
279
+ });
280
+
281
+ // --- Idle preemption ---
282
+
283
+ it('does NOT preempt active container when not idle', async () => {
284
+ const fs = await import('fs');
285
+ let resolveProcess: () => void;
286
+
287
+ const processMessages = vi.fn(async () => {
288
+ await new Promise<void>((resolve) => {
289
+ resolveProcess = resolve;
290
+ });
291
+ return true;
292
+ });
293
+
294
+ queue.setProcessMessagesFn(processMessages);
295
+
296
+ // Start processing (takes the active slot)
297
+ queue.enqueueMessageCheck('group1@g.us');
298
+ await vi.advanceTimersByTimeAsync(10);
299
+
300
+ // Register a process so closeStdin has a groupFolder
301
+ queue.registerProcess(
302
+ 'group1@g.us',
303
+ {} as any,
304
+ 'container-1',
305
+ 'test-group',
306
+ );
307
+
308
+ // Enqueue a task while container is active but NOT idle
309
+ const taskFn = vi.fn(async () => {});
310
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
311
+
312
+ // _close should NOT have been written (container is working, not idle)
313
+ const writeFileSync = vi.mocked(fs.default.writeFileSync);
314
+ const closeWrites = writeFileSync.mock.calls.filter(
315
+ (call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
316
+ );
317
+ expect(closeWrites).toHaveLength(0);
318
+
319
+ resolveProcess!();
320
+ await vi.advanceTimersByTimeAsync(10);
321
+ });
322
+
323
+ it('preempts idle container when task is enqueued', async () => {
324
+ const fs = await import('fs');
325
+ let resolveProcess: () => void;
326
+
327
+ const processMessages = vi.fn(async () => {
328
+ await new Promise<void>((resolve) => {
329
+ resolveProcess = resolve;
330
+ });
331
+ return true;
332
+ });
333
+
334
+ queue.setProcessMessagesFn(processMessages);
335
+
336
+ // Start processing
337
+ queue.enqueueMessageCheck('group1@g.us');
338
+ await vi.advanceTimersByTimeAsync(10);
339
+
340
+ // Register process and mark idle
341
+ queue.registerProcess(
342
+ 'group1@g.us',
343
+ {} as any,
344
+ 'container-1',
345
+ 'test-group',
346
+ );
347
+ queue.notifyIdle('group1@g.us');
348
+
349
+ // Clear previous writes, then enqueue a task
350
+ const writeFileSync = vi.mocked(fs.default.writeFileSync);
351
+ writeFileSync.mockClear();
352
+
353
+ const taskFn = vi.fn(async () => {});
354
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
355
+
356
+ // _close SHOULD have been written (container is idle)
357
+ const closeWrites = writeFileSync.mock.calls.filter(
358
+ (call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
359
+ );
360
+ expect(closeWrites).toHaveLength(1);
361
+
362
+ resolveProcess!();
363
+ await vi.advanceTimersByTimeAsync(10);
364
+ });
365
+
366
+ it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => {
367
+ const fs = await import('fs');
368
+ let resolveProcess: () => void;
369
+
370
+ const processMessages = vi.fn(async () => {
371
+ await new Promise<void>((resolve) => {
372
+ resolveProcess = resolve;
373
+ });
374
+ return true;
375
+ });
376
+
377
+ queue.setProcessMessagesFn(processMessages);
378
+ queue.enqueueMessageCheck('group1@g.us');
379
+ await vi.advanceTimersByTimeAsync(10);
380
+ queue.registerProcess(
381
+ 'group1@g.us',
382
+ {} as any,
383
+ 'container-1',
384
+ 'test-group',
385
+ );
386
+
387
+ // Container becomes idle
388
+ queue.notifyIdle('group1@g.us');
389
+
390
+ // A new user message arrives — resets idleWaiting
391
+ queue.sendMessage('group1@g.us', 'hello');
392
+
393
+ // Task enqueued after message reset — should NOT preempt (agent is working)
394
+ const writeFileSync = vi.mocked(fs.default.writeFileSync);
395
+ writeFileSync.mockClear();
396
+
397
+ const taskFn = vi.fn(async () => {});
398
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
399
+
400
+ const closeWrites = writeFileSync.mock.calls.filter(
401
+ (call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
402
+ );
403
+ expect(closeWrites).toHaveLength(0);
404
+
405
+ resolveProcess!();
406
+ await vi.advanceTimersByTimeAsync(10);
407
+ });
408
+
409
+ it('sendMessage returns false for task containers so user messages queue up', async () => {
410
+ let resolveTask: () => void;
411
+
412
+ const taskFn = vi.fn(async () => {
413
+ await new Promise<void>((resolve) => {
414
+ resolveTask = resolve;
415
+ });
416
+ });
417
+
418
+ // Start a task (sets isTaskContainer = true)
419
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
420
+ await vi.advanceTimersByTimeAsync(10);
421
+ queue.registerProcess(
422
+ 'group1@g.us',
423
+ {} as any,
424
+ 'container-1',
425
+ 'test-group',
426
+ );
427
+
428
+ // sendMessage should return false — user messages must not go to task containers
429
+ const result = queue.sendMessage('group1@g.us', 'hello');
430
+ expect(result).toBe(false);
431
+
432
+ resolveTask!();
433
+ await vi.advanceTimersByTimeAsync(10);
434
+ });
435
+
436
+ it('preempts when idle arrives with pending tasks', async () => {
437
+ const fs = await import('fs');
438
+ let resolveProcess: () => void;
439
+
440
+ const processMessages = vi.fn(async () => {
441
+ await new Promise<void>((resolve) => {
442
+ resolveProcess = resolve;
443
+ });
444
+ return true;
445
+ });
446
+
447
+ queue.setProcessMessagesFn(processMessages);
448
+
449
+ // Start processing
450
+ queue.enqueueMessageCheck('group1@g.us');
451
+ await vi.advanceTimersByTimeAsync(10);
452
+
453
+ // Register process and enqueue a task (no idle yet — no preemption)
454
+ queue.registerProcess(
455
+ 'group1@g.us',
456
+ {} as any,
457
+ 'container-1',
458
+ 'test-group',
459
+ );
460
+
461
+ const writeFileSync = vi.mocked(fs.default.writeFileSync);
462
+ writeFileSync.mockClear();
463
+
464
+ const taskFn = vi.fn(async () => {});
465
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
466
+
467
+ let closeWrites = writeFileSync.mock.calls.filter(
468
+ (call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
469
+ );
470
+ expect(closeWrites).toHaveLength(0);
471
+
472
+ // Now container becomes idle — should preempt because task is pending
473
+ writeFileSync.mockClear();
474
+ queue.notifyIdle('group1@g.us');
475
+
476
+ closeWrites = writeFileSync.mock.calls.filter(
477
+ (call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
478
+ );
479
+ expect(closeWrites).toHaveLength(1);
480
+
481
+ resolveProcess!();
482
+ await vi.advanceTimersByTimeAsync(10);
483
+ });
484
+ });