@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,188 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createLogger } from "../shared/logger.js";
|
|
4
|
+
const log = createLogger("mesh-executor-session-store", { stream: "stderr" });
|
|
5
|
+
/**
|
|
6
|
+
* In-memory registry of conversation sessions per group per CLI.
|
|
7
|
+
*
|
|
8
|
+
* Persistence moved to master DB (`agent_sessions` table). On startup the
|
|
9
|
+
* worker receives a `session_sync_push` from master with all its active
|
|
10
|
+
* sessions; mutations are pushed back via `session_snapshot`. The old
|
|
11
|
+
* `~/.rotom/sessions.json` file is gone — master is the single source of
|
|
12
|
+
* truth, which fixes the multi-worker flush-overwrite bug and lets the
|
|
13
|
+
* dashboard surface full session history (including invalidated ones).
|
|
14
|
+
*
|
|
15
|
+
* Key format: `${cliTool}:${groupId}` → StoredSession
|
|
16
|
+
*/
|
|
17
|
+
export class SessionStore {
|
|
18
|
+
sessions = new Map();
|
|
19
|
+
/** Populate from master's session_sync_push on startup. Merges with any
|
|
20
|
+
* existing in-memory state (e.g. legacy backfill) — master entries only
|
|
21
|
+
* fill in (cliTool, groupId) pairs the store doesn't already have. */
|
|
22
|
+
hydrate(entries) {
|
|
23
|
+
let added = 0;
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const k = this.key(e.cliTool, e.groupId);
|
|
26
|
+
if (this.sessions.has(k))
|
|
27
|
+
continue;
|
|
28
|
+
const stored = { sessionId: e.sessionId };
|
|
29
|
+
if (e.usage)
|
|
30
|
+
stored.usage = e.usage;
|
|
31
|
+
if (e.model)
|
|
32
|
+
stored.model = e.model;
|
|
33
|
+
if (typeof e.cumulativeCostUsd === "number")
|
|
34
|
+
stored.cumulativeCostUsd = e.cumulativeCostUsd;
|
|
35
|
+
if (typeof e.cumulativeInputTokens === "number")
|
|
36
|
+
stored.cumulativeInputTokens = e.cumulativeInputTokens;
|
|
37
|
+
if (typeof e.cumulativeOutputTokens === "number")
|
|
38
|
+
stored.cumulativeOutputTokens = e.cumulativeOutputTokens;
|
|
39
|
+
if (typeof e.cumulativeCacheReadTokens === "number")
|
|
40
|
+
stored.cumulativeCacheReadTokens = e.cumulativeCacheReadTokens;
|
|
41
|
+
if (typeof e.cumulativeCacheCreationTokens === "number")
|
|
42
|
+
stored.cumulativeCacheCreationTokens = e.cumulativeCacheCreationTokens;
|
|
43
|
+
this.sessions.set(k, stored);
|
|
44
|
+
added++;
|
|
45
|
+
}
|
|
46
|
+
if (added > 0) {
|
|
47
|
+
log.info(`Hydrated ${added} session(s) from master (of ${entries.length} pushed)`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* One-time migration: read the legacy `~/.rotom/sessions.json` file and
|
|
52
|
+
* populate the in-memory store. The file is deleted after reading so
|
|
53
|
+
* subsequent starts don't re-backfill (which would overwrite newer DB
|
|
54
|
+
* state). Safe to call multiple times — no-op if the file is gone.
|
|
55
|
+
*
|
|
56
|
+
* Called once from executor index.ts after constructing the shared
|
|
57
|
+
* SessionStore. After this, master DB is the source of truth.
|
|
58
|
+
*/
|
|
59
|
+
backfillFromLegacyJson(rotomHome) {
|
|
60
|
+
const file = path.join(rotomHome, "sessions.json");
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(file, "utf-8");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return; // file gone or never existed — normal path after first migration
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(raw);
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const [k, v] of Object.entries(data)) {
|
|
72
|
+
const sep = k.indexOf(":");
|
|
73
|
+
if (sep === -1)
|
|
74
|
+
continue;
|
|
75
|
+
const cliTool = k.slice(0, sep);
|
|
76
|
+
const groupId = k.slice(sep + 1);
|
|
77
|
+
if (typeof v === "string") {
|
|
78
|
+
this.sessions.set(k, { sessionId: v });
|
|
79
|
+
count++;
|
|
80
|
+
}
|
|
81
|
+
else if (v && typeof v === "object" && typeof v.sessionId === "string") {
|
|
82
|
+
const obj = v;
|
|
83
|
+
const stored = { sessionId: obj.sessionId };
|
|
84
|
+
if (obj.usage)
|
|
85
|
+
stored.usage = obj.usage;
|
|
86
|
+
if (obj.model)
|
|
87
|
+
stored.model = obj.model;
|
|
88
|
+
if (typeof obj.cumulativeCostUsd === "number")
|
|
89
|
+
stored.cumulativeCostUsd = obj.cumulativeCostUsd;
|
|
90
|
+
this.sessions.set(k, stored);
|
|
91
|
+
count++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
log.info(`Backfilled ${count} session(s) from legacy ${file}`);
|
|
95
|
+
// Delete the file so we never re-backfill (would clobber DB state).
|
|
96
|
+
try {
|
|
97
|
+
fs.unlinkSync(file);
|
|
98
|
+
log.info(`Removed legacy ${file}`);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
log.warn(`Failed to remove legacy ${file}: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
log.warn(`Failed to parse legacy ${file}: ${err.message} (leaving file in place)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
key(cliTool, groupId) {
|
|
109
|
+
return `${cliTool}:${groupId}`;
|
|
110
|
+
}
|
|
111
|
+
get(cliTool, groupId) {
|
|
112
|
+
return this.sessions.get(this.key(cliTool, groupId))?.sessionId;
|
|
113
|
+
}
|
|
114
|
+
set(cliTool, groupId, sessionId) {
|
|
115
|
+
// Preserve existing usage/model when the sessionId is being refreshed
|
|
116
|
+
// (e.g. a new chat turn returned the same sessionId). If the sessionId
|
|
117
|
+
// truly changed, clear stale usage — the new session has no turns yet.
|
|
118
|
+
const k = this.key(cliTool, groupId);
|
|
119
|
+
const existing = this.sessions.get(k);
|
|
120
|
+
if (existing && existing.sessionId === sessionId) {
|
|
121
|
+
this.sessions.set(k, { ...existing, sessionId });
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.sessions.set(k, { sessionId });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Record the latest usage/model captured from the CLI backend for this
|
|
128
|
+
* session. No-op if no session exists for (cliTool, groupId). */
|
|
129
|
+
recordUsage(cliTool, groupId, usage, model) {
|
|
130
|
+
const k = this.key(cliTool, groupId);
|
|
131
|
+
const existing = this.sessions.get(k);
|
|
132
|
+
if (!existing)
|
|
133
|
+
return;
|
|
134
|
+
// Only update if there's something to record — avoids bumping state
|
|
135
|
+
// on every chat turn that reports nothing.
|
|
136
|
+
if (!usage && !model)
|
|
137
|
+
return;
|
|
138
|
+
// 累加 cost + tokens:每次 turn 的值加到 cumulative 字段。
|
|
139
|
+
// usage 本身仍整体覆盖(保留"最近一 turn 用量"语义,tooltip 用)。
|
|
140
|
+
const turnCost = typeof usage?.totalCostUsd === "number" ? usage.totalCostUsd : 0;
|
|
141
|
+
const turnIn = typeof usage?.inputTokens === "number" ? usage.inputTokens : 0;
|
|
142
|
+
const turnOut = typeof usage?.outputTokens === "number" ? usage.outputTokens : 0;
|
|
143
|
+
const turnCacheRead = typeof usage?.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
|
|
144
|
+
const turnCacheCreation = typeof usage?.cacheCreationTokens === "number" ? usage.cacheCreationTokens : 0;
|
|
145
|
+
this.sessions.set(k, {
|
|
146
|
+
...existing,
|
|
147
|
+
...(usage ? { usage } : {}),
|
|
148
|
+
...(model ? { model } : {}),
|
|
149
|
+
cumulativeCostUsd: (existing.cumulativeCostUsd ?? 0) + turnCost,
|
|
150
|
+
cumulativeInputTokens: (existing.cumulativeInputTokens ?? 0) + turnIn,
|
|
151
|
+
cumulativeOutputTokens: (existing.cumulativeOutputTokens ?? 0) + turnOut,
|
|
152
|
+
cumulativeCacheReadTokens: (existing.cumulativeCacheReadTokens ?? 0) + turnCacheRead,
|
|
153
|
+
cumulativeCacheCreationTokens: (existing.cumulativeCacheCreationTokens ?? 0) + turnCacheCreation,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
delete(cliTool, groupId) {
|
|
157
|
+
this.sessions.delete(this.key(cliTool, groupId));
|
|
158
|
+
}
|
|
159
|
+
has(cliTool, groupId, sessionId) {
|
|
160
|
+
return this.sessions.get(this.key(cliTool, groupId))?.sessionId === sessionId;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Return every entry in the store, parsed from the `${cliTool}:${groupId}`
|
|
164
|
+
* keys. Used by the worker's session_snapshot push so master can persist
|
|
165
|
+
* to DB.
|
|
166
|
+
*/
|
|
167
|
+
listAll() {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const [k, stored] of this.sessions) {
|
|
170
|
+
const sep = k.indexOf(":");
|
|
171
|
+
if (sep === -1)
|
|
172
|
+
continue;
|
|
173
|
+
out.push({
|
|
174
|
+
cliTool: k.slice(0, sep),
|
|
175
|
+
groupId: k.slice(sep + 1),
|
|
176
|
+
sessionId: stored.sessionId,
|
|
177
|
+
...(stored.usage ? { usage: stored.usage } : {}),
|
|
178
|
+
...(stored.model ? { model: stored.model } : {}),
|
|
179
|
+
...(typeof stored.cumulativeCostUsd === "number" ? { cumulativeCostUsd: stored.cumulativeCostUsd } : {}),
|
|
180
|
+
...(typeof stored.cumulativeInputTokens === "number" ? { cumulativeInputTokens: stored.cumulativeInputTokens } : {}),
|
|
181
|
+
...(typeof stored.cumulativeOutputTokens === "number" ? { cumulativeOutputTokens: stored.cumulativeOutputTokens } : {}),
|
|
182
|
+
...(typeof stored.cumulativeCacheReadTokens === "number" ? { cumulativeCacheReadTokens: stored.cumulativeCacheReadTokens } : {}),
|
|
183
|
+
...(typeof stored.cumulativeCacheCreationTokens === "number" ? { cumulativeCacheCreationTokens: stored.cumulativeCacheCreationTokens } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatHandler — group chat replies for ExecutorWorker.
|
|
3
|
+
*
|
|
4
|
+
* handleChatReply serves @mention / DM turns; the activeTasks key is
|
|
5
|
+
* `chat:${requestId}` so the WS router's `chat_cancelled` branch can find it.
|
|
6
|
+
* Shares session/usage bookkeeping via the worker's SessionStore.
|
|
7
|
+
*/
|
|
8
|
+
import { composePrompt } from "../shared/prompt-composer.js";
|
|
9
|
+
import { createLogger } from "../shared/logger.js";
|
|
10
|
+
const log = createLogger("mesh-executor-worker-chat", { stream: "stderr" });
|
|
11
|
+
export class ChatHandler {
|
|
12
|
+
worker;
|
|
13
|
+
/** 同群 chat 队列:groupId → 待处理消息队列。同群串行,保证 session 不丢。 */
|
|
14
|
+
groupChatQueues = new Map();
|
|
15
|
+
groupChatActive = new Set();
|
|
16
|
+
constructor(worker) {
|
|
17
|
+
this.worker = worker;
|
|
18
|
+
}
|
|
19
|
+
async handleChatReply(requestId, content, fromName, conversation, cwdOverride, repoCtx) {
|
|
20
|
+
const taskKey = `chat:${requestId}`;
|
|
21
|
+
if (this.worker.activeTasks.has(taskKey))
|
|
22
|
+
return;
|
|
23
|
+
const groupId = conversation?.id ?? conversation?.groupId ?? "";
|
|
24
|
+
// 同群串行:若该群已有活跃 chat 任务,排队等当前任务结束(session 已存)再处理。
|
|
25
|
+
// 避免新 chat 开新 session 丢失上下文(ask-bridge 复述到达时 A 的原始 turn 可能还没结束)。
|
|
26
|
+
if (groupId && this.groupChatActive.has(groupId)) {
|
|
27
|
+
const queue = this.groupChatQueues.get(groupId) ?? [];
|
|
28
|
+
queue.push({ requestId, content, fromName, conversation, cwdOverride, repoCtx });
|
|
29
|
+
this.groupChatQueues.set(groupId, queue);
|
|
30
|
+
log.info(this.worker.tag, `Chat from ${fromName} queued for group ${groupId} (queue=${queue.length})`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await this.runChatReply(requestId, content, fromName, conversation, cwdOverride, groupId, undefined, repoCtx);
|
|
34
|
+
}
|
|
35
|
+
async runChatReply(requestId, content, fromName, conversation, cwdOverride, groupId, mergedSiblings, repoCtx) {
|
|
36
|
+
const taskKey = `chat:${requestId}`;
|
|
37
|
+
// cwd 优先用 master 推送(Dashboard 群工作目录 / per-agent override);
|
|
38
|
+
// 本机不存在或未推送时回落本地派生(<workingDirMap[groupId]> 或 <base>/<groupId>)。
|
|
39
|
+
// 旧的 conversation.workingDir 仍忽略(那是展示元数据,与 spawn 无关)。
|
|
40
|
+
// group 配了 repo 且同机时 master 在 a2a_message 里下发 repoCtx,这里走 group 模式
|
|
41
|
+
// 共享 worktree(<groupDir>/repos/primary/),让 chat 也能查 repo 代码。
|
|
42
|
+
const resolveChatCwd = async () => this.worker.resolveIssueCwd(groupId || undefined, cwdOverride, repoCtx);
|
|
43
|
+
if (this.worker.activeTasks.size >= this.worker.maxConcurrent) {
|
|
44
|
+
this.worker.sendChatEnd(requestId, `[系统] 当前任务繁忙,请稍后再试`, conversation, await resolveChatCwd());
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const task = { aborted: false, controller };
|
|
49
|
+
this.worker.activeTasks.set(taskKey, task);
|
|
50
|
+
if (groupId)
|
|
51
|
+
this.groupChatActive.add(groupId);
|
|
52
|
+
const body = content.replace(`@${this.worker.config.name}`, "").trim();
|
|
53
|
+
if (!body) {
|
|
54
|
+
this.worker.activeTasks.delete(taskKey);
|
|
55
|
+
this.dequeueNextChat(groupId);
|
|
56
|
+
this.worker.sendChatEnd(requestId, "你好,有什么可以帮你的?", conversation, await resolveChatCwd());
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Resolve session for this group
|
|
60
|
+
const sessionId = groupId ? this.worker.sessions.get(this.worker.cliTool, groupId) : undefined;
|
|
61
|
+
const cwd = await resolveChatCwd();
|
|
62
|
+
// 拼 prompt:rotom-cli → agent-role → group-basic → cwd → task。
|
|
63
|
+
// group 信息从 conversation 抽出(master 已 enrich 过 activeIssues / groupName)。
|
|
64
|
+
// fromName 告诉 agent 这条消息是谁发的,避免 agent 不知道对话方身份。
|
|
65
|
+
const composed = composePrompt({
|
|
66
|
+
mode: "chat",
|
|
67
|
+
agentName: this.worker.config.name,
|
|
68
|
+
agentProfile: this.worker.agentProfile,
|
|
69
|
+
group: conversation?.groupId
|
|
70
|
+
? {
|
|
71
|
+
id: conversation.groupId,
|
|
72
|
+
name: conversation.groupName || conversation.groupId,
|
|
73
|
+
activeIssues: conversation.activeIssues ?? [],
|
|
74
|
+
guidancePrompt: conversation.guidancePrompt ?? null,
|
|
75
|
+
memoryCounts: conversation.memoryCounts,
|
|
76
|
+
skillCount: conversation.skillCount,
|
|
77
|
+
}
|
|
78
|
+
: null,
|
|
79
|
+
cwd,
|
|
80
|
+
fromName: fromName || null,
|
|
81
|
+
body,
|
|
82
|
+
});
|
|
83
|
+
log.info(this.worker.tag, `Session lookup: cliTool=${this.worker.cliTool}, groupId=${groupId}, sessionId=${sessionId ?? "(none)"}, conversation=${JSON.stringify(conversation)}`);
|
|
84
|
+
log.info(this.worker.tag, `Replying to ${fromName}: ${composed.final.slice(0, 60)}...`);
|
|
85
|
+
// 合并 turn:把合并用的 composedPrompt 挂到 sibling 气泡上,让 dashboard
|
|
86
|
+
// "查看 prompt"在每个被合并的 bubble 都能打开(否则 sibling 只有一条系统
|
|
87
|
+
// 文案,hasPrompt=false,按钮不出现)。sibling 的 loading bubble 也由此关闭。
|
|
88
|
+
if (mergedSiblings && mergedSiblings.length > 0) {
|
|
89
|
+
for (const sib of mergedSiblings) {
|
|
90
|
+
this.worker.sendChatEnd(sib.requestId, "[系统] 已合并到下一条回复", sib.conversation, undefined, composed);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 提到 try 块外:catch 路径下(子进程被 SIGTERM 后某些 executor 会 throw)
|
|
94
|
+
// 仍要拿着已积累的 partial content 走 cancelled 终态,否则传空字符串给
|
|
95
|
+
// master 会把前端已经看到的流式内容覆盖成空。
|
|
96
|
+
let fullContent = "";
|
|
97
|
+
try {
|
|
98
|
+
// Chat replies (DM + @-mention in groups) intentionally do NOT pass
|
|
99
|
+
// onApprovalRequest. Rationale:
|
|
100
|
+
// • Conversational tool calls should feel snappy — pausing for a
|
|
101
|
+
// human Accept/Deny breaks the chat UX.
|
|
102
|
+
// • Codex chat sessions are resumed by sessionId; a denied tool call
|
|
103
|
+
// leaves an "assistant tool_calls without matching tool message"
|
|
104
|
+
// hole in the conversation history, which makes the NEXT chat turn
|
|
105
|
+
// fail with `invalid_request_error: An assistant message with
|
|
106
|
+
// 'tool_calls' must be followed by tool messages…`.
|
|
107
|
+
// File writes here still need a backing in-progress issue (the prompt
|
|
108
|
+
// tells the agent so — see composePrompt group-basic active_issues block).
|
|
109
|
+
const execOptions = {
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
env: this.worker.agentEnv(),
|
|
112
|
+
kind: "chat",
|
|
113
|
+
// 2-minute hard wall-clock cap on chat replies. Without this a
|
|
114
|
+
// hanging openclaw subprocess can tie up the worker's
|
|
115
|
+
// activeTasks slot until the user gives up and the daemon
|
|
116
|
+
// restarts. Executors pass this through to `--timeout` AND set
|
|
117
|
+
// a defensive SIGKILL after a small grace.
|
|
118
|
+
timeoutMs: 120_000,
|
|
119
|
+
};
|
|
120
|
+
if (sessionId)
|
|
121
|
+
execOptions.sessionId = sessionId;
|
|
122
|
+
// cwd 按 groupId 派生
|
|
123
|
+
const result = await this.worker.executor.execute(composed.final, cwd, (chunk) => {
|
|
124
|
+
if (task.aborted)
|
|
125
|
+
return;
|
|
126
|
+
fullContent += chunk;
|
|
127
|
+
this.worker.sendChatChunk(requestId, chunk);
|
|
128
|
+
}, execOptions);
|
|
129
|
+
// Drop the cached sessionId if the executor reports the conversation
|
|
130
|
+
// history is poisoned (e.g. dangling tool_calls, or a terminal
|
|
131
|
+
// provider error — see HermesCliExecutor's provider error sniffer).
|
|
132
|
+
// Next chat turn will start fresh instead of trying to resume into
|
|
133
|
+
// a broken transcript.
|
|
134
|
+
//
|
|
135
|
+
// 中断态不视为 poison —— codex 的 turn_aborted 走自己的清理路径,
|
|
136
|
+
// session 可以正常续聊。只有 invalidateSession=true 且非用户主动中断
|
|
137
|
+
// 时才丢弃 sessionId。
|
|
138
|
+
if (groupId && result.invalidateSession && !task.aborted) {
|
|
139
|
+
// 失效前抓住 sessionId,通知 master 在 DB 里打 invalidated_at 戳(保留历史)。
|
|
140
|
+
const invalidatedSessionId = sessionId;
|
|
141
|
+
this.worker.sessions.delete(this.worker.cliTool, groupId);
|
|
142
|
+
log.warn(this.worker.tag, `Session invalidated: ${this.worker.cliTool}:${groupId}` +
|
|
143
|
+
(result.failed ? " (provider error)" : " (poisoned history)"));
|
|
144
|
+
if (invalidatedSessionId) {
|
|
145
|
+
this.worker.send({
|
|
146
|
+
type: "session_invalidated",
|
|
147
|
+
cliTool: this.worker.cliTool,
|
|
148
|
+
groupId,
|
|
149
|
+
sessionId: invalidatedSessionId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
this.worker.sendSessionSnapshot();
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Persist sessionId for future messages in this group. Even when
|
|
156
|
+
// result.sessionId is absent (some backends only return it on the
|
|
157
|
+
// first turn), the existing session is still valid — record usage
|
|
158
|
+
// so the Debug view can show this chat session's own token cost.
|
|
159
|
+
if (groupId && result.sessionId) {
|
|
160
|
+
this.worker.sessions.set(this.worker.cliTool, groupId, result.sessionId);
|
|
161
|
+
log.info(this.worker.tag, `Session stored: ${this.worker.cliTool}:${groupId} → ${result.sessionId}`);
|
|
162
|
+
}
|
|
163
|
+
if (groupId && (result.usage || result.model)) {
|
|
164
|
+
this.worker.sessions.recordUsage(this.worker.cliTool, groupId, result.usage, result.model);
|
|
165
|
+
// Snapshot push is needed so master picks up the new usage/model.
|
|
166
|
+
// Coalesce with the set() push above by always sending here when
|
|
167
|
+
// we recorded anything.
|
|
168
|
+
this.worker.sendSessionSnapshot();
|
|
169
|
+
}
|
|
170
|
+
else if (groupId && result.sessionId) {
|
|
171
|
+
this.worker.sendSessionSnapshot();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (task.aborted) {
|
|
175
|
+
// 用户中断:已积累的 partial content 落库(走 master 的 cancelled_at 路径),
|
|
176
|
+
// bubble 切到「已中断」状态。不暴露 executor 返回的 aborted 错误文案 ——
|
|
177
|
+
// 用户自己点的中断,不需要再看"turn was aborted" 之类的内部噪声。
|
|
178
|
+
this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, undefined, { cancelled: true });
|
|
179
|
+
log.info(this.worker.tag, `Reply cancelled mid-stream to ${fromName} (kept ${fullContent.length} chars)`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Provider-error path: executor detected a terminal model failure
|
|
183
|
+
// (e.g. hermes's "API call failed after N retries: …" reply, which
|
|
184
|
+
// is not a legitimate assistant message). Surface it as a clean
|
|
185
|
+
// [错误] notice instead of streaming the error string as the
|
|
186
|
+
// agent's "answer". The dashboard's status pill is already on
|
|
187
|
+
// "Failed" from the executor's [status:Failed] emit.
|
|
188
|
+
if (result.failed) {
|
|
189
|
+
const reason = result.errorMessage || "unknown provider error";
|
|
190
|
+
this.worker.sendChatEnd(requestId, `[错误] 模型调用失败:${reason}\n(已清空会话上下文,下一条消息将重新开始)`, conversation, cwd, composed);
|
|
191
|
+
log.error(this.worker.tag, `Provider error surfaced to ${fromName}: ${reason}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, composed);
|
|
195
|
+
log.info(this.worker.tag, `Reply sent to ${fromName} (${fullContent.length} chars)`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (task.aborted) {
|
|
201
|
+
// 子进程被 SIGTERM/SIGKILL 时 executor 可能 throw(SIGNAL error),
|
|
202
|
+
// 这是用户主动取消的预期结果,走 cancelled 终态而不是 error。
|
|
203
|
+
this.worker.sendChatEnd(requestId, fullContent, conversation, cwd, undefined, { cancelled: true });
|
|
204
|
+
log.info(this.worker.tag, `Reply cancelled (executor threw on abort) to ${fromName} (kept ${fullContent.length} chars)`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
this.worker.sendChatEnd(requestId, `[错误] ${err.message}`, conversation, cwd, composed);
|
|
208
|
+
log.error(this.worker.tag, "Reply error:", err.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
this.worker.activeTasks.delete(taskKey);
|
|
213
|
+
this.dequeueNextChat(groupId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** 当前群 chat 任务结束,从队列取下一条处理。session 已存,新任务能复用。
|
|
217
|
+
* 积压合并:队列里有 ≥2 条待处理时,取最多 MAX_MERGE=3 条合并成一次 turn
|
|
218
|
+
* (首条 requestId 作主回复流,其余发系统文案 bubble 关闭 loading),省 LLM 调用。
|
|
219
|
+
*/
|
|
220
|
+
dequeueNextChat(groupId) {
|
|
221
|
+
if (!groupId)
|
|
222
|
+
return;
|
|
223
|
+
this.groupChatActive.delete(groupId);
|
|
224
|
+
const queue = this.groupChatQueues.get(groupId);
|
|
225
|
+
if (!queue || queue.length === 0) {
|
|
226
|
+
this.groupChatQueues.delete(groupId);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const MAX_MERGE = 3;
|
|
230
|
+
const batch = queue.splice(0, Math.min(MAX_MERGE, queue.length));
|
|
231
|
+
if (queue.length === 0)
|
|
232
|
+
this.groupChatQueues.delete(groupId);
|
|
233
|
+
if (batch.length === 1) {
|
|
234
|
+
const n = batch[0];
|
|
235
|
+
log.info(this.worker.tag, `Dequeue chat from ${n.fromName} for group ${groupId} (remaining=${queue.length})`);
|
|
236
|
+
this.runChatReply(n.requestId, n.content, n.fromName, n.conversation, n.cwdOverride, groupId, undefined, n.repoCtx).catch((err) => {
|
|
237
|
+
log.error(this.worker.tag, "Dequeued chat error:", err.message);
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// 合并:首条作主 requestId 流式回复,其余 sibling 在 runChatReply compose 完后
|
|
242
|
+
// 用同一份 composedPrompt 关闭 loading(让 dashboard 每个 sibling 都能查看 prompt)。
|
|
243
|
+
const primary = batch[0];
|
|
244
|
+
const mergedNames = batch.map((b) => b.fromName).join(", ");
|
|
245
|
+
const siblings = batch.slice(1).map((b) => ({ requestId: b.requestId, conversation: b.conversation }));
|
|
246
|
+
// 合并 body:每条标 [from=X] 让 agent 区分发送者;fromName 传 null 避免 composePrompt
|
|
247
|
+
// 再加单 sender 头(多 sender 已在 body 内标注)。
|
|
248
|
+
const mentionTag = `@${this.worker.config.name}`;
|
|
249
|
+
const mergedBody = batch
|
|
250
|
+
.map((b) => `[from=${b.fromName}]\n${b.content.replace(mentionTag, "").trim()}`)
|
|
251
|
+
.join("\n\n---\n\n");
|
|
252
|
+
log.info(this.worker.tag, `Dequeue merged chat for group ${groupId} (merged=${batch.length}, from=[${mergedNames}], remaining=${queue.length})`);
|
|
253
|
+
this.runChatReply(primary.requestId, mergedBody, null, primary.conversation, primary.cwdOverride, groupId, siblings, primary.repoCtx).catch((err) => {
|
|
254
|
+
log.error(this.worker.tag, "Dequeued merged chat error:", err.message);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerConnection — WS lifecycle for ExecutorWorker.
|
|
3
|
+
*
|
|
4
|
+
* Owns heartbeat + reconnect timers. The underlying `ws` socket lives on the
|
|
5
|
+
* worker (shared with send helpers), this module just wires connect/reconnect
|
|
6
|
+
* and routes incoming messages back to `worker.handleMessage`.
|
|
7
|
+
*/
|
|
8
|
+
import { WebSocket } from "ws";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { decodeJson } from "../shared/json-codec.js";
|
|
12
|
+
import { createLogger } from "../shared/logger.js";
|
|
13
|
+
const log = createLogger("mesh-executor-worker-connection", { stream: "stderr" });
|
|
14
|
+
export class WorkerConnection {
|
|
15
|
+
worker;
|
|
16
|
+
heartbeatTimer = null;
|
|
17
|
+
reconnectTimer = null;
|
|
18
|
+
constructor(worker) {
|
|
19
|
+
this.worker = worker;
|
|
20
|
+
}
|
|
21
|
+
start() {
|
|
22
|
+
this.worker.stopped = false;
|
|
23
|
+
this.connect();
|
|
24
|
+
}
|
|
25
|
+
stop() {
|
|
26
|
+
this.worker.stopped = true;
|
|
27
|
+
if (this.heartbeatTimer)
|
|
28
|
+
clearInterval(this.heartbeatTimer);
|
|
29
|
+
if (this.reconnectTimer)
|
|
30
|
+
clearTimeout(this.reconnectTimer);
|
|
31
|
+
if (this.worker.ws)
|
|
32
|
+
this.worker.ws.close(1000, "shutdown");
|
|
33
|
+
}
|
|
34
|
+
/** Called from handleMessage on auth_ok. Starts the 10s heartbeat loop. */
|
|
35
|
+
startHeartbeat() {
|
|
36
|
+
this.heartbeatTimer = setInterval(() => {
|
|
37
|
+
if (this.worker.ws?.readyState === WebSocket.OPEN) {
|
|
38
|
+
this.worker.ws.send(JSON.stringify({ type: "heartbeat" }));
|
|
39
|
+
}
|
|
40
|
+
}, 10_000);
|
|
41
|
+
}
|
|
42
|
+
wsUrl() {
|
|
43
|
+
let url = this.worker.masterUrl;
|
|
44
|
+
if (!url.endsWith("/ws"))
|
|
45
|
+
url += "/ws";
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
connect() {
|
|
49
|
+
if (this.worker.stopped)
|
|
50
|
+
return;
|
|
51
|
+
const url = this.wsUrl();
|
|
52
|
+
const cliName = this.worker.config.cliTool || "auto";
|
|
53
|
+
log.info(this.worker.tag, "Connecting to", url, `(cli: ${cliName}, cwd: ${this.worker.workingDir})`);
|
|
54
|
+
this.worker.ws = new WebSocket(url);
|
|
55
|
+
this.worker.ws.on("open", () => {
|
|
56
|
+
this.worker.ws.send(JSON.stringify({
|
|
57
|
+
type: "auth",
|
|
58
|
+
name: this.worker.config.name,
|
|
59
|
+
token: this.worker.config.token,
|
|
60
|
+
version: 2,
|
|
61
|
+
profile: this.worker.config.profile || {},
|
|
62
|
+
cliTool: this.worker.cliTool,
|
|
63
|
+
instance: {
|
|
64
|
+
instanceId: `${os.hostname()}-${process.pid}-${randomUUID()}`,
|
|
65
|
+
hostname: os.hostname(),
|
|
66
|
+
platform: `${process.platform} ${process.arch}`,
|
|
67
|
+
endpoint: this.worker.masterUrl,
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
});
|
|
71
|
+
this.worker.ws.on("message", (raw) => {
|
|
72
|
+
const msg = decodeJson(raw);
|
|
73
|
+
if (!msg)
|
|
74
|
+
return;
|
|
75
|
+
this.worker.handleMessage(msg);
|
|
76
|
+
});
|
|
77
|
+
this.worker.ws.on("close", () => {
|
|
78
|
+
log.info(this.worker.tag, "Disconnected, reconnecting in 3s...");
|
|
79
|
+
if (this.heartbeatTimer)
|
|
80
|
+
clearInterval(this.heartbeatTimer);
|
|
81
|
+
if (!this.worker.stopped) {
|
|
82
|
+
this.reconnectTimer = setTimeout(() => this.connect(), 3_000);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
this.worker.ws.on("error", (err) => {
|
|
86
|
+
log.error(this.worker.tag, "WS error:", err.message);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|