akemon 0.2.23 → 0.2.25

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.
@@ -0,0 +1,208 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { parseRole, parseProduct, resolveRoles, resolveProduct, buildRoleContext, } from "./role-module.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ function makeRole(name, triggers = [], extras = {}) {
11
+ return { name, description: "", triggers, include: [], exclude: [], customRules: "", raw: "", ...extras };
12
+ }
13
+ function makeProduct(name, extras = {}) {
14
+ return { name, playbook: "", productIds: [], raw: "", ...extras };
15
+ }
16
+ function makePlaybook(name, raw = "") {
17
+ return { name, raw };
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // Pure parsing — parseRole
21
+ // ---------------------------------------------------------------------------
22
+ describe("parseRole — pure parsing", () => {
23
+ it("extracts description, triggers, include, exclude from standard role md", () => {
24
+ const raw = `# Sales Rep
25
+
26
+ Expert in closing deals and building client relationships.
27
+
28
+ ## 激活
29
+ - trigger:order
30
+ - trigger:chat:public
31
+
32
+ ## 上下文范围
33
+ include: buyer history, product catalog
34
+ exclude: owner notes, private diary
35
+ `;
36
+ const role = parseRole("sales-rep", raw);
37
+ assert.equal(role.name, "sales-rep");
38
+ assert.equal(role.description, "Expert in closing deals and building client relationships.");
39
+ assert.deepEqual(role.triggers, ["trigger:order", "trigger:chat:public"]);
40
+ assert.deepEqual(role.include, ["buyer history", "product catalog"]);
41
+ assert.deepEqual(role.exclude, ["owner notes", "private diary"]);
42
+ });
43
+ it("returns empty arrays for include/exclude/triggers when sections are missing", () => {
44
+ const raw = `# Simple Role
45
+
46
+ A minimal role with no activation or context sections.
47
+ `;
48
+ const role = parseRole("simple", raw);
49
+ assert.deepEqual(role.triggers, []);
50
+ assert.deepEqual(role.include, []);
51
+ assert.deepEqual(role.exclude, []);
52
+ assert.equal(role.description, "A minimal role with no activation or context sections.");
53
+ });
54
+ it("collects content outside 激活 and 上下文范围 into customRules", () => {
55
+ const raw = `# Worker
56
+
57
+ Efficient task executor.
58
+
59
+ ## 激活
60
+ - trigger:agent_call
61
+
62
+ ## Operating Principles
63
+ Always reply in the language the user writes in.
64
+ Be concise and accurate.
65
+
66
+ ## Tone
67
+ Professional and neutral.
68
+ `;
69
+ const role = parseRole("worker", raw);
70
+ assert.ok(role.customRules.includes("Always reply in the language the user writes in."), "should include content from ## Operating Principles");
71
+ assert.ok(role.customRules.includes("Professional and neutral."), "should include content from ## Tone");
72
+ // triggers and standard sections should not bleed in
73
+ assert.deepEqual(role.triggers, ["trigger:agent_call"]);
74
+ });
75
+ });
76
+ // ---------------------------------------------------------------------------
77
+ // Pure parsing — parseProduct
78
+ // ---------------------------------------------------------------------------
79
+ describe("parseProduct — pure parsing", () => {
80
+ it("extracts playbook and multiple productIds correctly", () => {
81
+ const raw = `# Widget Pro
82
+
83
+ ## playbook
84
+ widget-strategy
85
+
86
+ ## products
87
+ - p_abc123
88
+ - p_def456
89
+ - p_ghi789
90
+ `;
91
+ const product = parseProduct("widget-pro", raw);
92
+ assert.equal(product.name, "widget-pro");
93
+ assert.equal(product.playbook, "widget-strategy");
94
+ assert.deepEqual(product.productIds, ["p_abc123", "p_def456", "p_ghi789"]);
95
+ });
96
+ it("returns empty productIds when ## products section is absent", () => {
97
+ const raw = `# No Products
98
+
99
+ ## playbook
100
+ some-playbook
101
+ `;
102
+ const product = parseProduct("no-products", raw);
103
+ assert.deepEqual(product.productIds, []);
104
+ assert.equal(product.playbook, "some-playbook");
105
+ });
106
+ it("returns empty string for playbook when ## playbook section is absent", () => {
107
+ const raw = `# No Playbook
108
+
109
+ ## products
110
+ - p_xyz
111
+ `;
112
+ const product = parseProduct("no-playbook", raw);
113
+ assert.equal(product.playbook, "");
114
+ assert.deepEqual(product.productIds, ["p_xyz"]);
115
+ });
116
+ });
117
+ // ---------------------------------------------------------------------------
118
+ // Resolution — resolveRoles
119
+ // ---------------------------------------------------------------------------
120
+ describe("resolveRoles — resolution", () => {
121
+ it("partial trigger match: first match is primary, remaining are secondary", () => {
122
+ const roles = [
123
+ makeRole("general-chat", ["trigger:chat"]),
124
+ makeRole("owner-companion", ["trigger:chat:owner"]),
125
+ ];
126
+ // "trigger:chat:owner" includes "trigger:chat" → both match
127
+ const { primary, secondary } = resolveRoles(roles, "trigger:chat:owner");
128
+ assert.equal(primary?.name, "general-chat");
129
+ assert.equal(secondary.length, 1);
130
+ assert.equal(secondary[0].name, "owner-companion");
131
+ });
132
+ it("returns primary=null and empty secondary when no trigger matches", () => {
133
+ const roles = [
134
+ makeRole("merchant", ["trigger:order"]),
135
+ makeRole("companion", ["trigger:chat:owner"]),
136
+ ];
137
+ const { primary, secondary } = resolveRoles(roles, "trigger:nonexistent");
138
+ assert.equal(primary, null);
139
+ assert.deepEqual(secondary, []);
140
+ });
141
+ });
142
+ // ---------------------------------------------------------------------------
143
+ // Resolution — resolveProduct
144
+ // ---------------------------------------------------------------------------
145
+ describe("resolveProduct — resolution", () => {
146
+ const products = [
147
+ makeProduct("Alpha Product", { productIds: ["p_alpha"], playbook: "alpha-pb" }),
148
+ makeProduct("Beta Service", { productIds: ["p_beta"], playbook: "beta-pb" }),
149
+ ];
150
+ const playbooks = [
151
+ makePlaybook("alpha-pb", "# Alpha Playbook\nContent."),
152
+ makePlaybook("beta-pb", "# Beta Playbook\nContent."),
153
+ ];
154
+ it("productId takes priority over productName when both are provided", () => {
155
+ // productId matches "Alpha Product", productName matches "Beta Service"
156
+ const result = resolveProduct(products, playbooks, "Beta Service", "p_alpha");
157
+ assert.ok(result !== null);
158
+ assert.equal(result.product.name, "Alpha Product");
159
+ });
160
+ it("fuzzy name match is case- and separator-insensitive", () => {
161
+ // "alpha_product" → normalized "alphaproduct"
162
+ // "Alpha Product" → normalized "alphaproduct" → exact match
163
+ const result = resolveProduct(products, playbooks, "alpha_product");
164
+ assert.ok(result !== null);
165
+ assert.equal(result.product.name, "Alpha Product");
166
+ });
167
+ it("returns null when neither productId nor productName matches", () => {
168
+ const result = resolveProduct(products, playbooks, "Completely Unknown");
169
+ assert.equal(result, null);
170
+ });
171
+ it("playbook lookup is case-insensitive", () => {
172
+ const prods = [makeProduct("my-widget", { playbook: "My-Playbook", productIds: [] })];
173
+ const pbs = [makePlaybook("my-playbook", "# PB Content")];
174
+ const result = resolveProduct(prods, pbs, "my-widget");
175
+ assert.ok(result !== null);
176
+ assert.ok(result.playbook !== null);
177
+ assert.equal(result.playbook?.name, "my-playbook");
178
+ });
179
+ });
180
+ // ---------------------------------------------------------------------------
181
+ // buildRoleContext — real fs with tmp dir
182
+ // ---------------------------------------------------------------------------
183
+ describe("buildRoleContext — fs integration", () => {
184
+ let tmpDir;
185
+ const agentName = "test-agent";
186
+ before(async () => {
187
+ tmpDir = await mkdtemp(join(tmpdir(), "akemon-test-"));
188
+ const selfBase = join(tmpDir, ".akemon", "agents", agentName, "self");
189
+ await mkdir(join(selfBase, "roles"), { recursive: true });
190
+ await mkdir(join(selfBase, "playbooks"), { recursive: true });
191
+ await mkdir(join(selfBase, "products"), { recursive: true });
192
+ await writeFile(join(selfBase, "roles", "worker.md"), `# 工人\n执行任务的专业角色。\n\n## 激活\n- trigger:order\n`);
193
+ await writeFile(join(selfBase, "playbooks", "strategy.md"), `# Strategy\n核心策略内容。\n`);
194
+ await writeFile(join(selfBase, "products", "widget.md"), `# Widget\n\n## playbook\nstrategy\n\n## products\n- p_1234\n`);
195
+ });
196
+ after(async () => {
197
+ await rm(tmpDir, { recursive: true, force: true });
198
+ });
199
+ it("matching trigger produces output with [Active role: ...]", async () => {
200
+ const result = await buildRoleContext(tmpDir, agentName, "trigger:order:001");
201
+ assert.ok(result.includes("[Active role: worker]"), `expected '[Active role: worker]' in:\n${result}`);
202
+ });
203
+ it("passing productName produces output with [Product: ...] and [Playbook: ...]", async () => {
204
+ const result = await buildRoleContext(tmpDir, agentName, "trigger:other", "widget");
205
+ assert.ok(result.includes("[Product: widget]"), `expected '[Product: widget]' in:\n${result}`);
206
+ assert.ok(result.includes("[Playbook: strategy]"), `expected '[Playbook: strategy]' in:\n${result}`);
207
+ });
208
+ });
@@ -273,6 +273,7 @@ export class ScriptModule {
273
273
  priority: "low",
274
274
  tools: ["Bash(curl *)"],
275
275
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
276
+ origin: "self_cycle",
276
277
  });
277
278
  if (result.success) {
278
279
  // Post-process if defined
package/dist/server.js CHANGED
@@ -1,16 +1,22 @@
1
1
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
2
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
3
  import { exec } from "child_process";
4
+ import { scanAndKillOrphans } from "./orphan-scan.js";
4
5
  import { createServer } from "http";
5
6
  import { createInterface } from "readline";
6
7
  import { initWorld, initBioState, initGuide, getSelfState, loadRecentCanvasEntries, initAgentConfig, loadAgentConfig, loadDirectives, loadTaskHistory, reviveAgent, } from "./self.js";
7
8
  // V2: module-level instances (set in serve())
8
9
  let _engineP = null;
9
10
  let _bus = null;
10
- // Engine mutual exclusion — module-level state (unified in Step 6)
11
- let engineBusy = false;
12
- let engineBusySince = 0;
11
+ // Engine mutual exclusion — priority queue so modules can't starve each other
12
+ const engineQueue = new EngineQueue();
13
13
  let lastEngineTrace = [];
14
+ // How long a caller will wait for the engine slot before giving up.
15
+ const ENGINE_WAIT_DEADLINE_MS = 5 * 60 * 1000;
16
+ // Hard cap on a single engine run. Beyond this the subprocess is aborted so
17
+ // the slot is returned to the queue. Shorter than the old 8 min — opencode
18
+ // runs that take longer almost always hang forever.
19
+ const ENGINE_EXEC_TIMEOUT_MS = 3 * 60 * 1000;
14
20
  // ---------------------------------------------------------------------------
15
21
  // V2 Event helpers — emit signals to EventBus
16
22
  // ---------------------------------------------------------------------------
@@ -74,6 +80,7 @@ function promptOwner(task, isHuman) {
74
80
  }
75
81
  import { RelayPeripheral } from "./relay-peripheral.js";
76
82
  import { EnginePeripheral, LLM_ENGINES as LLM_ENGINES_SET } from "./engine-peripheral.js";
83
+ import { EngineQueue } from "./engine-queue.js";
77
84
  import { BioStateModule } from "./bio-module.js";
78
85
  import { MemoryModule } from "./memory-module.js";
79
86
  import { RoleModule } from "./role-module.js";
@@ -92,11 +99,11 @@ const LLM_ENGINES = LLM_ENGINES_SET;
92
99
  // Engine execution — delegates to EnginePeripheral (V2 Step 3)
93
100
  // ---------------------------------------------------------------------------
94
101
  /** Unified engine runner — delegates to EnginePeripheral */
95
- function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay) {
102
+ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal, origin, routing) {
96
103
  if (!_engineP) {
97
104
  throw new Error("Engine peripheral not initialized");
98
105
  }
99
- const result = _engineP.runEngine(task, allowAll, extraAllowedTools);
106
+ const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal, origin, routing);
100
107
  // Sync trace back to module-level for reporting
101
108
  result.then(() => { lastEngineTrace = _engineP.lastTrace; }).catch(() => { lastEngineTrace = _engineP.lastTrace; });
102
109
  return result;
@@ -105,6 +112,8 @@ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, re
105
112
  // pullFromRelay → see relay-peripheral.ts
106
113
  export async function serve(options) {
107
114
  const workdir = options.workdir || process.cwd();
115
+ // Reclaim stale CLI children from any previous daemon crash before starting.
116
+ await scanAndKillOrphans();
108
117
  // V2: Relay peripheral — unified relay API access
109
118
  const relay = new RelayPeripheral({
110
119
  httpUrl: options.relayHttp || "",
@@ -146,8 +155,13 @@ export async function serve(options) {
146
155
  promptOwner,
147
156
  runCollaborativeQuery: (task, selfName, relayHttp, engine, model, allowAll, workdir, relay) => runCollaborativeQuery(task, selfName, relayHttp, engine, model, allowAll, workdir, runEngine, relay),
148
157
  autoRoute,
149
- isEngineBusy: () => engineBusy,
150
- setEngineBusy: (busy) => { engineBusy = busy; engineBusySince = busy ? Date.now() : 0; },
158
+ isEngineBusy: () => engineQueue.isBusy(),
159
+ setEngineBusy: (busy) => {
160
+ if (busy)
161
+ engineQueue.tryAcquire();
162
+ else
163
+ engineQueue.release();
164
+ },
151
165
  emitTaskCompleted,
152
166
  };
153
167
  const httpServer = createServer(async (req, res) => {
@@ -338,45 +352,52 @@ export async function serve(options) {
338
352
  const bus = new SimpleEventBus();
339
353
  // Peripheral registry — Core routes by capability
340
354
  const peripherals = [relay, engineP];
341
- // requestCompute: queue for engine, execute, return result
355
+ // requestCompute: acquire the engine slot (priority-aware), execute with a
356
+ // hard timeout, and release. The slot release and subprocess kill are both
357
+ // driven by the same AbortController so a stuck engine can't hold the lock.
342
358
  async function requestCompute(req) {
343
- // Wait for engine to become free (poll with backoff, max 5 min)
344
- const deadline = Date.now() + 5 * 60 * 1000;
345
- while (engineBusy) {
346
- // If engine has been busy for >10 min, it's stuck force release
347
- if (engineBusySince && Date.now() - engineBusySince > 10 * 60 * 1000) {
348
- console.log(`[engine] Force-releasing stuck engine lock (busy for ${Math.round((Date.now() - engineBusySince) / 60000)}min)`);
349
- engineBusy = false;
350
- engineBusySince = 0;
351
- break;
359
+ // Load latest agent config for routing table (fast file read, changes rarely)
360
+ const agentCfg = await loadAgentConfig(workdir, options.agentName);
361
+ const routing = agentCfg.engine_routing;
362
+ // user_manual tasks also hold a subscription-CLI semaphore to cap claude CLI concurrency
363
+ const isUserManual = req.origin === "user_manual";
364
+ if (isUserManual) {
365
+ try {
366
+ await engineQueue.acquireUserManualSlot(ENGINE_WAIT_DEADLINE_MS);
352
367
  }
353
- if (Date.now() > deadline) {
354
- return { success: false, error: "Engine busy timeout (5 min)" };
368
+ catch (err) {
369
+ return { success: false, error: err.message || "User manual slot timeout" };
355
370
  }
356
- await new Promise(r => setTimeout(r, 2000));
357
371
  }
358
- engineBusy = true;
359
- engineBusySince = Date.now();
360
372
  try {
361
- const prompt = req.context
362
- ? `${req.context}\n\n---\n\n${req.question}`
363
- : req.question;
364
- // Hard timeout: if engine doesn't respond in 8 min, give up
365
- const engineTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Engine execution timeout (8 min)")), 8 * 60 * 1000));
366
- const response = await Promise.race([
367
- runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay),
368
- engineTimeout,
369
- ]);
370
- // Track token usage via EventBus
373
+ await engineQueue.acquire(req.priority, ENGINE_WAIT_DEADLINE_MS);
374
+ }
375
+ catch (err) {
376
+ if (isUserManual)
377
+ engineQueue.releaseUserManualSlot();
378
+ return { success: false, error: err.message || "Engine busy timeout" };
379
+ }
380
+ const prompt = req.context
381
+ ? `${req.context}\n\n---\n\n${req.question}`
382
+ : req.question;
383
+ const abortController = new AbortController();
384
+ const timer = setTimeout(() => abortController.abort(), ENGINE_EXEC_TIMEOUT_MS);
385
+ try {
386
+ const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal, req.origin, routing);
371
387
  emitTokenUsage(prompt.length, response.length);
372
388
  return { success: true, response };
373
389
  }
374
390
  catch (err) {
375
- return { success: false, error: err.message || String(err) };
391
+ const msg = abortController.signal.aborted
392
+ ? `Engine execution timeout (${Math.round(ENGINE_EXEC_TIMEOUT_MS / 60000)} min)`
393
+ : (err.message || String(err));
394
+ return { success: false, error: msg };
376
395
  }
377
396
  finally {
378
- engineBusy = false;
379
- engineBusySince = 0;
397
+ clearTimeout(timer);
398
+ engineQueue.release();
399
+ if (isUserManual)
400
+ engineQueue.releaseUserManualSlot();
380
401
  }
381
402
  }
382
403
  const moduleCtx = {
@@ -479,9 +500,13 @@ export async function serve(options) {
479
500
  engine: options.engine,
480
501
  modules: loadedModules,
481
502
  }));
482
- // Graceful shutdown
503
+ // Graceful shutdown — kill engine children first, then stop modules.
504
+ // Engine children are killed via process group signal so any sub-forks
505
+ // (e.g. opencode's internal Node workers) are also terminated.
483
506
  const shutdown = async () => {
484
507
  console.log("[v2] Shutting down...");
508
+ if (_engineP)
509
+ _engineP.killAllChildren();
485
510
  bus.emit(SIG.AGENT_STOP, sig(SIG.AGENT_STOP, {}));
486
511
  for (const m of allModules) {
487
512
  try {
@@ -505,8 +530,13 @@ export async function serveStdio(agentName, workdir) {
505
530
  promptOwner,
506
531
  runCollaborativeQuery: (task, selfName, relayHttp, engine, model, allowAll, workdir, relay) => runCollaborativeQuery(task, selfName, relayHttp, engine, model, allowAll, workdir, runEngine, relay),
507
532
  autoRoute,
508
- isEngineBusy: () => engineBusy,
509
- setEngineBusy: (busy) => { engineBusy = busy; engineBusySince = busy ? Date.now() : 0; },
533
+ isEngineBusy: () => engineQueue.isBusy(),
534
+ setEngineBusy: (busy) => {
535
+ if (busy)
536
+ engineQueue.tryAcquire();
537
+ else
538
+ engineQueue.release();
539
+ },
510
540
  emitTaskCompleted,
511
541
  };
512
542
  const mcpServer = createMcpServer({ workdir: dir, agentName, publisherIds: new Map() }, stdioMcpDeps);
@@ -0,0 +1,26 @@
1
+ /** Eisenhower sort: smaller quadrant number first */
2
+ export function sortByQuadrant(items) {
3
+ return [...items].sort((a, b) => a.quadrant - b.quadrant);
4
+ }
5
+ /** Dedup by type+id composite key, preserving first occurrence */
6
+ export function dedupeWorkItems(items) {
7
+ const seen = new Set();
8
+ return items.filter(it => {
9
+ const key = `${it.type}:${it.id}`;
10
+ if (seen.has(key))
11
+ return false;
12
+ seen.add(key);
13
+ return true;
14
+ });
15
+ }
16
+ /** Retry intervals for order execution failures */
17
+ export const RETRY_INTERVALS = [0, 30_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000];
18
+ /**
19
+ * Returns next retry delay in ms given the current retry count, or null if exhausted.
20
+ * count=0 → first retry (0ms). count=intervals.length → null (give up).
21
+ */
22
+ export function computeRetryDelay(count, intervals = RETRY_INTERVALS) {
23
+ if (count < 0 || count >= intervals.length)
24
+ return null;
25
+ return intervals[count];
26
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sortByQuadrant, dedupeWorkItems, computeRetryDelay, RETRY_INTERVALS } from "./task-helpers.js";
4
+ describe("sortByQuadrant", () => {
5
+ it("empty array returns empty array", () => {
6
+ assert.deepStrictEqual(sortByQuadrant([]), []);
7
+ });
8
+ it("does not modify the input array", () => {
9
+ const items = [
10
+ { quadrant: 3, id: "a" },
11
+ { quadrant: 1, id: "b" },
12
+ ];
13
+ const original = [...items];
14
+ sortByQuadrant(items);
15
+ assert.deepStrictEqual(items, original, "input array must not be mutated");
16
+ });
17
+ it("sorts ascending by quadrant", () => {
18
+ const items = [
19
+ { quadrant: 4, id: "d" },
20
+ { quadrant: 2, id: "b" },
21
+ { quadrant: 3, id: "c" },
22
+ { quadrant: 1, id: "a" },
23
+ ];
24
+ const result = sortByQuadrant(items);
25
+ assert.deepStrictEqual(result.map(i => i.quadrant), [1, 2, 3, 4]);
26
+ });
27
+ it("is stable: equal-quadrant items preserve original order", () => {
28
+ const items = [
29
+ { quadrant: 2, id: "first" },
30
+ { quadrant: 1, id: "solo" },
31
+ { quadrant: 2, id: "second" },
32
+ ];
33
+ const result = sortByQuadrant(items);
34
+ assert.strictEqual(result[0].id, "solo");
35
+ assert.strictEqual(result[1].id, "first", "first Q2 item should come before second Q2 item");
36
+ assert.strictEqual(result[2].id, "second");
37
+ });
38
+ });
39
+ describe("dedupeWorkItems", () => {
40
+ it("different type, same id are kept (not considered duplicates)", () => {
41
+ const items = [
42
+ { type: "order", id: "abc" },
43
+ { type: "user_task", id: "abc" },
44
+ ];
45
+ const result = dedupeWorkItems(items);
46
+ assert.strictEqual(result.length, 2, "order:abc and user_task:abc must both survive");
47
+ });
48
+ it("same type+id: only first occurrence is kept", () => {
49
+ const items = [
50
+ { type: "order", id: "x", extra: 1 },
51
+ { type: "order", id: "x", extra: 2 },
52
+ ];
53
+ const result = dedupeWorkItems(items);
54
+ assert.strictEqual(result.length, 1);
55
+ assert.strictEqual(result[0].extra, 1, "first occurrence must be preserved");
56
+ });
57
+ it("returns empty array for empty input", () => {
58
+ assert.deepStrictEqual(dedupeWorkItems([]), []);
59
+ });
60
+ });
61
+ describe("computeRetryDelay", () => {
62
+ it("count=0 returns 0 (immediate first retry)", () => {
63
+ assert.strictEqual(computeRetryDelay(0), 0);
64
+ });
65
+ it("count=1 returns 30_000", () => {
66
+ assert.strictEqual(computeRetryDelay(1), 30_000);
67
+ });
68
+ it("count=4 returns 7_200_000 (2h)", () => {
69
+ assert.strictEqual(computeRetryDelay(4), 2 * 3600_000);
70
+ });
71
+ it("count=5 returns null (exhausted)", () => {
72
+ assert.strictEqual(computeRetryDelay(5), null);
73
+ });
74
+ it("count=-1 returns null (negative treated as exhausted)", () => {
75
+ assert.strictEqual(computeRetryDelay(-1), null);
76
+ });
77
+ it("uses RETRY_INTERVALS as default intervals", () => {
78
+ for (let i = 0; i < RETRY_INTERVALS.length; i++) {
79
+ assert.strictEqual(computeRetryDelay(i), RETRY_INTERVALS[i]);
80
+ }
81
+ });
82
+ it("accepts a custom intervals array", () => {
83
+ const custom = [100, 200, 300];
84
+ assert.strictEqual(computeRetryDelay(0, custom), 100);
85
+ assert.strictEqual(computeRetryDelay(2, custom), 300);
86
+ assert.strictEqual(computeRetryDelay(3, custom), null);
87
+ });
88
+ });