@bolloon/bolloon-agent 0.3.52 → 0.4.2

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 (225) hide show
  1. package/README.md +135 -135
  2. package/bin/bolloon-cli.cjs +183 -183
  3. package/bin/bolloon.cjs +0 -0
  4. package/bin/bolloon.js +157 -0
  5. package/dist/agents/constraint-layer.js +19 -19
  6. package/dist/agents/judgment-protocol.js +14 -14
  7. package/dist/agents/loop-review.js +21 -0
  8. package/dist/agents/pi-sdk-tools.js +86 -2
  9. package/dist/agents/pi-sdk-types.js +12 -12
  10. package/dist/agents/pi-sdk.js +90 -90
  11. package/dist/agents/shell-guard.js +22 -1
  12. package/dist/agents/skill-organizer.js +19 -19
  13. package/dist/agents/verify-recipe.js +84 -0
  14. package/dist/agents/workflow-pivot-loop.js +101 -13
  15. package/dist/bollharness/src/scripts/deploy-guard.js +8 -8
  16. package/dist/bollharness-integration/context-router-judgment.js +4 -4
  17. package/dist/bollharness-integration/context-router.js +292 -292
  18. package/dist/bollharness-integration/gate-state-machine.js +13 -13
  19. package/dist/bollharness-integration/llm-judgment-engine.js +134 -134
  20. package/dist/bollharness-integration/skill-adapter.js +21 -21
  21. package/dist/bootstrap/chat-archiver.js +14 -14
  22. package/dist/bootstrap/context-os.js +50 -40
  23. package/dist/bootstrap/memory-compressor.js +13 -13
  24. package/dist/cli-entry.js +36 -36
  25. package/dist/context-compaction/auto-compact.js +7 -7
  26. package/dist/cron/cron-parser.js +142 -0
  27. package/dist/cron/jobs-store.js +96 -0
  28. package/dist/cron/scheduler.js +72 -0
  29. package/dist/cron/suggestion-catalog.js +41 -0
  30. package/dist/cron/suggestions-command.js +96 -0
  31. package/dist/cron/suggestions.js +110 -0
  32. package/dist/electron/first-run.js +54 -54
  33. package/dist/electron-build/electron/first-run.js +54 -54
  34. package/dist/electron-preload.js.map +1 -1
  35. package/dist/electron.js.map +1 -1
  36. package/dist/external-engines/delegate-handle.js +70 -0
  37. package/dist/external-engines/delegate.js +24 -3
  38. package/dist/index.js +219 -112
  39. package/dist/llm/error-lessons.js +69 -0
  40. package/dist/llm/llm-judgment-client.js +102 -102
  41. package/dist/llm/pi-ai.js +86 -48
  42. package/dist/llm/system-prompt/layers/channel/human-async.md +41 -41
  43. package/dist/llm/system-prompt/layers/channel/local.md +14 -14
  44. package/dist/llm/system-prompt/layers/channel/p2p-agent.md +18 -18
  45. package/dist/llm/system-prompt/layers/channel/p2p-peer-sync.md +51 -51
  46. package/dist/llm/system-prompt/layers/channel/p2p-proactive.md +43 -43
  47. package/dist/llm/system-prompt/layers/channel/p2p-visitor.md +19 -19
  48. package/dist/llm/system-prompt/layers/channel/session-handoff.md +61 -61
  49. package/dist/llm/system-prompt/layers/core/artifacts_storage.md +89 -89
  50. package/dist/llm/system-prompt/layers/core/evenhandedness.md +21 -21
  51. package/dist/llm/system-prompt/layers/core/external-engagement.md +73 -73
  52. package/dist/llm/system-prompt/layers/core/hibs_reminders.md +15 -15
  53. package/dist/llm/system-prompt/layers/core/identity.md +48 -48
  54. package/dist/llm/system-prompt/layers/core/knowledge.md +17 -17
  55. package/dist/llm/system-prompt/layers/core/memory_system.md +12 -12
  56. package/dist/llm/system-prompt/layers/core/network_filesystem.md +28 -28
  57. package/dist/llm/system-prompt/layers/core/refusal.md +37 -37
  58. package/dist/llm/system-prompt/layers/core/tone.md +31 -31
  59. package/dist/llm/system-prompt/layers/core/tools.thin.md +13 -13
  60. package/dist/llm/system-prompt/layers/core/wellbeing.md +41 -41
  61. package/dist/llm/system-prompt/layers/role/architect.md +20 -20
  62. package/dist/llm/system-prompt/layers/role/expert.md +19 -19
  63. package/dist/llm/system-prompt/layers/role/implementer.md +15 -15
  64. package/dist/llm/system-prompt/layers/role/security.md +15 -15
  65. package/dist/llm/system-prompt/layers/tool/artifacts.md +72 -72
  66. package/dist/llm/system-prompt/layers/tool/bash.md +25 -25
  67. package/dist/llm/system-prompt/layers/tool/goal_handoff.md +77 -77
  68. package/dist/llm/system-prompt/layers/tool/hibs_api.md +171 -171
  69. package/dist/llm/system-prompt/layers/tool/image_search.md +70 -70
  70. package/dist/llm/system-prompt/layers/tool/manifest.md +89 -89
  71. package/dist/llm/system-prompt/layers/tool/mcp_apps.md +53 -53
  72. package/dist/llm/system-prompt/layers/tool/p2p_request.md +61 -61
  73. package/dist/llm/system-prompt/layers/tool/web_search.md +83 -83
  74. package/dist/llm/system-prompt/registry.js +9 -1
  75. package/dist/llm/tool-manifest/ask_user_input.js +1 -1
  76. package/dist/llm/tool-manifest/bash.js +3 -3
  77. package/dist/llm/tool-manifest/create_file.js +4 -4
  78. package/dist/llm/tool-manifest/fetch_sports_data.js +3 -3
  79. package/dist/llm/tool-manifest/image_search.js +2 -2
  80. package/dist/llm/tool-manifest/index.js +0 -59
  81. package/dist/llm/tool-manifest/mcp.js +2 -2
  82. package/dist/llm/tool-manifest/message_compose.js +3 -3
  83. package/dist/llm/tool-manifest/places.js +2 -2
  84. package/dist/llm/tool-manifest/present_files.js +1 -1
  85. package/dist/llm/tool-manifest/recipe.js +2 -2
  86. package/dist/llm/tool-manifest/str_replace.js +5 -5
  87. package/dist/llm/tool-manifest/view.js +3 -3
  88. package/dist/llm/tool-manifest/weather.js +4 -4
  89. package/dist/llm/tool-manifest/web.js +4 -4
  90. package/dist/locales/en.js +28 -0
  91. package/dist/locales/zh.js +26 -0
  92. package/dist/pi-ecosystem-colony/index.js +365 -0
  93. package/dist/pi-ecosystem-judgment/causal-judge.js +51 -51
  94. package/dist/pi-ecosystem-judgment/distill-prompt.js +42 -42
  95. package/dist/pi-ecosystem-judgment/distillation.js +14 -14
  96. package/dist/pi-ecosystem-judgment/monitor-gate.js +28 -28
  97. package/dist/pi-ecosystem-judgment/value-injection.js +102 -102
  98. package/dist/security/context-router-tool.js +15 -15
  99. package/dist/social/ant-colony/AdaptiveHeartbeat.js +101 -0
  100. package/dist/social/ant-colony/PheromoneEngine.js +227 -0
  101. package/dist/social/ant-colony/index.js +6 -0
  102. package/dist/social/ant-colony/types.js +24 -0
  103. package/dist/storage/trajectory.js +101 -0
  104. package/dist/test/ai-judgment-test.js +80 -0
  105. package/dist/test/bollharness-integration.test.js +318 -0
  106. package/dist/test/channel-agent-multi-dialogue.js +205 -0
  107. package/dist/test/channel-heartbeat-agent-test.js +201 -0
  108. package/dist/test/constraint-layer.test.js +164 -0
  109. package/dist/test/diap-identity-test.js +172 -0
  110. package/dist/test/diap-quick-test.js +62 -0
  111. package/dist/test/global-shared-context.test.js +315 -0
  112. package/dist/test/harness-judgment-injection.test.js +246 -0
  113. package/dist/test/harness-workflow-integrator-test.js +228 -0
  114. package/dist/test/human-value-store.test.js +243 -0
  115. package/dist/test/hybrid-integration-test.js +118 -0
  116. package/dist/test/hybrid-messenger-verify.js +55 -0
  117. package/dist/test/iroh-bistream-debug.js +38 -0
  118. package/dist/test/iroh-communication.test.js +66 -0
  119. package/dist/test/iroh-debug-test.js +57 -0
  120. package/dist/test/iroh-diap-test.js +71 -0
  121. package/dist/test/iroh-direct-connect.js +55 -0
  122. package/dist/test/iroh-e2e-fixed.js +89 -0
  123. package/dist/test/iroh-e2e-same-process.js +63 -0
  124. package/dist/test/iroh-e2e.js +66 -0
  125. package/dist/test/iroh-final-e2e.js +72 -0
  126. package/dist/test/iroh-relay-test.js +37 -0
  127. package/dist/test/iroh-simple-test.js +41 -0
  128. package/dist/test/iroh-transport-verify.js +54 -0
  129. package/dist/test/iroh-transport.test.js +37 -0
  130. package/dist/test/iroh-two-nodes.js +70 -0
  131. package/dist/test/iroh-verify.js +44 -0
  132. package/dist/test/judgment-decision.test.js +219 -0
  133. package/dist/test/llm-judgment-integration.test.js +220 -0
  134. package/dist/test/p2p-agent-complex-dialogue.js +385 -0
  135. package/dist/test/p2p-agent-dialogue.js +341 -0
  136. package/dist/test/p2p-agent-full-bidirectional.js +510 -0
  137. package/dist/test/p2p-agent-harness-flow.js +437 -0
  138. package/dist/test/p2p-agent-harness-single.js +143 -0
  139. package/dist/test/p2p-ai-dialogue-test.js +318 -0
  140. package/dist/test/p2p-cid-connect-test.js +195 -0
  141. package/dist/test/p2p-connect-receiver.js +69 -0
  142. package/dist/test/p2p-doc-transfer.js +110 -0
  143. package/dist/test/p2p-identity-page-test.js +77 -0
  144. package/dist/test/p2p-iroh-test.js +171 -0
  145. package/dist/test/p2p-minimal-test.js +241 -0
  146. package/dist/test/p2p-node-1.js +148 -0
  147. package/dist/test/p2p-node-2.js +148 -0
  148. package/dist/test/p2p-server.js +281 -0
  149. package/dist/test/p2p-two-nodes-test.js +438 -0
  150. package/dist/test/pi-sdk.test.js +44 -0
  151. package/dist/test/set-persona.js +40 -0
  152. package/dist/test/simple.test.js +9 -0
  153. package/dist/test/storage-integration.test.js +150 -0
  154. package/dist/test/subagent-manager.test.js +276 -0
  155. package/dist/test/test-gate-flow.test.js +81 -0
  156. package/dist/test/workflow-engine.test.js +87 -0
  157. package/dist/test/workflow-pivot-loop.test.js +246 -0
  158. package/dist/web/api-config.html +779 -779
  159. package/dist/web/client-hearth.js +10 -10
  160. package/dist/web/components/wallet-viem.mjs +118 -118
  161. package/dist/web/delivery-ledger.js +103 -0
  162. package/dist/web/i18n.js +104 -0
  163. package/dist/web/index.html +472 -472
  164. package/dist/web/loop-noise.js +86 -0
  165. package/dist/web/manifest.json +20 -20
  166. package/dist/web/routes-tasks.js +242 -24
  167. package/dist/web/server-sse.js +10 -5
  168. package/dist/web/server-storage.js +111 -0
  169. package/dist/web/server.js +55 -26
  170. package/dist/web/style.css +4946 -4946
  171. package/dist/web/task-breaker.js +18 -0
  172. package/dist/web/task-cancel.js +35 -0
  173. package/dist/web/task-deps.js +36 -0
  174. package/dist/web/task-review.js +27 -0
  175. package/dist/web/task-verify.js +60 -0
  176. package/package.json +196 -196
  177. package/scripts/build-cli.js +215 -215
  178. package/scripts/build-web.ts +130 -130
  179. package/scripts/postinstall.js +152 -152
  180. package/bin/bolloon-daemon.sh +0 -207
  181. package/dist/constraint-runtime/reference_data/archive_surface_snapshot.json +0 -63
  182. package/dist/constraint-runtime/reference_data/commands_snapshot.json +0 -1037
  183. package/dist/constraint-runtime/reference_data/subsystems/assistant.json +0 -8
  184. package/dist/constraint-runtime/reference_data/subsystems/bootstrap.json +0 -8
  185. package/dist/constraint-runtime/reference_data/subsystems/bridge.json +0 -32
  186. package/dist/constraint-runtime/reference_data/subsystems/buddy.json +0 -13
  187. package/dist/constraint-runtime/reference_data/subsystems/cli.json +0 -26
  188. package/dist/constraint-runtime/reference_data/subsystems/components.json +0 -32
  189. package/dist/constraint-runtime/reference_data/subsystems/constants.json +0 -28
  190. package/dist/constraint-runtime/reference_data/subsystems/coordinator.json +0 -8
  191. package/dist/constraint-runtime/reference_data/subsystems/entrypoints.json +0 -15
  192. package/dist/constraint-runtime/reference_data/subsystems/hooks.json +0 -32
  193. package/dist/constraint-runtime/reference_data/subsystems/keybindings.json +0 -21
  194. package/dist/constraint-runtime/reference_data/subsystems/memdir.json +0 -15
  195. package/dist/constraint-runtime/reference_data/subsystems/migrations.json +0 -18
  196. package/dist/constraint-runtime/reference_data/subsystems/moreright.json +0 -8
  197. package/dist/constraint-runtime/reference_data/subsystems/native_ts.json +0 -11
  198. package/dist/constraint-runtime/reference_data/subsystems/outputStyles.json +0 -8
  199. package/dist/constraint-runtime/reference_data/subsystems/plugins.json +0 -9
  200. package/dist/constraint-runtime/reference_data/subsystems/remote.json +0 -11
  201. package/dist/constraint-runtime/reference_data/subsystems/schemas.json +0 -8
  202. package/dist/constraint-runtime/reference_data/subsystems/screens.json +0 -10
  203. package/dist/constraint-runtime/reference_data/subsystems/server.json +0 -10
  204. package/dist/constraint-runtime/reference_data/subsystems/services.json +0 -32
  205. package/dist/constraint-runtime/reference_data/subsystems/skills.json +0 -27
  206. package/dist/constraint-runtime/reference_data/subsystems/state.json +0 -13
  207. package/dist/constraint-runtime/reference_data/subsystems/types.json +0 -18
  208. package/dist/constraint-runtime/reference_data/subsystems/upstreamproxy.json +0 -9
  209. package/dist/constraint-runtime/reference_data/subsystems/utils.json +0 -32
  210. package/dist/constraint-runtime/reference_data/subsystems/vim.json +0 -12
  211. package/dist/constraint-runtime/reference_data/subsystems/voice.json +0 -8
  212. package/dist/constraint-runtime/reference_data/tools_snapshot.json +0 -922
  213. package/dist/electron/config.js.map +0 -1
  214. package/dist/electron/dialogs.js.map +0 -1
  215. package/dist/electron/first-run.js.map +0 -1
  216. package/dist/electron/ipc.js.map +0 -1
  217. package/dist/electron/logger.js.map +0 -1
  218. package/dist/electron/main.js.map +0 -1
  219. package/dist/electron/menu.js.map +0 -1
  220. package/dist/electron/paths.js.map +0 -1
  221. package/dist/electron/server.js.map +0 -1
  222. package/dist/electron/tray.js.map +0 -1
  223. package/dist/electron/window.js.map +0 -1
  224. package/dist/utils/auto-update.js.map +0 -1
  225. package/dist/web/client.js.map +0 -7
@@ -0,0 +1,86 @@
1
+ /**
2
+ * loop-noise.ts — 良性连接拆除噪音抑制 (借鉴 Hermes tui_gateway/loop_noise.py)
3
+ *
4
+ * Hermes 问题: Desktop 客户端强关 WebSocket 时, gateway 还有 pending socket 操作,
5
+ * asyncio transport teardown 对每个 _call_connection_lost 回调打一条完整 traceback,
6
+ * 单次断开可刷 50+ 条相同 ConnectionResetError/WinError 10054 进 errors.log (#50005).
7
+ *
8
+ * Bolloon 等价: SSE/HTTP 客户端断开后, 服务端向已关闭的 res 写消息 → 每次广播抛
9
+ * "write EPIPE / write after end / 连接被重置"。这不是可行动的错误 — 是对端在
10
+ * 写 drain 前挂线的预期副作用。这里对"拆除期写失败"这类良性错误做一次过滤,
11
+ * 把同类错误折叠成一行 debug, 其余错误原样交给默认逻辑, 真实的循环 bug 仍会暴露。
12
+ *
13
+ * 设计要点 (对齐 hermes):
14
+ * - 同时看错误类型 AND 写路径标记 (broadcast 的 res.write) 才吞 — 其它地方抛同样错误
15
+ * 不被误吞 (对应 hermes 的 _call_connection_lost gating)。
16
+ * - 同类错误窗口节流 (channel_directory 模式): 同一 (类别, 摘要) 窗口内只 warn 一次,
17
+ * 复用 delivery-ledger 已有的 shouldWarn 心智。
18
+ */
19
+ /** 良性拆除期写失败: 对端挂线 / 写已结束的流。等价 hermes 的 ConnectionReset/Aborted/BrokenPipe。 */
20
+ const BENIGN_WRITE_PATTERNS = [
21
+ /write EPIPE/i,
22
+ /write after end/i,
23
+ /ECONNRESET|connection (reset|aborted)|10054|10053/i,
24
+ /Cannot (write|set headers) after (the client|they) (are |is |has )?(closed|ended)/i,
25
+ /socket hang up/i,
26
+ /broken pipe/i,
27
+ ];
28
+ /**
29
+ * 一条错误是否属于"写给已断开客户端"的良性噪音。
30
+ * 双重判定等价 hermes 的 (错误类型 + _call_connection_lost 回调 gating):
31
+ * - 类型/消息命中拆除类写失败 (ConnectionReset/BrokenPipe/写已结束)
32
+ * - 且调用点本来就是"写路径" — 由 guardClientWriteNoise 只在 res.write 处调用保证
33
+ */
34
+ export function isBenignClientWriteNoise(err) {
35
+ const msg = String(err?.message || err || '');
36
+ return BENIGN_WRITE_PATTERNS.some((re) => re.test(msg));
37
+ }
38
+ /** 同类错误节流器: 同 (类别, 摘要) 窗口内返回 false (不重复 warn), 窗口外返回 true 并刷新。 */
39
+ export class NoiseThrottle {
40
+ windowMs;
41
+ last = new Map();
42
+ constructor(windowMs = 5 * 60 * 1000) {
43
+ this.windowMs = windowMs;
44
+ }
45
+ shouldWarn(category, detail) {
46
+ const key = `${category}|${detail.slice(0, 80)}`;
47
+ const now = Date.now();
48
+ const last = this.last.get(key) ?? 0;
49
+ if (now - last < this.windowMs)
50
+ return false;
51
+ this.last.set(key, now);
52
+ return true;
53
+ }
54
+ reset() {
55
+ this.last.clear();
56
+ }
57
+ }
58
+ /**
59
+ * 包装一个"可能抛拆除期写失败"的写操作:
60
+ * - 良性噪音 → 静默吞一次 (不抛出, 不打印), 仅按节流器偶尔 debug 一行
61
+ * - 其它错误 → 原样抛出 / 转交 onError, 不掩盖真实问题
62
+ *
63
+ * @param fn 实际写操作 (抛异常或 reject)
64
+ * @param opts 诊断标签 + 可选节流器 (默认全局单例) + 可选 onError 回调 (捕获真实错误)
65
+ */
66
+ export async function guardClientWriteNoise(fn, opts = { label: 'write' }) {
67
+ const throttle = opts.throttle ?? globalNoiseThrottle;
68
+ try {
69
+ await fn();
70
+ }
71
+ catch (e) {
72
+ if (isBenignClientWriteNoise(e)) {
73
+ // 良性: 折叠成一行 (节流), 不抛出
74
+ if (throttle.shouldWarn(opts.label, String(e?.message || e))) {
75
+ console.debug(`[loop-noise] 已抑制良性客户端断开写失败 (${opts.label}): ${String(e?.message || '').slice(0, 120)}`);
76
+ }
77
+ return;
78
+ }
79
+ if (opts.onError)
80
+ opts.onError(e);
81
+ else
82
+ throw e;
83
+ }
84
+ }
85
+ /** 全局默认节流器 (server 生命期复用, 跨连接共享告警窗口)。 */
86
+ export const globalNoiseThrottle = new NoiseThrottle();
@@ -1,21 +1,21 @@
1
- {
2
- "name": "Bolloon Agent",
3
- "short_name": "Bolloon",
4
- "description": "AI Agent with Claude API integration",
5
- "start_url": "/",
6
- "display": "standalone",
7
- "background_color": "#1a1a2e",
8
- "theme_color": "#1a1a2e",
9
- "icons": [
10
- {
11
- "src": "/icons/favicon-192x192.png",
12
- "sizes": "192x192",
13
- "type": "image/png"
14
- },
15
- {
16
- "src": "/icons/favicon-512x512.png",
17
- "sizes": "512x512",
18
- "type": "image/png"
19
- }
20
- ]
1
+ {
2
+ "name": "Bolloon Agent",
3
+ "short_name": "Bolloon",
4
+ "description": "AI Agent with Claude API integration",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#1a1a2e",
8
+ "theme_color": "#1a1a2e",
9
+ "icons": [
10
+ {
11
+ "src": "/icons/favicon-192x192.png",
12
+ "sizes": "192x192",
13
+ "type": "image/png"
14
+ },
15
+ {
16
+ "src": "/icons/favicon-512x512.png",
17
+ "sizes": "512x512",
18
+ "type": "image/png"
19
+ }
20
+ ]
21
21
  }
@@ -4,8 +4,10 @@
4
4
  * 从 src/web/server.ts 抽出 (~160 行).
5
5
  * 包含 /api/tasks/* (GET, POST, PATCH, DELETE, execute, execute-next)
6
6
  */
7
- import { loadTaskQueue, saveTaskQueue, isTaskExecuting } from './server-storage.js';
7
+ import { loadTaskQueue, saveTaskQueue } from './server-storage.js';
8
8
  import { documentReader } from '../documents/reader.js';
9
+ import { applyCancelRequest, shouldFinalizeAsCancelled } from './task-cancel.js';
10
+ import { applyReviewRequest, applyReviewApprove, applyReviewReject } from './task-review.js';
9
11
  export function registerTaskRoutes(app, opts) {
10
12
  const { broadcast, getAgentForChannel } = opts;
11
13
  // 获取所有任务
@@ -21,10 +23,19 @@ export function registerTaskRoutes(app, opts) {
21
23
  // 创建新任务
22
24
  app.post('/api/tasks', async (req, res) => {
23
25
  try {
24
- const { type, title, description, steps } = req.body;
26
+ const { type, title, description, steps, parentIds } = req.body;
25
27
  if (!type || !title) {
26
28
  return res.status(400).json({ error: 'type and title required' });
27
29
  }
30
+ // 2026-08-11 (Hermes 父依赖): parentIds 必须是已存在的任务 id
31
+ if (parentIds !== undefined) {
32
+ const tasks0 = await loadTaskQueue();
33
+ const known = new Set(tasks0.map((t) => t.id));
34
+ const unknown = (Array.isArray(parentIds) ? parentIds : []).filter((id) => !known.has(id));
35
+ if (unknown.length > 0) {
36
+ return res.status(400).json({ error: `Unknown parent task ids: ${unknown.join(', ')}` });
37
+ }
38
+ }
28
39
  const tasks = await loadTaskQueue();
29
40
  const task = {
30
41
  id: `task_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
@@ -35,6 +46,7 @@ export function registerTaskRoutes(app, opts) {
35
46
  progress: 0,
36
47
  createdAt: new Date().toISOString(),
37
48
  updatedAt: new Date().toISOString(),
49
+ parentIds: Array.isArray(parentIds) && parentIds.length > 0 ? parentIds : undefined,
38
50
  steps: steps?.map((s, i) => ({
39
51
  id: `step_${i}`,
40
52
  name: s,
@@ -109,14 +121,24 @@ export function registerTaskRoutes(app, opts) {
109
121
  if (!channelId) {
110
122
  return res.status(400).json({ error: 'channelId required' });
111
123
  }
112
- const tasks = await loadTaskQueue();
113
- const task = tasks.find(t => t.id === taskId);
114
- if (!task) {
115
- return res.status(404).json({ error: 'Task not found' });
116
- }
117
- if (isTaskExecuting()) {
124
+ // 2026-08-11: CAS 认领 — 只有 pending 能认领成功, 输家不重试
125
+ const { claimTaskForExecution } = await import('./server-storage.js');
126
+ const claim = await claimTaskForExecution(taskId);
127
+ if (claim === 'busy') {
118
128
  return res.status(409).json({ error: 'Another task is currently executing' });
119
129
  }
130
+ if (claim === 'not-pending') {
131
+ const tasks = await loadTaskQueue();
132
+ const task = tasks.find(t => t.id === taskId);
133
+ return res.status(409).json({ error: `Task not claimable (status=${task?.status ?? 'missing'})` });
134
+ }
135
+ if (claim === 'parents-undone') {
136
+ const tasks = await loadTaskQueue();
137
+ const task = tasks.find(t => t.id === taskId);
138
+ return res.status(409).json({ error: `Task parents not done (${(task?.parentIds || []).join(', ')}) — 父任务完成后才能执行` });
139
+ }
140
+ const tasks = await loadTaskQueue();
141
+ const task = tasks.find(t => t.id === taskId);
120
142
  // 异步执行任务
121
143
  executeTask(task, channelId, getAgentForChannel, broadcast);
122
144
  res.json({ ok: true, taskId: task.id });
@@ -132,13 +154,11 @@ export function registerTaskRoutes(app, opts) {
132
154
  if (!channelId) {
133
155
  return res.status(400).json({ error: 'channelId required' });
134
156
  }
135
- const tasks = await loadTaskQueue();
136
- const nextTask = tasks.find(t => t.status === 'pending');
157
+ // 2026-08-11: CAS 认领下一个 pending — 输家 (无任务/已被认领/忙) 返回, 不重试
158
+ const { claimNextPendingTask } = await import('./server-storage.js');
159
+ const nextTask = await claimNextPendingTask();
137
160
  if (!nextTask) {
138
- return res.json({ ok: false, message: 'No pending tasks' });
139
- }
140
- if (isTaskExecuting()) {
141
- return res.status(409).json({ error: 'Another task is currently executing' });
161
+ return res.json({ ok: false, message: 'No pending tasks or another task is executing' });
142
162
  }
143
163
  // 异步执行任务
144
164
  executeTask(nextTask, channelId, getAgentForChannel, broadcast);
@@ -148,13 +168,132 @@ export function registerTaskRoutes(app, opts) {
148
168
  res.status(500).json({ error: err.message });
149
169
  }
150
170
  });
171
+ // 取消任务 (2026-08-11, Hermes 两段式: cancel-requested → cancelled)
172
+ // - pending 任务: 从未开始, 直接终态 cancelled (请求即完成, 不会卡在中间态)
173
+ // - running 任务: 先置 cancel-requested (第一段: 请求已受理), executor 观测到后置 cancelled (第二段: 实际停止)
174
+ app.post('/api/tasks/:taskId/cancel', async (req, res) => {
175
+ try {
176
+ const { taskId } = req.params;
177
+ const { channelId } = req.body || {};
178
+ const tasks = await loadTaskQueue();
179
+ const task = tasks.find(t => t.id === taskId);
180
+ if (!task) {
181
+ return res.status(404).json({ error: 'Task not found' });
182
+ }
183
+ switch (task.status) {
184
+ case 'pending': {
185
+ const t = applyCancelRequest(task.status);
186
+ task.status = t.status;
187
+ task.updatedAt = new Date().toISOString();
188
+ await saveTaskQueue(tasks);
189
+ broadcast({ type: 'task_status', taskId: task.id, status: t.status, progress: task.progress }, channelId);
190
+ return res.json({ ok: true, taskId: task.id, status: t.status, phase: t.phase });
191
+ }
192
+ case 'running': {
193
+ const t = applyCancelRequest(task.status);
194
+ task.status = t.status;
195
+ task.updatedAt = new Date().toISOString();
196
+ await saveTaskQueue(tasks);
197
+ broadcast({ type: 'task_status', taskId: task.id, status: t.status }, channelId);
198
+ return res.json({ ok: true, taskId: task.id, status: t.status, phase: t.phase });
199
+ }
200
+ case 'cancel-requested':
201
+ return res.json({ ok: true, taskId: task.id, status: 'cancel-requested', phase: 'already-requested' });
202
+ default:
203
+ return res.status(409).json({ error: `Task already terminal (${task.status})` });
204
+ }
205
+ }
206
+ catch (err) {
207
+ res.status(500).json({ error: err.message });
208
+ }
209
+ });
210
+ // ==================== review 审批通道 (2026-08-11, Hermes request_review 模式) ====================
211
+ // running/pending → review (挂起等人工/审查者) → approve (review→completed) / reject (review→pending 退回队列)
212
+ // 请求审批
213
+ app.post('/api/tasks/:taskId/review', async (req, res) => {
214
+ try {
215
+ const { taskId } = req.params;
216
+ const { channelId } = req.body || {};
217
+ const tasks = await loadTaskQueue();
218
+ const task = tasks.find(t => t.id === taskId);
219
+ if (!task) {
220
+ return res.status(404).json({ error: 'Task not found' });
221
+ }
222
+ const t = applyReviewRequest(task.status);
223
+ if (t.phase === 'not-reviewable') {
224
+ return res.status(409).json({ error: `Task not reviewable (status=${task.status})` });
225
+ }
226
+ task.status = t.status;
227
+ task.updatedAt = new Date().toISOString();
228
+ await saveTaskQueue(tasks);
229
+ broadcast({ type: 'task_status', taskId: task.id, status: t.status }, channelId);
230
+ return res.json({ ok: true, taskId: task.id, status: t.status, phase: t.phase });
231
+ }
232
+ catch (err) {
233
+ res.status(500).json({ error: err.message });
234
+ }
235
+ });
236
+ // 审批通过 (放行完成)
237
+ app.post('/api/tasks/:taskId/approve', async (req, res) => {
238
+ try {
239
+ const { taskId } = req.params;
240
+ const { channelId } = req.body || {};
241
+ const tasks = await loadTaskQueue();
242
+ const task = tasks.find(t => t.id === taskId);
243
+ if (!task) {
244
+ return res.status(404).json({ error: 'Task not found' });
245
+ }
246
+ const t = applyReviewApprove(task.status);
247
+ if (t.phase === 'not-reviewable') {
248
+ return res.status(409).json({ error: `Task not in review (status=${task.status})` });
249
+ }
250
+ task.status = t.status;
251
+ task.progress = 100;
252
+ task.updatedAt = new Date().toISOString();
253
+ await saveTaskQueue(tasks);
254
+ broadcast({ type: 'task_status', taskId: task.id, status: t.status, progress: 100 }, channelId);
255
+ broadcast({ type: 'status', content: `任务审批通过: ${task.title}` }, channelId);
256
+ return res.json({ ok: true, taskId: task.id, status: t.status, phase: t.phase });
257
+ }
258
+ catch (err) {
259
+ res.status(500).json({ error: err.message });
260
+ }
261
+ });
262
+ // 审批驳回 (退回队列)
263
+ app.post('/api/tasks/:taskId/reject', async (req, res) => {
264
+ try {
265
+ const { taskId } = req.params;
266
+ const { channelId } = req.body || {};
267
+ const tasks = await loadTaskQueue();
268
+ const task = tasks.find(t => t.id === taskId);
269
+ if (!task) {
270
+ return res.status(404).json({ error: 'Task not found' });
271
+ }
272
+ const t = applyReviewReject(task.status);
273
+ if (t.phase === 'not-reviewable') {
274
+ return res.status(409).json({ error: `Task not in review (status=${task.status})` });
275
+ }
276
+ task.status = t.status;
277
+ task.progress = 0;
278
+ task.result = undefined;
279
+ task.updatedAt = new Date().toISOString();
280
+ await saveTaskQueue(tasks);
281
+ broadcast({ type: 'task_status', taskId: task.id, status: t.status, progress: 0 }, channelId);
282
+ broadcast({ type: 'status', content: `任务审批驳回, 退回队列: ${task.title}` }, channelId);
283
+ return res.json({ ok: true, taskId: task.id, status: t.status, phase: t.phase });
284
+ }
285
+ catch (err) {
286
+ res.status(500).json({ error: err.message });
287
+ }
288
+ });
151
289
  }
152
290
  // ==================== Task Execution ====================
153
291
  async function executeTask(task, channelId, getAgentForChannel, broadcast) {
154
- // 使用 server-storage startTaskExecution 做锁
155
- const { startTaskExecution, endTaskExecution } = await import('./server-storage.js');
156
- if (!startTaskExecution(task.id))
157
- return;
292
+ // 2026-08-11: 认领已由 claimTaskForExecution/claimNextPendingTask (CAS) 完成并持锁,
293
+ // 这里不再重复 startTaskExecution (会因 isExecutingTask=true 返回 false 导致提前 return).
294
+ const { endTaskExecution, heartbeatTaskExecution } = await import('./server-storage.js');
295
+ // 2026-08-11 (Hermes 心跳): 开始执行先续期一次 — 认领后到首个 await 之间有调度延迟
296
+ await heartbeatTaskExecution(task.id).catch(() => { });
158
297
  const agent = await getAgentForChannel(channelId);
159
298
  const tasks = await loadTaskQueue();
160
299
  const taskIndex = tasks.findIndex(t => t.id === task.id);
@@ -193,6 +332,29 @@ async function executeTask(task, channelId, getAgentForChannel, broadcast) {
193
332
  default:
194
333
  result = '未知任务类型';
195
334
  }
335
+ // 2026-08-11 (Hermes 两段式): executor 观测到 cancel-requested → 落终态 cancelled (第二段)
336
+ // 运行中被请求取消 → 不执行正常完成路径, 直接标记取消
337
+ const tasksAfter = await loadTaskQueue();
338
+ const idxAfter = tasksAfter.findIndex(t => t.id === task.id);
339
+ if (idxAfter >= 0 && shouldFinalizeAsCancelled(tasksAfter[idxAfter].status)) {
340
+ tasksAfter[idxAfter].status = 'cancelled';
341
+ tasksAfter[idxAfter].result = '任务已取消';
342
+ tasksAfter[idxAfter].updatedAt = new Date().toISOString();
343
+ await saveTaskQueue(tasksAfter);
344
+ broadcast({ type: 'task_status', taskId: task.id, status: 'cancelled', progress: tasksAfter[idxAfter].progress }, channelId);
345
+ broadcast({ type: 'status', content: `任务已取消: ${task.title}` }, channelId);
346
+ return;
347
+ }
348
+ // 2026-08-11 (Hermes request_review 模式): 执行完成但任务已挂入 review 审批通道 →
349
+ // 只落结果, 不覆盖成 completed; 等 approve 路由放行 (review → completed)
350
+ if (idxAfter >= 0 && tasksAfter[idxAfter].status === 'review') {
351
+ tasksAfter[idxAfter].result = result;
352
+ tasksAfter[idxAfter].updatedAt = new Date().toISOString();
353
+ await saveTaskQueue(tasksAfter);
354
+ broadcast({ type: 'task_status', taskId: task.id, status: 'review', progress: tasksAfter[idxAfter].progress }, channelId);
355
+ broadcast({ type: 'status', content: `任务执行完成, 待审批: ${task.title}` }, channelId);
356
+ return;
357
+ }
196
358
  // 更新任务状态
197
359
  const tasks = await loadTaskQueue();
198
360
  const idx = tasks.findIndex(t => t.id === task.id);
@@ -200,23 +362,79 @@ async function executeTask(task, channelId, getAgentForChannel, broadcast) {
200
362
  tasks[idx].status = 'completed';
201
363
  tasks[idx].progress = 100;
202
364
  tasks[idx].result = result;
365
+ // 2026-08-11 (熔断器): 成功完成 → 连续失败计数清零
366
+ tasks[idx].consecutiveFailures = 0;
367
+ tasks[idx].lastFailureError = undefined;
368
+ tasks[idx].claimExpires = undefined;
203
369
  tasks[idx].updatedAt = new Date().toISOString();
204
370
  await saveTaskQueue(tasks);
205
371
  }
206
372
  broadcast({ type: 'task_status', taskId: task.id, status: 'completed', progress: 100, result }, channelId);
207
373
  broadcast({ type: 'ai', content: result }, channelId);
374
+ // 2026-08-11 (Hermes 完成防幻觉, advisory): 异步校验 result 里的任务/文件引用,
375
+ // 幽灵引用 → 存 task.warnings + broadcast warning (不阻塞完成 — 单次同步执行无法返工)
376
+ void (async () => {
377
+ try {
378
+ const { verifyTaskResult } = await import('./task-verify.js');
379
+ const allTasks = await loadTaskQueue();
380
+ const v = await verifyTaskResult(result, { knownTaskIds: allTasks.map((t) => t.id) });
381
+ if (v.warnings.length > 0) {
382
+ const q = await loadTaskQueue();
383
+ const vi = q.findIndex((t) => t.id === task.id);
384
+ if (vi >= 0) {
385
+ q[vi].warnings = v.warnings;
386
+ await saveTaskQueue(q);
387
+ }
388
+ for (const w of v.warnings) {
389
+ broadcast({ type: 'error', content: `⚠ ${w}` }, channelId);
390
+ }
391
+ }
392
+ }
393
+ catch {
394
+ // 校验失败不阻塞完成
395
+ }
396
+ })();
208
397
  }
209
398
  catch (error) {
210
399
  const tasks = await loadTaskQueue();
211
400
  const idx = tasks.findIndex(t => t.id === task.id);
212
401
  if (idx >= 0) {
213
- tasks[idx].status = 'failed';
214
- tasks[idx].error = error.message;
215
- tasks[idx].updatedAt = new Date().toISOString();
216
- await saveTaskQueue(tasks);
402
+ const t = tasks[idx];
403
+ // 取消请求在先 → 异常属于取消副作用, 落 cancelled 而非 failed (Hermes 两段式)
404
+ if (t.status === 'cancel-requested') {
405
+ t.status = 'cancelled';
406
+ t.error = error.message;
407
+ t.updatedAt = new Date().toISOString();
408
+ await saveTaskQueue(tasks);
409
+ }
410
+ else {
411
+ // 2026-08-11 (Hermes consecutive_failures 熔断器): 失败计数 +1;
412
+ // 达到阈值 → failed 终态 (熔断, 不再自动重试)
413
+ // 未达阈值 → 回 pending 可重新认领 (execute-next 自动重试), 防一次性小错卡死队列
414
+ const { recordTaskFailure } = await import('./task-breaker.js');
415
+ const envLimit = Number(process.env.BOLLOON_FAILURE_LIMIT || '');
416
+ const f = recordTaskFailure(t, envLimit > 0 ? envLimit : undefined);
417
+ t.consecutiveFailures = f.consecutiveFailures;
418
+ t.lastFailureError = String(error.message || error).slice(0, 300);
419
+ t.claimExpires = undefined;
420
+ if (f.tripped) {
421
+ t.status = 'failed';
422
+ t.error = `连续失败 ${f.consecutiveFailures} 次, 熔断 (不再自动重试): ${String(error.message || error).slice(0, 200)}`;
423
+ }
424
+ else {
425
+ t.status = 'pending';
426
+ t.error = `失败 ${f.consecutiveFailures} 次 (自动重试): ${String(error.message || error).slice(0, 200)}`;
427
+ }
428
+ t.updatedAt = new Date().toISOString();
429
+ await saveTaskQueue(tasks);
430
+ }
217
431
  }
218
- broadcast({ type: 'task_status', taskId: task.id, status: 'failed', error: error.message }, channelId);
219
- broadcast({ type: 'error', content: `任务执行失败: ${error.message}` }, channelId);
432
+ // 读取实际落态 (failed 熔断 / pending 自动重试 / cancelled)
433
+ const tasksNow = await loadTaskQueue();
434
+ const taskNow = tasksNow.find(t => t.id === task.id);
435
+ const finalStatus = taskNow?.status ?? 'failed';
436
+ broadcast({ type: 'task_status', taskId: task.id, status: finalStatus, error: error.message }, channelId);
437
+ broadcast({ type: 'error', content: `任务执行失败: ${error.message}${finalStatus === 'pending' ? ' (自动重试)' : ''}` }, channelId);
220
438
  }
221
439
  endTaskExecution();
222
440
  }
@@ -11,6 +11,7 @@
11
11
  * SSEClient type 来自 ./server-types.ts.
12
12
  */
13
13
  import * as crypto from 'crypto';
14
+ import { guardClientWriteNoise } from './loop-noise.js';
14
15
  const sseClients = new Set();
15
16
  export function getSseClients() {
16
17
  return sseClients;
@@ -57,12 +58,16 @@ export function broadcast(data, channelId) {
57
58
  console.log(`[broadcast] type=${data.type}, channelId=${channelId}, seq=${seq}, msgId=${msgId}, clients=${sseClients.size}`);
58
59
  for (const client of sseClients) {
59
60
  if (!channelId || client.channelId === channelId) {
60
- try {
61
+ // 2026-08-11 (Hermes loop_noise): 客户端在写 drain 前断开 → 每次广播对已关闭 res
62
+ // write 都会抛 "write EPIPE / write after end / 连接已关闭"。这是预期副作用, 不是
63
+ // bug — guardClientWriteNoise 把同类错误折叠成一行 (节流), 不打印每条刷屏。
64
+ void guardClientWriteNoise(() => {
61
65
  client.res.write(message);
62
- }
63
- catch (e) {
64
- console.error(`[broadcast] 写入失败:`, e.message);
65
- }
66
+ return Promise.resolve();
67
+ }, {
68
+ label: `sse-broadcast:${data.type}`,
69
+ onError: (e) => console.error(`[broadcast] 写入失败 (非良性): ${e.message}`),
70
+ });
66
71
  }
67
72
  }
68
73
  }
@@ -13,6 +13,7 @@
13
13
  import * as fs from 'fs/promises';
14
14
  import { CHANNELS_PATH, SESSION_CACHE_PATH, TASK_QUEUE_PATH, THEME_PATH, } from './server-types.js';
15
15
  import { saveWindow as saveSessionWindow, loadWindow as loadSessionWindow } from '../bootstrap/session-window.js';
16
+ import { parentsSatisfied } from './task-deps.js';
16
17
  // 写盘去重: 上次写盘内容, 用于跳过幂等调用
17
18
  let lastChannelsJson = '';
18
19
  // 写盘保护: 任何调用 saveChannels 后更新
@@ -179,6 +180,116 @@ export async function loadTaskQueue() {
179
180
  export async function saveTaskQueue(tasks) {
180
181
  await fs.writeFile(TASK_QUEUE_PATH, JSON.stringify(tasks, null, 2));
181
182
  }
183
+ // ==================== 任务认领 CAS (2026-08-11, Hermes kanban 模式) ====================
184
+ // kanban_db.py: "WAL + BEGIN IMMEDIATE + compare-and-swap on tasks.status/claim_lock —
185
+ // 至多一个 worker 能认领任务, 输家看到 0 行受影响就退出, 无重试循环, 无分布式锁."
186
+ // Bolloon 对应: 任务队列是 JSON 文件, 用进程内互斥链 (withTaskQueueLock) 序列化
187
+ // load→CAS(status==='pending'→'running')→save, 输家返回 null/not-pending, 不重试.
188
+ let taskQueueLock = Promise.resolve();
189
+ function withTaskQueueLock(fn) {
190
+ const run = taskQueueLock.then(fn);
191
+ taskQueueLock = run.then(() => undefined, () => undefined);
192
+ return run;
193
+ }
194
+ /** 认领 TTL 默认 15min (BOLLOON_CLAIM_TTL_SECONDS 可调) — Hermes DEFAULT_CLAIM_TTL_SECONDS=15*60 */
195
+ export function claimTtlSeconds() {
196
+ const v = Number(process.env.BOLLOON_CLAIM_TTL_SECONDS || '');
197
+ return v > 0 ? v : 15 * 60;
198
+ }
199
+ /**
200
+ * CAS 认领指定任务: 只有 status==='pending' 才能认领成功 (原子翻成 running).
201
+ * 认领点强制父依赖不变式 (Hermes kanban): 任一父未 completed/cancelled → 不认领, 返回 'parents-undone'.
202
+ * 认领写 claim_expires (TTL) — 崩溃/卡死后由 releaseStaleClaims 释放, 锁不泄漏.
203
+ */
204
+ export async function claimTaskForExecution(taskId) {
205
+ if (isExecutingTask)
206
+ return 'busy';
207
+ return withTaskQueueLock(async () => {
208
+ if (isExecutingTask)
209
+ return 'busy';
210
+ const tasks = await loadTaskQueue();
211
+ const t = tasks.find((x) => x.id === taskId);
212
+ if (!t || t.status !== 'pending')
213
+ return 'not-pending';
214
+ // 父依赖不变式: 父未完成 → 拒绝认领 (依赖悬空时任务不可执行)
215
+ if (!parentsSatisfied(t, tasks))
216
+ return 'parents-undone';
217
+ t.status = 'running';
218
+ t.claimExpires = Date.now() + claimTtlSeconds() * 1000;
219
+ t.updatedAt = new Date().toISOString();
220
+ await saveTaskQueue(tasks);
221
+ isExecutingTask = true;
222
+ executionTaskId = taskId;
223
+ return 'claimed';
224
+ });
225
+ }
226
+ /**
227
+ * CAS 认领下一个 pending 任务; 无任务/已被认领 → null (输家不重试).
228
+ * 只认领父依赖满足的 pending — 父未完成的任务跳过 (等父 done 后可被认领), 不阻塞队列.
229
+ */
230
+ export async function claimNextPendingTask() {
231
+ if (isExecutingTask)
232
+ return null;
233
+ return withTaskQueueLock(async () => {
234
+ if (isExecutingTask)
235
+ return null;
236
+ const tasks = await loadTaskQueue();
237
+ const t = tasks.find((x) => x.status === 'pending' && parentsSatisfied(x, tasks));
238
+ if (!t)
239
+ return null;
240
+ t.status = 'running';
241
+ t.claimExpires = Date.now() + claimTtlSeconds() * 1000;
242
+ t.updatedAt = new Date().toISOString();
243
+ await saveTaskQueue(tasks);
244
+ isExecutingTask = true;
245
+ executionTaskId = t.id;
246
+ return t;
247
+ });
248
+ }
249
+ /**
250
+ * 心跳续期 (Hermes kanban_heartbeat): 执行中有实质进展时调用, 把 claim_expires 推到 now+TTL.
251
+ * CAS: 只续期当前执行中的任务 (executionTaskId 匹配 + status==='running').
252
+ */
253
+ export async function heartbeatTaskExecution(taskId) {
254
+ return withTaskQueueLock(async () => {
255
+ if (executionTaskId !== taskId)
256
+ return false;
257
+ const tasks = await loadTaskQueue();
258
+ const t = tasks.find((x) => x.id === taskId);
259
+ if (!t || t.status !== 'running')
260
+ return false;
261
+ t.claimExpires = Date.now() + claimTtlSeconds() * 1000;
262
+ t.updatedAt = new Date().toISOString();
263
+ await saveTaskQueue(tasks);
264
+ return true;
265
+ });
266
+ }
267
+ /**
268
+ * 释放过期认领 (Hermes release_stale_claims): 扫所有 running 且 claim_expires 已过期的任务
269
+ * → 释放 in-memory 锁 + 任务回 pending (可重新认领执行). 防 executeTask 卡死/崩溃后锁泄漏.
270
+ * 返回释放数量. 安全可频繁调用.
271
+ */
272
+ export async function releaseStaleClaims() {
273
+ return withTaskQueueLock(async () => {
274
+ const now = Date.now();
275
+ const tasks = await loadTaskQueue();
276
+ const stale = tasks.filter((t) => t.status === 'running' && t.claimExpires !== undefined && t.claimExpires < now);
277
+ if (stale.length === 0)
278
+ return 0;
279
+ for (const t of stale) {
280
+ t.status = 'pending';
281
+ t.claimExpires = undefined;
282
+ t.updatedAt = new Date().toISOString();
283
+ }
284
+ await saveTaskQueue(tasks);
285
+ // 当前执行锁指向过期任务 → 一并释放 (卡死的 executeTask 其锁也失效)
286
+ if (executionTaskId && stale.some((t) => t.id === executionTaskId)) {
287
+ isExecutingTask = false;
288
+ executionTaskId = null;
289
+ }
290
+ return stale.length;
291
+ });
292
+ }
182
293
  /** 执行 task 的 helper 标记 (server.ts 内部 still 持有 lock) */
183
294
  export function startTaskExecution(taskId) {
184
295
  if (isExecutingTask)