@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.
- package/README.md +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- 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
|
+
}
|