akemon 0.2.24 → 0.2.26

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,6 +1,7 @@
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";
@@ -98,11 +99,11 @@ const LLM_ENGINES = LLM_ENGINES_SET;
98
99
  // Engine execution — delegates to EnginePeripheral (V2 Step 3)
99
100
  // ---------------------------------------------------------------------------
100
101
  /** Unified engine runner — delegates to EnginePeripheral */
101
- function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal) {
102
+ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal, origin, routing) {
102
103
  if (!_engineP) {
103
104
  throw new Error("Engine peripheral not initialized");
104
105
  }
105
- const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal);
106
+ const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal, origin, routing);
106
107
  // Sync trace back to module-level for reporting
107
108
  result.then(() => { lastEngineTrace = _engineP.lastTrace; }).catch(() => { lastEngineTrace = _engineP.lastTrace; });
108
109
  return result;
@@ -111,6 +112,8 @@ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, re
111
112
  // pullFromRelay → see relay-peripheral.ts
112
113
  export async function serve(options) {
113
114
  const workdir = options.workdir || process.cwd();
115
+ // Reclaim stale CLI children from any previous daemon crash before starting.
116
+ await scanAndKillOrphans();
114
117
  // V2: Relay peripheral — unified relay API access
115
118
  const relay = new RelayPeripheral({
116
119
  httpUrl: options.relayHttp || "",
@@ -353,10 +356,25 @@ export async function serve(options) {
353
356
  // hard timeout, and release. The slot release and subprocess kill are both
354
357
  // driven by the same AbortController so a stuck engine can't hold the lock.
355
358
  async function requestCompute(req) {
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);
367
+ }
368
+ catch (err) {
369
+ return { success: false, error: err.message || "User manual slot timeout" };
370
+ }
371
+ }
356
372
  try {
357
373
  await engineQueue.acquire(req.priority, ENGINE_WAIT_DEADLINE_MS);
358
374
  }
359
375
  catch (err) {
376
+ if (isUserManual)
377
+ engineQueue.releaseUserManualSlot();
360
378
  return { success: false, error: err.message || "Engine busy timeout" };
361
379
  }
362
380
  const prompt = req.context
@@ -365,7 +383,7 @@ export async function serve(options) {
365
383
  const abortController = new AbortController();
366
384
  const timer = setTimeout(() => abortController.abort(), ENGINE_EXEC_TIMEOUT_MS);
367
385
  try {
368
- const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal);
386
+ const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal, req.origin, routing);
369
387
  emitTokenUsage(prompt.length, response.length);
370
388
  return { success: true, response };
371
389
  }
@@ -378,6 +396,8 @@ export async function serve(options) {
378
396
  finally {
379
397
  clearTimeout(timer);
380
398
  engineQueue.release();
399
+ if (isUserManual)
400
+ engineQueue.releaseUserManualSlot();
381
401
  }
382
402
  }
383
403
  const moduleCtx = {
@@ -480,9 +500,13 @@ export async function serve(options) {
480
500
  engine: options.engine,
481
501
  modules: loadedModules,
482
502
  }));
483
- // 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.
484
506
  const shutdown = async () => {
485
507
  console.log("[v2] Shutting down...");
508
+ if (_engineP)
509
+ _engineP.killAllChildren();
486
510
  bus.emit(SIG.AGENT_STOP, sig(SIG.AGENT_STOP, {}));
487
511
  for (const m of allModules) {
488
512
  try {
@@ -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
+ });
@@ -13,12 +13,14 @@ import { SIG, sig } from "./types.js";
13
13
  import { selfDir, biosPath, localNow, loadBioState, saveBioState, syncEnergyFromTokens, loadAgentConfig, getDueUserTasks, loadTaskRuns, saveTaskRuns, loadDirectives, buildDirectivesPrompt, appendTaskHistory, notifyOwner, updateHungerDecay, updateNaturalDecay, resetTokenCountIfNewDay, computeSociability, appendBioEvent, bioStatePromptModifier, feedHunger, SHOP_ITEMS, logBioStatus, logBioDecision, } from "./self.js";
14
14
  import { appendMessage } from "./context.js";
15
15
  import { buildRoleContext } from "./role-module.js";
16
+ import { sortByQuadrant, dedupeWorkItems, computeRetryDelay } from "./task-helpers.js";
17
+ import { updateMetrics } from "./metrics.js";
18
+ import { downgradeForRetry } from "./engine-routing.js";
16
19
  // ---------------------------------------------------------------------------
17
20
  // Config
18
21
  // ---------------------------------------------------------------------------
19
22
  const INITIAL_DELAY = 60_000; // 1 min after startup
20
23
  const POLL_INTERVAL = 30_000; // 30s between polls
21
- const RETRY_INTERVALS = [0, 30_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000];
22
24
  const USER_TASK_MAX_RETRIES = 2;
23
25
  const USER_TASK_RETRY_DELAY = 2 * 60_000;
24
26
  export class TaskModule {
@@ -33,6 +35,8 @@ export class TaskModule {
33
35
  userTaskRetry = new Map();
34
36
  gaveUp = new Set();
35
37
  executing = new Set(); // orders currently being fulfilled
38
+ // Dedup guard: track which orders already had their User message written
39
+ orderUserWritten = new Set();
36
40
  // Push notification support
37
41
  urgentOrderIds = new Set();
38
42
  triggerWorkFn = null;
@@ -82,6 +86,7 @@ export class TaskModule {
82
86
  module: "task",
83
87
  pendingRetries: this.orderRetry.size + this.userTaskRetry.size,
84
88
  gaveUp: this.gaveUp.size,
89
+ executing: this.executing.size,
85
90
  };
86
91
  }
87
92
  /** Push-notify an urgent order (bypasses poll interval) */
@@ -140,9 +145,12 @@ export class TaskModule {
140
145
  if (retry && Date.now() < retry.nextAt)
141
146
  continue;
142
147
  const urgent = this.urgentOrderIds.has(order.id);
148
+ const isRetry = this.orderRetry.has(order.id);
149
+ const rawOrigin = order.human_origin ? "user_manual" : "platform";
143
150
  queue.push({
144
151
  type: "order", id: order.id,
145
152
  quadrant: urgent ? 1 : 2, // orders are important (paid work)
153
+ origin: isRetry ? downgradeForRetry(rawOrigin) : rawOrigin,
146
154
  data: order,
147
155
  });
148
156
  }
@@ -162,6 +170,7 @@ export class TaskModule {
162
170
  queue.push({
163
171
  type: "user_task", id: taskKey,
164
172
  quadrant: rt ? 1 : 2, // retries are urgent+important
173
+ origin: rt ? downgradeForRetry("user_manual") : "user_manual",
165
174
  data: task,
166
175
  });
167
176
  }
@@ -176,6 +185,7 @@ export class TaskModule {
176
185
  queue.push({
177
186
  type: "relay_task", id: task.id,
178
187
  quadrant: 3, // urgent but less important
188
+ origin: "platform",
179
189
  data: task,
180
190
  });
181
191
  }
@@ -197,16 +207,9 @@ export class TaskModule {
197
207
  }
198
208
  logBioStatus(bio, "work-active");
199
209
  // --- Eisenhower sort: Q1 > Q2 > Q3 > Q4 ---
200
- queue.sort((a, b) => a.quadrant - b.quadrant);
210
+ const sorted = sortByQuadrant(queue);
201
211
  // Dedup
202
- const seen = new Set();
203
- const deduped = queue.filter(item => {
204
- const key = `${item.type}:${item.id}`;
205
- if (seen.has(key))
206
- return false;
207
- seen.add(key);
208
- return true;
209
- });
212
+ const deduped = dedupeWorkItems(sorted);
210
213
  // Bio filtering (fear & boredom)
211
214
  const filtered = await this.applyBioFilter(deduped, bio);
212
215
  if (!filtered.length)
@@ -215,18 +218,20 @@ export class TaskModule {
215
218
  // Execute sequentially
216
219
  for (const item of filtered) {
217
220
  try {
218
- if (item.type === "order")
221
+ if (item.type === "order") {
219
222
  this.executing.add(item.id);
223
+ updateMetrics({ task_executing: this.executing.size });
224
+ }
220
225
  switch (item.type) {
221
226
  case "order":
222
- await this.executeOrder(item.data);
227
+ await this.executeOrder(item.data, item.origin);
223
228
  this.urgentOrderIds.delete(item.id);
224
229
  break;
225
230
  case "user_task":
226
- await this.executeUserTask(item.data);
231
+ await this.executeUserTask(item.data, item.origin);
227
232
  break;
228
233
  case "relay_task":
229
- await this.executeRelayTask(item.data);
234
+ await this.executeRelayTask(item.data, item.origin);
230
235
  break;
231
236
  }
232
237
  }
@@ -234,8 +239,13 @@ export class TaskModule {
234
239
  console.log(`[task] Error processing ${item.type}:${item.id}: ${err.message}`);
235
240
  }
236
241
  finally {
237
- if (item.type === "order")
242
+ if (item.type === "order") {
238
243
  this.executing.delete(item.id);
244
+ updateMetrics({
245
+ task_executing: this.executing.size,
246
+ task_pending_retries: this.orderRetry.size + this.userTaskRetry.size,
247
+ });
248
+ }
239
249
  }
240
250
  }
241
251
  bus.emit(SIG.CYCLE_END, sig(SIG.CYCLE_END, { ts: Date.now() }));
@@ -291,7 +301,7 @@ export class TaskModule {
291
301
  // ---------------------------------------------------------------------------
292
302
  // Execute order
293
303
  // ---------------------------------------------------------------------------
294
- async executeOrder(order) {
304
+ async executeOrder(order, origin = "platform") {
295
305
  if (!this.ctx)
296
306
  return;
297
307
  const { workdir, agentName, bus } = this.ctx;
@@ -332,21 +342,25 @@ Steps:
332
342
 
333
343
  RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
334
344
  // Write user message to conversation immediately (before engine runs)
335
- // buyer_name = agent name (from JOIN), buyer_ip = publisher ID or IP
336
- const orderBuyer = order.buyer_name || order.buyer_ip || "anonymous";
337
- // Product orders get isolated conversations; ad-hoc chats share one conv per buyer
338
- const buyerPubId = orderBuyer;
345
+ // Bug 1 fix: buyer_ip = actual publisherId (despite the name); buyer_name = display name only.
346
+ // Must use buyer_ip to match the MCP chat path convId (pub_<publisherId>).
347
+ const buyerPubId = order.buyer_ip || order.buyer_name || "anonymous";
339
348
  const productScope = order.product_id ? `:prod_${order.product_id}` : "";
340
349
  const orderConvId = `pub_${buyerPubId}${productScope}`;
341
350
  const orderUserMsg = order.buyer_task || "(no message)";
342
- await appendMessage(workdir, agentName, orderConvId, "User", orderUserMsg);
343
- console.log(`[task] Fulfilling order ${order.id}...`);
351
+ // Bug 3 fix: only write User message once per order (retries must not duplicate it)
352
+ if (!this.orderUserWritten.has(order.id)) {
353
+ this.orderUserWritten.add(order.id);
354
+ await appendMessage(workdir, agentName, orderConvId, "User", orderUserMsg, "order");
355
+ }
356
+ console.log(`[task] Fulfilling order ${order.id}... (origin=${origin})`);
344
357
  const result = await this.ctx.requestCompute({
345
358
  context,
346
359
  question,
347
360
  priority: "high",
348
361
  tools: ["Bash(curl *)"],
349
362
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
363
+ origin,
350
364
  });
351
365
  if (!result.success)
352
366
  throw new Error(result.error || "compute failed");
@@ -359,7 +373,8 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
359
373
  const orderAgentMsg = (finalStatus.result_text || result.response || "").slice(0, 2000);
360
374
  console.log(`[task] Order ${order.id} delivered`);
361
375
  this.orderRetry.delete(order.id);
362
- await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg);
376
+ this.orderUserWritten.delete(order.id);
377
+ await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg, "order");
363
378
  await appendTaskHistory(workdir, agentName, { ts: localNow(), id: order.id, type: "order", status: "success", duration_ms: duration, output_summary: (result.response || "").slice(0, 500) });
364
379
  await notifyOwner(nurl, `${agentName}: order done`, `Order ${order.id} delivered`, "default", ["package"]);
365
380
  bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: true, taskLabel: orderLabel, creditsEarned: orderPrice, productName: order.product_name }));
@@ -371,7 +386,8 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
371
386
  const orderAgentMsg = (result.response || "").slice(0, 2000);
372
387
  console.log(`[task] Delivered order ${order.id} (fallback)`);
373
388
  this.orderRetry.delete(order.id);
374
- await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg);
389
+ this.orderUserWritten.delete(order.id);
390
+ await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg, "order");
375
391
  await appendTaskHistory(workdir, agentName, { ts: localNow(), id: order.id, type: "order", status: "success", duration_ms: duration, output_summary: result.response.slice(0, 500) });
376
392
  await notifyOwner(nurl, `${agentName}: order done`, `Order ${order.id}: ${result.response.slice(0, 200)}`, "default", ["package"]);
377
393
  bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: true, taskLabel: orderLabel, creditsEarned: orderPrice, productName: order.product_name }));
@@ -400,10 +416,11 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
400
416
  // Retry logic
401
417
  const current = this.orderRetry.get(order.id) || { count: 0, nextAt: 0 };
402
418
  current.count++;
403
- if (current.count < RETRY_INTERVALS.length) {
404
- current.nextAt = Date.now() + RETRY_INTERVALS[current.count];
419
+ const delay = computeRetryDelay(current.count);
420
+ if (delay !== null) {
421
+ current.nextAt = Date.now() + delay;
405
422
  this.orderRetry.set(order.id, current);
406
- console.log(`[task] Retry ${order.id} in ${RETRY_INTERVALS[current.count] / 1000}s`);
423
+ console.log(`[task] Retry ${order.id} in ${delay / 1000}s`);
407
424
  try {
408
425
  await relay.extendOrder(order.id);
409
426
  }
@@ -411,6 +428,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
411
428
  }
412
429
  else {
413
430
  this.orderRetry.delete(order.id);
431
+ this.orderUserWritten.delete(order.id);
414
432
  this.gaveUp.add(order.id);
415
433
  bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: false, taskLabel: orderLabel }));
416
434
  try {
@@ -423,7 +441,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
423
441
  // ---------------------------------------------------------------------------
424
442
  // Execute user task
425
443
  // ---------------------------------------------------------------------------
426
- async executeUserTask(task) {
444
+ async executeUserTask(task, origin = "user_manual") {
427
445
  if (!this.ctx)
428
446
  return;
429
447
  const { workdir, agentName, bus } = this.ctx;
@@ -458,6 +476,7 @@ Your personal directory: ${sd}/`;
458
476
  priority: "high",
459
477
  tools: ["Bash(curl *)"],
460
478
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
479
+ origin,
461
480
  });
462
481
  if (!result.success)
463
482
  throw new Error(result.error || "compute failed");
@@ -483,8 +502,9 @@ Your personal directory: ${sd}/`;
483
502
  relay?.reportLog("user_task", taskKey, "failed", err.message, []);
484
503
  const retry = this.userTaskRetry.get(taskKey) || { count: 0, nextAt: 0 };
485
504
  retry.count++;
486
- if (retry.count <= USER_TASK_MAX_RETRIES) {
487
- retry.nextAt = Date.now() + USER_TASK_RETRY_DELAY;
505
+ const retryDelay = computeRetryDelay(retry.count);
506
+ if (retryDelay !== null) {
507
+ retry.nextAt = Date.now() + retryDelay;
488
508
  this.userTaskRetry.set(taskKey, retry);
489
509
  await appendTaskHistory(workdir, agentName, {
490
510
  ts: localNow(), id: taskKey, type: "user_task", status: "retry",
@@ -508,7 +528,7 @@ Your personal directory: ${sd}/`;
508
528
  // ---------------------------------------------------------------------------
509
529
  // Execute relay platform task
510
530
  // ---------------------------------------------------------------------------
511
- async executeRelayTask(task) {
531
+ async executeRelayTask(task, origin = "platform") {
512
532
  if (!this.ctx)
513
533
  return;
514
534
  const { workdir, agentName, bus } = this.ctx;
@@ -560,6 +580,7 @@ Complete this task. Use the environment info above and tools (curl, etc.) as nee
560
580
  priority: "low",
561
581
  tools: ["Bash(curl *)"],
562
582
  relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
583
+ origin,
563
584
  });
564
585
  if (!result.success)
565
586
  throw new Error(result.error || "compute failed");