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.
- package/dist/engine-peripheral.js +118 -42
- package/dist/engine-queue.js +143 -0
- package/dist/engine-queue.test.js +99 -0
- package/dist/engine-routing.js +52 -0
- package/dist/engine-routing.test.js +122 -0
- package/dist/mcp-server.js +18 -23
- package/dist/memory-module.js +2 -0
- package/dist/metrics.js +30 -0
- package/dist/orphan-scan.js +79 -0
- package/dist/orphan-scan.test.js +81 -0
- package/dist/reflection-module.integration.test.js +180 -0
- package/dist/reflection-module.js +27 -29
- package/dist/reflection-module.test.js +66 -0
- package/dist/relay-client.js +17 -1
- package/dist/role-module.js +2 -2
- package/dist/role-module.test.js +208 -0
- package/dist/script-module.js +1 -0
- package/dist/server.js +68 -38
- package/dist/task-helpers.js +26 -0
- package/dist/task-helpers.test.js +88 -0
- package/dist/task-module.js +38 -25
- package/package.json +3 -2
|
@@ -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
|
+
});
|
package/dist/script-module.js
CHANGED
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 —
|
|
11
|
-
|
|
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: () =>
|
|
150
|
-
setEngineBusy: (busy) => {
|
|
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:
|
|
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
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
return { success: false, error: "
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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: () =>
|
|
509
|
-
setEngineBusy: (busy) => {
|
|
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
|
+
});
|