@konglx/rotom 2.21.0

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 (189) hide show
  1. package/README.md +417 -0
  2. package/bin/mesh-master.sh +439 -0
  3. package/bin/rotom +29 -0
  4. package/bin/rotom-link.sh +136 -0
  5. package/bin/rotom-send-with-status +57 -0
  6. package/bin/rotom-up.sh +428 -0
  7. package/dist/cli/ask.js +62 -0
  8. package/dist/cli/common.js +321 -0
  9. package/dist/cli/config.js +65 -0
  10. package/dist/cli/directory.js +17 -0
  11. package/dist/cli/executor.js +58 -0
  12. package/dist/cli/fed.js +91 -0
  13. package/dist/cli/group.js +273 -0
  14. package/dist/cli/identity.js +62 -0
  15. package/dist/cli/init.js +268 -0
  16. package/dist/cli/issue.js +202 -0
  17. package/dist/cli/join.js +170 -0
  18. package/dist/cli/link.js +47 -0
  19. package/dist/cli/master.js +51 -0
  20. package/dist/cli/memory.js +307 -0
  21. package/dist/cli/note.js +68 -0
  22. package/dist/cli/repo.js +77 -0
  23. package/dist/cli/rotom.js +277 -0
  24. package/dist/cli/routes.js +118 -0
  25. package/dist/cli/run.js +45 -0
  26. package/dist/cli/schedule.js +237 -0
  27. package/dist/cli/skill.js +173 -0
  28. package/dist/cli/team.js +106 -0
  29. package/dist/executor/claude-code-hook.cjs +80 -0
  30. package/dist/executor/cli-executor.js +8 -0
  31. package/dist/executor/executors/claude-code.js +780 -0
  32. package/dist/executor/executors/codex.js +719 -0
  33. package/dist/executor/executors/hermes-cli.js +855 -0
  34. package/dist/executor/executors/openclaw.js +467 -0
  35. package/dist/executor/executors/pi.js +514 -0
  36. package/dist/executor/index.js +269 -0
  37. package/dist/executor/jsonrpc-transport.js +125 -0
  38. package/dist/executor/process-runner.js +101 -0
  39. package/dist/executor/reasoning-status.js +83 -0
  40. package/dist/executor/repo-cache.js +502 -0
  41. package/dist/executor/session-store.js +188 -0
  42. package/dist/executor/worker-chat.js +257 -0
  43. package/dist/executor/worker-connection.js +89 -0
  44. package/dist/executor/worker-issue.js +264 -0
  45. package/dist/executor/worker.js +877 -0
  46. package/dist/link/pending-requests.js +72 -0
  47. package/dist/link/server.js +233 -0
  48. package/dist/link/visibility-store.js +58 -0
  49. package/dist/master/api/agents.js +333 -0
  50. package/dist/master/api/artifacts.js +271 -0
  51. package/dist/master/api/domains.js +64 -0
  52. package/dist/master/api/groups.js +635 -0
  53. package/dist/master/api/guidance-templates.js +147 -0
  54. package/dist/master/api/index.js +89 -0
  55. package/dist/master/api/issues-patrol.js +172 -0
  56. package/dist/master/api/issues.js +663 -0
  57. package/dist/master/api/links-patrol.js +168 -0
  58. package/dist/master/api/links.js +114 -0
  59. package/dist/master/api/memory.js +259 -0
  60. package/dist/master/api/messages.js +157 -0
  61. package/dist/master/api/notes.js +77 -0
  62. package/dist/master/api/schedule-patterns.js +133 -0
  63. package/dist/master/api/schedules.js +272 -0
  64. package/dist/master/api/sessions.js +158 -0
  65. package/dist/master/api/share.js +269 -0
  66. package/dist/master/api/skills.js +190 -0
  67. package/dist/master/api/teams.js +122 -0
  68. package/dist/master/api/uploads.js +245 -0
  69. package/dist/master/auth.js +134 -0
  70. package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
  71. package/dist/master/dashboard/animations/calico-error.apng +0 -0
  72. package/dist/master/dashboard/animations/calico-happy.apng +0 -0
  73. package/dist/master/dashboard/animations/calico-notification.apng +0 -0
  74. package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
  75. package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
  76. package/dist/master/dashboard/animations/calico-waking.apng +0 -0
  77. package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
  78. package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
  79. package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
  80. package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
  81. package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
  82. package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
  83. package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
  84. package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
  85. package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
  86. package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
  87. package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
  88. package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
  89. package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
  90. package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
  91. package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
  92. package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
  93. package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
  94. package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
  95. package/dist/master/dashboard/debug-auth.html +197 -0
  96. package/dist/master/dashboard/favicon.ico +0 -0
  97. package/dist/master/dashboard/index.html +20 -0
  98. package/dist/master/dashboard/rotom-avatar.png +0 -0
  99. package/dist/master/db/agent-sessions.js +60 -0
  100. package/dist/master/db/agent-visibility.js +64 -0
  101. package/dist/master/db/agents.js +119 -0
  102. package/dist/master/db/ask-bridges.js +157 -0
  103. package/dist/master/db/build-update.js +59 -0
  104. package/dist/master/db/core.js +82 -0
  105. package/dist/master/db/domains.js +80 -0
  106. package/dist/master/db/groups.js +316 -0
  107. package/dist/master/db/guidance-templates.js +58 -0
  108. package/dist/master/db/index.js +12 -0
  109. package/dist/master/db/internal.js +45 -0
  110. package/dist/master/db/issues-patrol.js +81 -0
  111. package/dist/master/db/issues.js +373 -0
  112. package/dist/master/db/links.js +221 -0
  113. package/dist/master/db/master-node.js +43 -0
  114. package/dist/master/db/memory.js +272 -0
  115. package/dist/master/db/messages.js +210 -0
  116. package/dist/master/db/notes.js +55 -0
  117. package/dist/master/db/schedule-patterns.js +56 -0
  118. package/dist/master/db/schedules.js +135 -0
  119. package/dist/master/db/skills.js +144 -0
  120. package/dist/master/db/team.js +88 -0
  121. package/dist/master/db/types.js +10 -0
  122. package/dist/master/db.js +12 -0
  123. package/dist/master/embedded.js +133 -0
  124. package/dist/master/federation/client.js +283 -0
  125. package/dist/master/federation/identity.js +133 -0
  126. package/dist/master/federation/manager.js +267 -0
  127. package/dist/master/federation/publisher.js +87 -0
  128. package/dist/master/federation/self-publisher.js +69 -0
  129. package/dist/master/federation/server.js +487 -0
  130. package/dist/master/group-paths.js +208 -0
  131. package/dist/master/offline-queue.js +38 -0
  132. package/dist/master/opc-bootstrap.js +245 -0
  133. package/dist/master/patrol-terminal.js +275 -0
  134. package/dist/master/repo-scan.js +188 -0
  135. package/dist/master/router.js +214 -0
  136. package/dist/master/scheduler-handlers.js +510 -0
  137. package/dist/master/scheduler.js +201 -0
  138. package/dist/master/server.js +203 -0
  139. package/dist/master/services/link-collector.js +82 -0
  140. package/dist/master/services/link-patrol-bootstrap.js +50 -0
  141. package/dist/master/services/memory-extract-prompt.js +34 -0
  142. package/dist/master/services/patrol-bootstrap.js +63 -0
  143. package/dist/master/share-tokens.js +56 -0
  144. package/dist/master/terminal-hub.js +300 -0
  145. package/dist/master/uploads.js +108 -0
  146. package/dist/master/util/fs.js +100 -0
  147. package/dist/master/util/paths.js +50 -0
  148. package/dist/master/util/persona.js +10 -0
  149. package/dist/master/ws-hub/connection.js +928 -0
  150. package/dist/master/ws-hub/conversation.js +290 -0
  151. package/dist/master/ws-hub/directory.js +70 -0
  152. package/dist/master/ws-hub/dispatch-enrich.js +34 -0
  153. package/dist/master/ws-hub/hub.js +136 -0
  154. package/dist/master/ws-hub/index.js +9 -0
  155. package/dist/master/ws-hub/internal.js +35 -0
  156. package/dist/master/ws-hub/routing.js +295 -0
  157. package/dist/master/ws-hub/sessions.js +130 -0
  158. package/dist/master/ws-hub.js +11 -0
  159. package/dist/shared/agent-profile.js +44 -0
  160. package/dist/shared/constants.js +55 -0
  161. package/dist/shared/dedup.js +33 -0
  162. package/dist/shared/group-context.js +62 -0
  163. package/dist/shared/json-codec.js +33 -0
  164. package/dist/shared/logger.js +136 -0
  165. package/dist/shared/mention.js +22 -0
  166. package/dist/shared/network.js +40 -0
  167. package/dist/shared/parse.js +18 -0
  168. package/dist/shared/prompt-composer.js +171 -0
  169. package/dist/shared/protocol/client-messages.js +8 -0
  170. package/dist/shared/protocol/enums.js +6 -0
  171. package/dist/shared/protocol/federation.js +62 -0
  172. package/dist/shared/protocol/guards.js +87 -0
  173. package/dist/shared/protocol/server-messages.js +8 -0
  174. package/dist/shared/protocol/types.js +8 -0
  175. package/dist/shared/protocol.js +19 -0
  176. package/dist/shared/readonly-allowlist.js +122 -0
  177. package/dist/shared/rotom-cli-prompt.js +23 -0
  178. package/dist/shared/skill-context.js +19 -0
  179. package/dist/shared/skill-md.js +43 -0
  180. package/dist/shared/slash-commands.js +50 -0
  181. package/dist/shared/time.js +80 -0
  182. package/dist/shared/title.js +46 -0
  183. package/dist/shared/url-extractor.js +99 -0
  184. package/migrations/001-schema.sql +942 -0
  185. package/package.json +68 -0
  186. package/scripts/fix-node-pty-perms.mjs +46 -0
  187. package/skill/rotom-a2a-communicate/SKILL.md +257 -0
  188. package/skill/rotom-bus-host/SKILL.md +78 -0
  189. package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
@@ -0,0 +1,877 @@
1
+ /**
2
+ * ExecutorWorker — 单个数字员工的完整生命周期
3
+ *
4
+ * 每个 worker 拥有独立的 WebSocket 连接、身份、CLI 后端和任务队列。
5
+ * 职责:Issue 执行 + 群聊回复。
6
+ *
7
+ * 本文件是 integration glue:WS 路由(handleMessage)、共享可变状态
8
+ * (activeTasks / pendingApprovals / pendingAppends / ws / sessions)、
9
+ * 以及所有 handler 共用的 helpers(send* / agentEnv / resolveIssueCwd /
10
+ * sendSessionSnapshot)。各执行域拆出独立文件:
11
+ * • SessionStore → session-store.ts(持久化层)
12
+ * • WorkerConnection → worker-connection.ts(WS / 心跳 / 重连)
13
+ * • IssueHandler → worker-issue.ts(issue 执行循环)
14
+ * • ChatHandler → worker-chat.ts(群聊回复 + 协作启动)
15
+ */
16
+ import { WebSocket } from "ws";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import fs from "node:fs";
20
+ import { composePrompt } from "../shared/prompt-composer.js";
21
+ import { parseAgentProfile } from "../shared/agent-profile.js";
22
+ import { SessionStore } from "./session-store.js";
23
+ import { WorkerConnection } from "./worker-connection.js";
24
+ import { IssueHandler } from "./worker-issue.js";
25
+ import { ChatHandler } from "./worker-chat.js";
26
+ import { ensureBareCloneAsync, addWorktreeAsync, removeWorktree, getBarePathForUrl, getWorktreePathForUrl } from "./repo-cache.js";
27
+ import { createLogger } from "../shared/logger.js";
28
+ const log = createLogger("mesh-executor-worker", { stream: "stderr" });
29
+ // ── Worker ──────────────────────────────────────────────────────────────
30
+ /** issue_usage_progress 推送节流窗口:执行过程中每秒最多推一次累积 usage。 */
31
+ const USAGE_THROTTLE_MS = 1000;
32
+ /**
33
+ * 累积两份 TokenUsage 字段。各字段独立 sum,undefined 的字段保留另一份的值。
34
+ * 用于把 executor 的单轮增量累积成跨轮总量。
35
+ */
36
+ function mergeTokenUsage(a, b) {
37
+ return {
38
+ inputTokens: sumIfNum(a.inputTokens, b.inputTokens),
39
+ outputTokens: sumIfNum(a.outputTokens, b.outputTokens),
40
+ cacheReadTokens: sumIfNum(a.cacheReadTokens, b.cacheReadTokens),
41
+ cacheCreationTokens: sumIfNum(a.cacheCreationTokens, b.cacheCreationTokens),
42
+ // 成本不累积(result 终态覆盖时会一次性给准确值,中间累积无意义)
43
+ totalCostUsd: b.totalCostUsd ?? a.totalCostUsd,
44
+ };
45
+ }
46
+ function sumIfNum(x, y) {
47
+ if (typeof x === "number" && typeof y === "number")
48
+ return x + y;
49
+ return x ?? y;
50
+ }
51
+ export class ExecutorWorker {
52
+ config;
53
+ executor;
54
+ masterUrl;
55
+ rotomHome;
56
+ // ── Shared mutable state (touched by handlers + WS router) ────────
57
+ // activeTasks key shape: issueId | `chat:${requestId}`
58
+ activeTasks = new Map();
59
+ /**
60
+ * Approvals awaiting the user's Accept/Deny. Keyed by approvalId (the same
61
+ * id sent to Master and rendered in the dashboard). Issue cancel resolves
62
+ * every entry for that issue to "deny" so codex can unblock and exit.
63
+ */
64
+ pendingApprovals = new Map();
65
+ /**
66
+ * Issue 的内置 repo 上下文缓存(migration 051)。issue_assigned/continue/append
67
+ * 收到 repoCtx 时记下,issue_cancelled 时查 map 后清理本机 worktree。
68
+ *
69
+ * Keyed by issueId。issue 完成/失败时不清(用户可能想保留 worktree 看产物),
70
+ * 只在 cancelled(用户主动放弃)时清理。issue_delete 走 master DELETE API,
71
+ * 若 assignee 是本机 agent,master 发 issue_cancelled,本机清。
72
+ */
73
+ issueRepoCtxs = new Map();
74
+ // 已入队但尚未消费的「追加指令」。user 在 in_progress 期间提交的 prompt
75
+ // 先攒在这里,当前一轮 CLI 收尾时(runIssueExecution 的 finally)合并起一轮新执行。
76
+ pendingAppends = new Map();
77
+ /**
78
+ * Token usage 累积器:每个正在执行的 issue 一条。executor 的 onUsage
79
+ * 回调给的是**单轮增量**,这里 sum 起来做累积值,leading+trailing 1s 节流
80
+ * 后通过 issue_usage_progress 推给 master(由 master 转发给订阅了该 issue
81
+ * 详情的 dashboard 客户端,不落 DB)。
82
+ *
83
+ * 终态时(runIssueExecution 的 finally)由 flushIssueUsage 强制推一次,
84
+ * 并用 result.usage 覆盖累积值——保证 reload 后看到的 issue.usage 与最后
85
+ * 一次推送一致(避免 assistant 增量与 result 终态口径不一致导致数字跳变)。
86
+ */
87
+ usageAccumulators = new Map();
88
+ /** WS socket — assigned by WorkerConnection.connect(). Null until first connect. */
89
+ ws;
90
+ stopped = false;
91
+ // ── Config-derived readonly fields ────────────────────────────────
92
+ tag;
93
+ workingDir;
94
+ maxConcurrent;
95
+ cliTool;
96
+ /** agents.profile 解析后缓存,供 composePrompt() 渲染 agent-role 层。
97
+ * 初始值来自 executor.config.json,运行时收到带 agentProfile 字段的
98
+ * WS 消息(issue_assigned/continue/append、a2a_message)
99
+ * 时由 setAgentProfile() 更新 —— 这是 Dashboard 编辑后下一条消息即生效的入口。 */
100
+ agentProfile;
101
+ // ── Subsystems (constructed after the fields above are initialized) ──
102
+ sessions;
103
+ connection;
104
+ issues;
105
+ chat;
106
+ constructor(config, executor, masterUrl, cliTool,
107
+ /**
108
+ * Rotom 主目录(通常 `~/.rotom`)。SessionStore 文件落在该目录下
109
+ * (`<rotomHome>/sessions.json`),与 per-group cwd 派生路径**解耦**——
110
+ * session 是 worker 全局状态,不应该跟着 groupId 散布到 `<base>/<groupId>/` 里。
111
+ */
112
+ rotomHome,
113
+ /**
114
+ * 共享的 SessionStore 实例。executor 进程内所有 worker 必须共用同一个,
115
+ * 否则每个 worker 各自 flush 自己的内存 map 到同一个 sessions.json,
116
+ * 后 flush 的 worker 会覆盖先 flush 的 worker 写入的条目(典型表现:
117
+ * 重启后某些 cliTool 的 session 「消失」)。由 index.ts 创建并传入。
118
+ * 不传时(主要为了测试隔离)回退到自建实例。
119
+ */
120
+ sharedSessions) {
121
+ this.config = config;
122
+ this.executor = executor;
123
+ this.masterUrl = masterUrl;
124
+ this.rotomHome = rotomHome;
125
+ this.tag = `[executor:${config.name}]`;
126
+ // workingDir 是 per-group cwd 派生的 base,完全本机解析,与 master 无关。
127
+ // index.ts 启动时已校验存在 / 可读;此处仅做兜底默认值。
128
+ if (!config.workingDir) {
129
+ log.warn(this.tag, "WARN: no workingDir configured, falling back to ~/.rotom (likely not a project dir, agent may have nothing to read)");
130
+ }
131
+ this.workingDir = config.workingDir || path.join(os.homedir(), ".rotom");
132
+ this.maxConcurrent = config.maxConcurrent ?? 2;
133
+ this.cliTool = cliTool;
134
+ this.sessions = sharedSessions ?? new SessionStore();
135
+ this.agentProfile = parseAgentProfile(JSON.stringify(config.profile ?? null));
136
+ // Subsystems store the worker reference only — no work in constructors.
137
+ // Safe to construct after sessions/agentProfile are initialized.
138
+ this.connection = new WorkerConnection(this);
139
+ this.issues = new IssueHandler(this);
140
+ this.chat = new ChatHandler(this);
141
+ // 不再 mkdirSync this.workingDir —— 启动时已校验 base 存在;
142
+ // per-group 子目录在 resolveIssueCwd() 首次解析时按需 mkdir -p。
143
+ }
144
+ /**
145
+ * 解析本 issue 实际使用的 spawn cwd。优先级:
146
+ * 1. config.workingDirMap[groupId] —— per-group 显式覆盖
147
+ * 2. <this.workingDir>/<groupId> —— 按 groupId 派生(本机 base 下的子目录)
148
+ * 3. this.workingDir —— groupId 缺失时的兜底(实际不该发生,master 总会带 groupId)
149
+ *
150
+ * 派生后 / override 命中的目录按需 mkdir -p(只读语义下,目录创建一次后
151
+ * agent 不会再写,后续 issue 直接复用)。
152
+ *
153
+ * 跨机器部署安全:每台 executor 用自己的 this.workingDir,各机器各自的
154
+ * `<base>/<groupId>` 物理隔离;master 推送的 workingDir 永远不会被用到这里。
155
+ */
156
+ /**
157
+ * 解析本 issue 实际使用的 spawn cwd。优先级:
158
+ * 0. master 推送的 cwd(Dashboard 群工作目录)优先 —— 跨机器部署时
159
+ * 若本机不存在该路径则静默回落本地派生,保证 worker 永远能 spawn。
160
+ * 1. repoCtx(内置 repo, migration 051):master 下发 repoUrl 时,在
161
+ * `<this.workingDir>/<groupId>/<issueId>/repos/primary/` 起 git worktree
162
+ * 作为 cwd;extraRepos 各自一个 worktree,通过 symlink 挂到 primary 下。
163
+ * 单 group 多分支天然隔离,同 URL 跨 group/issue 全局复用 bare clone。
164
+ * 2. config.workingDirMap[groupId] —— per-group 显式覆盖
165
+ * 3. <this.workingDir>/<groupId> —— 按 groupId 派生(本机 base 下的子目录)
166
+ * 4. this.workingDir —— groupId 缺失时的兜底(实际不该发生,master 总会带 groupId)
167
+ *
168
+ * 派生后 / override 命中的目录按需 mkdir -p(只读语义下,目录创建一次后
169
+ * agent 不会再写,后续 issue 直接复用)。
170
+ *
171
+ * 跨机器部署安全:每台 executor 用自己的 this.workingDir,各机器各自的
172
+ * `<base>/<groupId>` 物理隔离;master 推送的 workingDir 永远不会被用到这里。
173
+ *
174
+ * @param repoCtx 内置 repo 上下文。repoUrl 非空时启用 worktree 模式;否则走 1-4 老路径。
175
+ * @returns cwd 字符串。worktree 模式下返回 primary worktree 路径(已存在)。
176
+ */
177
+ async resolveIssueCwd(groupId, override, repoCtx) {
178
+ // 0. 内置 repo(migration 051)优先级最高:master 下发 repoUrl 且 groupId 已知时,
179
+ // 在本机起 worktree 作为 cwd。worktree 路径完全由 executor 本地决定,
180
+ // 忽略 master 推送的 override cwd(那是 group.working_dir,worktree 模式下
181
+ // 不再适用——agent 应在 worktree 里跑,不是 group 共享目录)。
182
+ // worktree 创建可能抛错(bare clone 失败等),降级到老路径让 issue/chat 至少能跑。
183
+ // issueId:issue 模式必须(per-issue worktree 路径);group 模式不需要(chat 可不传)。
184
+ if (repoCtx?.repoUrl && groupId) {
185
+ try {
186
+ return await this.resolveRepoCwd(groupId, repoCtx.issueId ?? "chat", {
187
+ repoUrl: repoCtx.repoUrl,
188
+ repoBranch: repoCtx.repoBranch,
189
+ extraRepos: repoCtx.extraRepos,
190
+ worktreeMode: repoCtx.worktreeMode,
191
+ });
192
+ }
193
+ catch (err) {
194
+ log.warn(this.tag, `worktree setup failed for ${repoCtx.issueId ?? "chat"} in group ${groupId}, fallback to derived dir: ${err?.message ?? err}`);
195
+ }
196
+ }
197
+ // 1. master 推送的 cwd(Dashboard 配置的群工作目录)—— 跨机器部署时
198
+ // 若本机不存在该路径则静默回落本地派生,保证 worker 永远能 spawn。
199
+ // 仅在未启 worktree 模式时生效(repoCtx 为空或失败时)。
200
+ if (override && fs.existsSync(override)) {
201
+ fs.mkdirSync(override, { recursive: true });
202
+ return override;
203
+ }
204
+ // 2. per-group override
205
+ if (groupId && this.config.workingDirMap?.[groupId]) {
206
+ const mapped = this.config.workingDirMap[groupId];
207
+ fs.mkdirSync(mapped, { recursive: true });
208
+ return mapped;
209
+ }
210
+ // 3. 按 groupId 派生
211
+ if (groupId) {
212
+ const derived = path.join(this.workingDir, groupId);
213
+ fs.mkdirSync(derived, { recursive: true });
214
+ return derived;
215
+ }
216
+ // 4. 兜底
217
+ return this.workingDir;
218
+ }
219
+ /**
220
+ * 内置 repo worktree 模式:为该 (group, issue, repo) 起一个 git worktree。
221
+ *
222
+ * 物理布局:
223
+ * <workingDir>/<groupId>/<issueId>/
224
+ * ├── repos/
225
+ * │ ├── primary/ <- primaryRepo worktree (agent cwd)
226
+ * │ ├── <repo-B>/ <- extraRepo worktree
227
+ * │ └── <repo-C>/
228
+ * └── artifacts/ <- 该 issue 的产物目录(空,留给 agent 写)
229
+ *
230
+ * extraRepo 通过 primary 下相对 symlink `repos/<id>` -> `../<id>` 让 agent
231
+ * 在 cwd 内直接访问。symlink 而非直接在 primary 下 clone,是为了让 primary 自己
232
+ * 的 git 不会把 extraRepo 当成 untracked 文件,两条 worktree 互不干扰。
233
+ */
234
+ async resolveRepoCwd(groupId, issueId, repoCtx) {
235
+ // 全局布局:~/.rotom/repos/<repoName>-<repoId8>-wt/<slot>/
236
+ // - group 模式:slot = group-<groupId8>(每 group 一个 worktree,跨群不共享)
237
+ // - issue 模式:slot = <issueId8>(per-issue)
238
+ //
239
+ // bare clone(.git 对象库)全局共享,只克隆一次;worktree 各自一份 checkout,
240
+ // 跨群/跨 issue 改的是各自的工作树,互不干扰。group 模式下同 group 的 issue 共用
241
+ // 一个 worktree(切分支会互相打断,适合单分支线性);issue 模式完全并行。
242
+ const mode = repoCtx.worktreeMode === "issue" ? "issue" : "group";
243
+ const groupId8 = groupId.slice(0, 8);
244
+ const issueId8 = issueId.slice(0, 8);
245
+ const slot = mode === "group" ? `group-${groupId8}` : issueId8;
246
+ // primary worktree
247
+ const { barePath: primaryBare } = await ensureBareCloneAsync(repoCtx.repoUrl);
248
+ const primaryWt = getWorktreePathForUrl(repoCtx.repoUrl, slot);
249
+ // 派生分支后缀:group 模式用 groupId8(每 group 独立),issue 模式用 issueId8。
250
+ // 避免 group 模式 issueId8 缺省时退化成 "tmp"(出现 master-rotom-tmp 这种无名分支)。
251
+ const primarySuffix = mode === "group" ? groupId8 : issueId8;
252
+ // addWorktreeAsync 创建派生分支 <branch>-rotom-<suffix> 并 checkout 到该分支。
253
+ // 不再 checkoutWorktreeAsync 切原分支——git worktree 不允许同一分支在多个
254
+ // worktree 同时 checkout(多 group 同 URL 同分支会冲突)。每个 group/issue 在
255
+ // 自己的派生分支上工作,互不干扰,agent 可 push 该派生分支或 merge 回原分支。
256
+ const primaryBranch = repoCtx.repoBranch;
257
+ await addWorktreeAsync(primaryBare, primaryWt, primaryBranch, primarySuffix);
258
+ // extraRepo worktrees + symlink(挂到 primary 的 mountPath)
259
+ for (const extra of repoCtx.extraRepos ?? []) {
260
+ const { barePath: extraBare } = await ensureBareCloneAsync(extra.url);
261
+ const extraWt = getWorktreePathForUrl(extra.url, slot);
262
+ const extraSuffix = primarySuffix;
263
+ const extraBranch = extra.branch;
264
+ await addWorktreeAsync(extraBare, extraWt, extraBranch, extraSuffix);
265
+ // mountPath 形如 "repos/<repo-B>";在 primary 下建相对 symlink
266
+ // primary/repos/<repo-B> -> <extraWt绝对路径>(用相对,基于 primary 所在目录)
267
+ if (extra.mountPath) {
268
+ const linkPath = path.join(primaryWt, extra.mountPath);
269
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
270
+ try {
271
+ fs.rmSync(linkPath, { force: true });
272
+ }
273
+ catch { /* 可能不存在 */ }
274
+ // primary 在 ~/.rotom/repos/<repo-id>-wt/<slot>/,extra 在 ~/.rotom/repos/<extra-id>-wt/<slot>/
275
+ // 相对路径:../../../../<extra-id>-wt/<slot>
276
+ const target = path.relative(path.dirname(linkPath), extraWt);
277
+ try {
278
+ fs.symlinkSync(target, linkPath, "dir");
279
+ }
280
+ catch (err) {
281
+ log.warn(this.tag, `symlink create failed for ${linkPath}: ${err?.message ?? err}`);
282
+ }
283
+ }
284
+ }
285
+ return primaryWt;
286
+ }
287
+ /**
288
+ * 清理某 issue 的所有 worktree(primary + extras)。issue 完成/取消/删除时调。
289
+ * bare clone 不删(全局复用)。失败只 warn,不阻塞 issue 流程。
290
+ *
291
+ * group 模式:worktree 是共享的(<groupDir>/repos/primary/),不按 issue 清理 ——
292
+ * 删了别的 issue 也用不了。留给 group 删除 / `rotom repo prune` 手动清。
293
+ * issue 模式:清 per-issue worktree(<groupDir>/<issueId>/repos/)。
294
+ *
295
+ * 跨机器部署时只能清理本机的 worktree;其它机器的 issue 完成时各自清理自己的。
296
+ */
297
+ cleanupIssueWorktrees(groupId, issueId, repoCtx) {
298
+ if (!groupId || !issueId || !repoCtx?.repoUrl)
299
+ return;
300
+ // group 模式共享 worktree,不按 issue 清
301
+ if (repoCtx.worktreeMode !== "issue")
302
+ return;
303
+ // issue 模式:清 ~/.rotom/repos/<repo-id>-wt/<issueId8>/
304
+ const issueId8 = issueId.slice(0, 8);
305
+ // primary
306
+ try {
307
+ const barePath = getBarePathForUrl(repoCtx.repoUrl);
308
+ const primaryWt = getWorktreePathForUrl(repoCtx.repoUrl, issueId8);
309
+ removeWorktree(barePath, primaryWt);
310
+ }
311
+ catch (err) {
312
+ log.warn(this.tag, `cleanup primary worktree failed for ${issueId}: ${err?.message ?? err}`);
313
+ }
314
+ // extras
315
+ for (const extra of repoCtx.extraRepos ?? []) {
316
+ try {
317
+ const barePath = getBarePathForUrl(extra.url);
318
+ const wt = getWorktreePathForUrl(extra.url, issueId8);
319
+ removeWorktree(barePath, wt);
320
+ }
321
+ catch (err) {
322
+ log.warn(this.tag, `cleanup extra worktree ${extra.id} failed for ${issueId}: ${err?.message ?? err}`);
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * 更新本地缓存的 agentProfile —— master 在 dispatch 时通过 WS 消息字段
328
+ * `agentProfile` 推送 Dashboard 编辑后的最新值。worker 收到后调用本方法,
329
+ * 下一次 composePrompt() 就会渲染新角色信息。
330
+ *
331
+ * JSON.stringify 比对避免无变化时刷日志。空 profile 也会更新(用户可能
332
+ * 在 Dashboard 清空了字段,需如实下沉)。
333
+ */
334
+ setAgentProfile(p) {
335
+ if (JSON.stringify(p) === JSON.stringify(this.agentProfile))
336
+ return;
337
+ this.agentProfile = p;
338
+ const sig = p
339
+ ? `position=${p.position ?? "-"}, bio=${p.bio ? "(set)" : "-"}, category=${p.category ?? "-"}`
340
+ : "(null)";
341
+ log.info(this.tag, `agentProfile updated (${sig})`);
342
+ }
343
+ // ── Lifecycle ─────────────────────────────────────────────────────
344
+ start() {
345
+ this.connection.start();
346
+ }
347
+ stop() {
348
+ this.connection.stop();
349
+ // SessionStore is in-memory only now; persistence lives in master DB.
350
+ }
351
+ // ── Message router ────────────────────────────────────────────────
352
+ handleMessage(msg) {
353
+ // issue_assigned / continue / append 分支异步 resolve worktree(可能耗时几秒到
354
+ // 几分钟做 bare clone)。用 void IIFE 包住,不阻塞 handleMessage 返回,其他 WS
355
+ // 消息(心跳、chat 取消、其他 issue 进度)继续能处理。错误在 IIFE 内 catch。
356
+ if (msg.type === "issue_assigned" || msg.type === "issue_continue" || msg.type === "issue_append") {
357
+ void this.handleIssueRepoMsg(msg).catch((err) => {
358
+ log.error(this.tag, "issue repo msg error:", err);
359
+ });
360
+ return;
361
+ }
362
+ if (msg.type === "auth_ok") {
363
+ log.info(this.tag, "Authenticated");
364
+ // Push initial SessionStore snapshot so master's DB is populated
365
+ // (covers the legacy backfill path: entries read from sessions.json
366
+ // get upserted into master DB on first auth). Master will then push
367
+ // back a session_sync_push with the worker's active sessions.
368
+ this.sendSessionSnapshot();
369
+ this.connection.startHeartbeat();
370
+ }
371
+ if (msg.type === "session_sync_push") {
372
+ // Master pushes the worker's active sessions from DB on auth. Hydrate
373
+ // the in-memory store so subsequent chat turns can --resume.
374
+ const entries = msg.entries;
375
+ if (Array.isArray(entries)) {
376
+ this.sessions.hydrate(entries.map(e => ({
377
+ cliTool: e.cliTool,
378
+ groupId: e.groupId,
379
+ sessionId: e.sessionId,
380
+ usage: e.usage ?? undefined,
381
+ model: e.model ?? undefined,
382
+ cumulativeCostUsd: e.cumulativeCostUsd,
383
+ })));
384
+ }
385
+ return;
386
+ }
387
+ if (msg.type === "auth_fail") {
388
+ log.error(this.tag, `Auth failed: ${msg.reason}`);
389
+ return;
390
+ }
391
+ // Issue assignment
392
+ // issue_assigned / continue / append 三分支在 handleIssueRepoMsg(async)里处理,
393
+ // 此处已被开头 void IIFE 拦截,不会走到。
394
+ if (msg.type === "issue_created") {
395
+ log.info(this.tag, `New issue: "${msg.title}" (awaiting manual assignment)`);
396
+ }
397
+ // Issue cancellation — abort the in-flight CLI process if we own the task.
398
+ if (msg.type === "issue_cancelled") {
399
+ const issueId = msg.issueId;
400
+ if (!issueId)
401
+ return;
402
+ // Resolve any pending approvals for this issue as "deny" so codex can
403
+ // unblock its parked JSON-RPC request and exit cleanly.
404
+ for (const [approvalId, p] of this.pendingApprovals) {
405
+ if (p.issueId !== issueId)
406
+ continue;
407
+ this.pendingApprovals.delete(approvalId);
408
+ p.resolve({ decision: "deny" });
409
+ }
410
+ const task = this.activeTasks.get(issueId);
411
+ if (task) {
412
+ log.info(this.tag, `Cancel requested for ${issueId}, aborting child process`);
413
+ task.aborted = true;
414
+ try {
415
+ task.controller.abort();
416
+ }
417
+ catch { /* noop */ }
418
+ }
419
+ else {
420
+ log.info(this.tag, `Cancel requested for ${issueId} but no active task here`);
421
+ }
422
+ // 清理本机 worktree(若该 issue 走了 repo 模式)。issueRepoCtxs 命中即清。
423
+ const repoCtx = this.issueRepoCtxs.get(issueId);
424
+ if (repoCtx) {
425
+ this.cleanupIssueWorktrees(repoCtx.groupId, issueId, repoCtx);
426
+ this.issueRepoCtxs.delete(issueId);
427
+ }
428
+ }
429
+ // Issue interrupt — 对齐 codex CLI 的 ESC:abort 当前 CLI 进程但不翻转
430
+ // issue status(保持 in_progress)。runIssueExecution 的 finally 块会接管:
431
+ // • pendingAppends[issueId] 非空 → 合并队列 + `--resume <lastSessionId>`
432
+ // 起新一轮(等同于 codex 的 "interrupt + flush queued steers")
433
+ // • 队列空 → 不重启,issue 留在 idle in_progress,用户下次 append 时
434
+ // 走 issue_append 的 idle 分支用 sessionId resume
435
+ // 与 issue_cancelled 的关键差异:不 resolve pendingApprovals(中断不
436
+ // 终结 issue,审批 gate 状态保留供下一轮继承)、不改 status。
437
+ if (msg.type === "issue_interrupt") {
438
+ const issueId = msg.issueId;
439
+ if (!issueId)
440
+ return;
441
+ const task = this.activeTasks.get(issueId);
442
+ if (task) {
443
+ log.info(this.tag, `Interrupt requested for ${issueId}, aborting current CLI turn`);
444
+ task.aborted = true;
445
+ // 标记 interrupted 让 finally 块区分 cancel(丢队列)vs interrupt(消费队列续跑)。
446
+ task.interrupted = true;
447
+ try {
448
+ task.controller.abort();
449
+ }
450
+ catch { /* noop */ }
451
+ }
452
+ else {
453
+ log.info(this.tag, `Interrupt requested for ${issueId} but no active task here`);
454
+ }
455
+ }
456
+ // Chat reply cancellation — mirror of issue_cancelled for the chat path.
457
+ // activeTasks key is `chat:${requestId}` (set by handleChatReply).
458
+ // No pendingApprovals cleanup needed — chat path doesn't wire approval
459
+ // gating (conversational tool calls stay auto-accepted).
460
+ // No-op when the task already finished naturally (race: user clicked ⏹
461
+ // right as the executor resolved) — log + return.
462
+ if (msg.type === "chat_cancelled") {
463
+ const requestId = msg.requestId;
464
+ if (!requestId)
465
+ return;
466
+ const taskKey = `chat:${requestId}`;
467
+ const task = this.activeTasks.get(taskKey);
468
+ if (task) {
469
+ log.info(this.tag, `Chat cancel requested for ${requestId}, aborting child process`);
470
+ task.aborted = true;
471
+ try {
472
+ task.controller.abort();
473
+ }
474
+ catch { /* noop */ }
475
+ }
476
+ else {
477
+ log.info(this.tag, `Chat cancel for ${requestId} but no active task (already finished?)`);
478
+ }
479
+ }
480
+ // issue_continue 在 handleIssueRepoMsg(async)里处理。
481
+ // Issue append — user typed a follow-up while the issue is still active.
482
+ // (在 handleIssueRepoMsg 里处理)
483
+ // User decided an approval — hand the verdict to the parked codex call.
484
+ if (msg.type === "issue_approval_response") {
485
+ const approvalId = msg.approvalId;
486
+ const decision = msg.decision;
487
+ if (!approvalId || (decision !== "accept" && decision !== "deny"))
488
+ return;
489
+ const pending = this.pendingApprovals.get(approvalId);
490
+ if (!pending) {
491
+ log.warn(this.tag, `approval response for unknown id ${approvalId}`);
492
+ return;
493
+ }
494
+ this.pendingApprovals.delete(approvalId);
495
+ if (decision === "accept") {
496
+ pending.resolve({ decision: "accept" });
497
+ }
498
+ else {
499
+ const feedback = typeof msg.feedback === "string" && msg.feedback
500
+ ? msg.feedback
501
+ : undefined;
502
+ pending.resolve({ decision: "deny", feedback });
503
+ }
504
+ }
505
+ // Chat message reply
506
+ if (msg.type === "a2a_message") {
507
+ const { requestId, from, payload, conversation, agentProfile, cwd: overrideCwd, repoUrl, repoBranch, extraRepos, worktreeMode } = msg;
508
+ if (agentProfile)
509
+ this.setAgentProfile(agentProfile);
510
+ const content = payload?.message || "";
511
+ const fromName = from?.name || "unknown";
512
+ // One-on-one: always process
513
+ // Group: only process if @mentioned (qaMode 例外:master 用 --need-reply 触发,
514
+ // 已自动补 @target,但兜底也允许 qaMode 直接绕过 @ 检查)
515
+ const isGroup = conversation?.type === "group";
516
+ const isMentioned = content.includes(`@${this.config.name}`);
517
+ const qaMode = msg.qaMode === true;
518
+ log.info(this.tag, `a2a_message from ${fromName} requestId=${requestId} isGroup=${isGroup} isMentioned=${isMentioned} qaMode=${qaMode} contentLen=${content.length} contentHead=${JSON.stringify(content.slice(0, 60))}`);
519
+ if (repoUrl) {
520
+ log.info(this.tag, `repoCtx: url=${repoUrl} branch=${repoBranch} mode=${worktreeMode} extras=${extraRepos ? JSON.stringify(extraRepos.map(e => e.id)) : "(none)"}`);
521
+ }
522
+ if (!isGroup || isMentioned || qaMode) {
523
+ log.info(this.tag, `Chat from ${fromName}: ${content.slice(0, 80)}...`);
524
+ this.chat.handleChatReply(requestId, content, fromName, conversation, overrideCwd, { issueId: "chat", repoUrl, repoBranch, extraRepos, worktreeMode });
525
+ }
526
+ else {
527
+ log.info(this.tag, `SKIP group message from ${fromName}: not @mentioned (looking for @${this.config.name})`);
528
+ }
529
+ }
530
+ // Session management — master asks for visibility / control over the
531
+ // per-(cliTool, groupId) sessions this worker tracks. The list path is
532
+ // covered by the unsolicited `session_snapshot` push (see
533
+ // sendSessionSnapshot above), so workers only handle view / delete here.
534
+ if (msg.type === "session_view_request") {
535
+ const requestId = msg.requestId;
536
+ const groupId = msg.groupId;
537
+ const sessionId = msg.sessionId;
538
+ const tailLines = typeof msg.tailLines === "number" ? msg.tailLines : undefined;
539
+ if (!requestId || !groupId || !sessionId)
540
+ return;
541
+ void this.handleSessionViewRequest(requestId, groupId, sessionId, tailLines);
542
+ return;
543
+ }
544
+ if (msg.type === "session_delete_request") {
545
+ const requestId = msg.requestId;
546
+ const groupId = msg.groupId;
547
+ const sessionId = msg.sessionId;
548
+ if (!requestId || !groupId || !sessionId)
549
+ return;
550
+ const had = this.sessions.has(this.cliTool, groupId, sessionId);
551
+ if (had) {
552
+ this.sessions.delete(this.cliTool, groupId);
553
+ log.info(this.tag, `Session deleted via dashboard: ${this.cliTool}:${groupId} → ${sessionId}`);
554
+ // 通知 master 标记失效(不删行,保留历史);再推 snapshot 同步 active 列表。
555
+ this.send({
556
+ type: "session_invalidated",
557
+ cliTool: this.cliTool,
558
+ groupId,
559
+ sessionId,
560
+ });
561
+ this.sendSessionSnapshot();
562
+ }
563
+ this.send({
564
+ type: "session_delete_response",
565
+ requestId,
566
+ groupId,
567
+ sessionId,
568
+ ok: had,
569
+ error: had ? undefined : "session not found in this worker",
570
+ });
571
+ return;
572
+ }
573
+ }
574
+ /**
575
+ * 异步处理 issue_assigned / continue / append。worktree 创建(ensureBareCloneAsync
576
+ * + addWorktreeAsync)用 spawn 而非 spawnSync,避免大仓库 clone 阻塞 executor 其他
577
+ * WS 处理(心跳、chat 取消、其他 issue 进度)。第一次 bare clone 可能几分钟,
578
+ * 用户可见进度事件("📦 正在准备代码仓库...")。
579
+ */
580
+ async handleIssueRepoMsg(msg) {
581
+ if (msg.type === "issue_assigned") {
582
+ const { issueId, title, description, groupId, slashCommand, approvalPolicy, agentProfile, cwd: overrideCwd, repoUrl, repoBranch, extraRepos, worktreeMode } = msg;
583
+ if (agentProfile)
584
+ this.setAgentProfile(agentProfile);
585
+ log.info(this.tag, `Issue assigned: "${title}" (${issueId}, group=${groupId ?? "(none)"})${slashCommand ? ` [${slashCommand}]` : ""}${approvalPolicy ? ` [${approvalPolicy}]` : ""}${repoUrl ? ` [repo:${worktreeMode || "group"}]` : ""}`);
586
+ if (repoUrl && groupId && issueId) {
587
+ this.sendUpdate(issueId, "in_progress", "📦 正在准备代码仓库(worktree)...", undefined, overrideCwd);
588
+ }
589
+ const cwd = await this.resolveIssueCwd(groupId, overrideCwd, { issueId, repoUrl, repoBranch, extraRepos, worktreeMode });
590
+ if (repoUrl && issueId) {
591
+ this.issueRepoCtxs.set(issueId, { groupId, repoUrl, extraRepos: extraRepos?.map((e) => ({ id: e.id, url: e.url })), worktreeMode });
592
+ }
593
+ this.issues.executeIssue(issueId, title, description || "", cwd, slashCommand, approvalPolicy, { issueId, groupId, repoUrl, repoBranch, extraRepos, worktreeMode });
594
+ return;
595
+ }
596
+ if (msg.type === "issue_continue") {
597
+ const issueId = msg.issueId;
598
+ const title = msg.title;
599
+ const prompt = msg.prompt;
600
+ const sessionId = msg.sessionId;
601
+ const groupId = msg.groupId;
602
+ const slashCommand = msg.slashCommand;
603
+ const approvalPolicy = msg.approvalPolicy;
604
+ const agentProfile = msg.agentProfile;
605
+ const overrideCwd = msg.cwd;
606
+ const repoUrl = msg.repoUrl;
607
+ const repoBranch = msg.repoBranch;
608
+ const extraRepos = msg.extraRepos;
609
+ const worktreeMode = msg.worktreeMode;
610
+ if (agentProfile)
611
+ this.setAgentProfile(agentProfile);
612
+ if (!issueId || !prompt)
613
+ return;
614
+ log.info(this.tag, `Issue continue: "${title ?? "(no title)"}" (${issueId}, session=${sessionId ?? "(none)"}${slashCommand ? `, slash=${slashCommand}` : ""}${approvalPolicy ? `, policy=${approvalPolicy}` : ""}${repoUrl ? `, repo:${worktreeMode || "group"}` : ""})`);
615
+ const cwd = await this.resolveIssueCwd(groupId, overrideCwd, { issueId, repoUrl, repoBranch, extraRepos, worktreeMode });
616
+ const issueHeader = `[当前群活跃 issue]\n` +
617
+ `- #${issueId.slice(0, 8)} in_progress "${title ?? "(unnamed)"}" by ${this.config.name}\n` +
618
+ `提示:你正在执行此 issue,工作目录 **可写**,直接按任务描述动手即可。` +
619
+ `**不要为此任务再创建新 issue。**\n`;
620
+ const body = `${issueHeader}\n${prompt}`;
621
+ const composed = composePrompt({
622
+ mode: "issue",
623
+ agentName: this.config.name,
624
+ agentProfile: this.agentProfile,
625
+ group: null,
626
+ cwd,
627
+ body,
628
+ approvalPolicy,
629
+ });
630
+ this.issues.runIssueExecution(issueId, composed.final, cwd, sessionId, slashCommand, approvalPolicy, composed, { issueId, groupId, repoUrl, repoBranch, extraRepos, worktreeMode });
631
+ return;
632
+ }
633
+ if (msg.type === "issue_append") {
634
+ const issueId = msg.issueId;
635
+ const title = msg.title;
636
+ const prompt = msg.prompt;
637
+ const sessionId = msg.sessionId;
638
+ const groupId = msg.groupId;
639
+ const slashCommand = msg.slashCommand;
640
+ const approvalPolicy = msg.approvalPolicy;
641
+ const agentProfile = msg.agentProfile;
642
+ const overrideCwd = msg.cwd;
643
+ const repoUrl = msg.repoUrl;
644
+ const repoBranch = msg.repoBranch;
645
+ const extraRepos = msg.extraRepos;
646
+ const worktreeMode = msg.worktreeMode;
647
+ if (agentProfile)
648
+ this.setAgentProfile(agentProfile);
649
+ if (!issueId || !prompt)
650
+ return;
651
+ const issueHeader = `[当前群活跃 issue]\n` +
652
+ `- #${issueId.slice(0, 8)} in_progress "${title ?? "(unnamed)"}" by ${this.config.name}\n` +
653
+ `提示:你正在执行此 issue,工作目录 **可写**,直接按任务描述动手即可。` +
654
+ `**不要为此任务再创建新 issue。**\n`;
655
+ const body = `${issueHeader}\n${prompt}`;
656
+ if (this.activeTasks.has(issueId)) {
657
+ const queue = this.pendingAppends.get(issueId) ?? [];
658
+ queue.push(body);
659
+ this.pendingAppends.set(issueId, queue);
660
+ log.info(this.tag, `Issue append queued: ${issueId} (queue=${queue.length})`);
661
+ }
662
+ else {
663
+ log.info(this.tag, `Issue append (idle, run now): ${issueId} (session=${sessionId ?? "(none)"}${approvalPolicy ? `, policy=${approvalPolicy}` : ""}${repoUrl ? `, repo:${worktreeMode || "group"}` : ""})`);
664
+ const cwd = await this.resolveIssueCwd(groupId, overrideCwd, { issueId, repoUrl, repoBranch, extraRepos, worktreeMode });
665
+ const composed = composePrompt({
666
+ mode: "issue",
667
+ agentName: this.config.name,
668
+ agentProfile: this.agentProfile,
669
+ group: null,
670
+ cwd,
671
+ body,
672
+ approvalPolicy,
673
+ });
674
+ this.issues.runIssueExecution(issueId, composed.final, cwd, sessionId, slashCommand, approvalPolicy, composed, { issueId, groupId, repoUrl, repoBranch, extraRepos, worktreeMode });
675
+ }
676
+ }
677
+ }
678
+ // ── Session helpers ───────────────────────────────────────────────
679
+ /**
680
+ * Push the worker's owned sessions to master as an unsolicited snapshot.
681
+ * Called on auth_ok (initial sync) and after every SessionStore.set/delete
682
+ * so the master's in-memory cache (used by GET /sessions) stays current
683
+ * without dashboards having to broadcast over WS.
684
+ *
685
+ * Filter to entries where the stored cliTool matches `this.cliTool`. The
686
+ * shared `~/.rotom/sessions.json` may carry entries for cliTools this
687
+ * worker doesn't own (e.g. a previous run bound to `claude`); pushing them
688
+ * under the wrong cliTool label would let the dashboard ask this worker
689
+ * for `readSessionContent` on a sessionId it can't find in its own keys,
690
+ * returning "session not found in this worker".
691
+ *
692
+ * Full-array semantics: master REPLACES its cached entry for this worker on
693
+ * receipt. Sending the whole array (typically <10 entries) is cheaper than
694
+ * tracking deltas, and avoids drift on missed messages.
695
+ */
696
+ sendSessionSnapshot() {
697
+ const entries = this.sessions
698
+ .listAll()
699
+ .filter((e) => e.cliTool === this.cliTool);
700
+ this.send({ type: "session_snapshot", entries });
701
+ }
702
+ async handleSessionViewRequest(requestId, groupId, sessionId, tailLines) {
703
+ if (!this.sessions.has(this.cliTool, groupId, sessionId)) {
704
+ this.send({
705
+ type: "session_view_response",
706
+ requestId,
707
+ groupId,
708
+ sessionId,
709
+ format: "raw",
710
+ content: "",
711
+ error: "session not found in this worker",
712
+ });
713
+ return;
714
+ }
715
+ const cwd = await this.resolveIssueCwd(groupId);
716
+ try {
717
+ const result = await this.executor.readSessionContent?.({
718
+ sessionId,
719
+ workingDir: cwd,
720
+ tailLines: tailLines ?? 200,
721
+ });
722
+ if (!result) {
723
+ // Executor doesn't implement introspection for this backend — surface
724
+ // a "not introspectable" empty response rather than 500.
725
+ this.send({
726
+ type: "session_view_response",
727
+ requestId,
728
+ groupId,
729
+ sessionId,
730
+ format: "raw",
731
+ content: "",
732
+ error: `${this.cliTool} backend does not support session introspection`,
733
+ });
734
+ return;
735
+ }
736
+ this.send({
737
+ type: "session_view_response",
738
+ requestId,
739
+ groupId,
740
+ sessionId,
741
+ format: result.format,
742
+ content: result.content,
743
+ ...(result.error ? { error: result.error } : {}),
744
+ });
745
+ }
746
+ catch (err) {
747
+ this.send({
748
+ type: "session_view_response",
749
+ requestId,
750
+ groupId,
751
+ sessionId,
752
+ format: "raw",
753
+ content: "",
754
+ error: err?.message || String(err),
755
+ });
756
+ }
757
+ }
758
+ // ── Sending helpers (shared by all subsystems) ────────────────────
759
+ agentEnv() {
760
+ const env = {
761
+ ROTOM_AGENT: this.config.name,
762
+ ROTOM_MASTER: this.masterUrl,
763
+ };
764
+ // OPC 本机模式 token 可空,不强制设 ROTOM_TOKEN 让下游 CLI 自己处理。
765
+ if (this.config.token) {
766
+ env.ROTOM_TOKEN = this.config.token;
767
+ }
768
+ return env;
769
+ }
770
+ send(msg) {
771
+ if (this.ws?.readyState === WebSocket.OPEN) {
772
+ this.ws.send(JSON.stringify(msg));
773
+ }
774
+ }
775
+ sendUpdate(issueId, status, content, metadata, cwd, composedPrompt) {
776
+ const msg = { type: "issue_update", issueId, status, content, metadata };
777
+ if (cwd)
778
+ msg.cwd = cwd;
779
+ if (composedPrompt)
780
+ msg.composedPrompt = composedPrompt;
781
+ this.send(msg);
782
+ }
783
+ /**
784
+ * 执行过程中 executor 上报单轮 token usage 增量。merge 到累积值后做
785
+ * leading+trailing 1s 节流推送:
786
+ * - leading:距上次推送 ≥ 1000ms 立即推
787
+ * - trailing:否则排一个 setTimeout 在窗口尾再推一次(只在 dirty 时推,
788
+ * 避免和 leading 重叠推同一份数据)
789
+ *
790
+ * 不调 onUsage 的 backend(codex/hermes/openclaw)→ 本方法不被调,前端
791
+ * 自然降级到终态 issue.usage,无副作用。
792
+ */
793
+ reportIssueUsage(issueId, increment) {
794
+ let entry = this.usageAccumulators.get(issueId);
795
+ if (!entry) {
796
+ entry = { accumulated: {}, lastPushAt: 0, dirty: false };
797
+ this.usageAccumulators.set(issueId, entry);
798
+ }
799
+ entry.accumulated = mergeTokenUsage(entry.accumulated, increment);
800
+ entry.dirty = true;
801
+ const now = Date.now();
802
+ if (now - entry.lastPushAt >= USAGE_THROTTLE_MS) {
803
+ // leading 窗口已过,直接推
804
+ this.pushAccumulatedUsage(issueId, entry);
805
+ return;
806
+ }
807
+ // 还在节流窗口内,排一个 trailing(若未排)。setTimeout 触发时再 check
808
+ // dirty:可能 leading 已经推过相同值,trailing 无需再推。
809
+ if (!entry.trailingTimer) {
810
+ const wait = USAGE_THROTTLE_MS - (now - entry.lastPushAt);
811
+ entry.trailingTimer = setTimeout(() => {
812
+ const e = this.usageAccumulators.get(issueId);
813
+ if (!e)
814
+ return;
815
+ e.trailingTimer = undefined;
816
+ if (!e.dirty)
817
+ return;
818
+ this.pushAccumulatedUsage(issueId, e);
819
+ }, Math.max(0, wait));
820
+ // trailing 不应阻止 Node 退出(虽然 worker 是常驻进程,但语义上对)
821
+ entry.trailingTimer.unref?.();
822
+ }
823
+ }
824
+ /**
825
+ * issue 翻终态时强制 flush 一次累积 usage,确保最后一次推送不丢。
826
+ * override 给定时(通常是 ExecuteResult.usage 终态值)直接覆盖累积值,
827
+ * 保证 reload 后看到的 issue.usage 与最后一次推送口径一致。
828
+ *
829
+ * 必须在 runIssueExecution 的 finally 块调用,覆盖正常完成 / abort / catch
830
+ * 所有路径。flush 后清掉 entry,避免内存泄漏。
831
+ */
832
+ flushIssueUsage(issueId, override) {
833
+ const entry = this.usageAccumulators.get(issueId);
834
+ if (!entry) {
835
+ // 整个执行过程 executor 从未调过 onUsage(例如 backend 不支持),
836
+ // 但终态 result.usage 仍有值 → 仍要推一次,让前端拿到终态数字。
837
+ if (override) {
838
+ this.send({ type: "issue_usage_progress", issueId, usage: override });
839
+ }
840
+ return;
841
+ }
842
+ if (entry.trailingTimer) {
843
+ clearTimeout(entry.trailingTimer);
844
+ entry.trailingTimer = undefined;
845
+ }
846
+ if (override)
847
+ entry.accumulated = override;
848
+ this.pushAccumulatedUsage(issueId, entry);
849
+ this.usageAccumulators.delete(issueId);
850
+ }
851
+ pushAccumulatedUsage(issueId, entry) {
852
+ this.send({ type: "issue_usage_progress", issueId, usage: entry.accumulated });
853
+ entry.lastPushAt = Date.now();
854
+ entry.dirty = false;
855
+ }
856
+ sendChatChunk(requestId, delta) {
857
+ this.send({ type: "a2a_reply_chunk", requestId, delta });
858
+ }
859
+ sendChatEnd(requestId, fullContent, conversation, cwd, composedPrompt, options) {
860
+ const msg = {
861
+ type: "a2a_reply_end",
862
+ requestId,
863
+ payload: { message: fullContent },
864
+ conversation,
865
+ };
866
+ if (cwd)
867
+ msg.cwd = cwd;
868
+ // 中断态不带 composedPrompt —— prompt 已无意义,且 dashboard 端
869
+ // a2a_stream_end 处理对 cancelled 路径会跳过 history 重拉,
870
+ // 传过去也用不上。partial 内容(已积累的 fullContent)是用户唯一关心。
871
+ if (composedPrompt && !options?.cancelled)
872
+ msg.composedPrompt = composedPrompt;
873
+ if (options?.cancelled)
874
+ msg.cancelled = true;
875
+ this.send(msg);
876
+ }
877
+ }