@taskless-app/swarm 0.1.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli.js +2033 -301
- package/dist/overmind.js +2882 -514
- package/docs/HERO.md +3 -0
- package/package.json +23 -19
package/dist/cli.js
CHANGED
|
@@ -1,62 +1,240 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { basename as
|
|
4
|
+
import { basename as basename3 } from "path";
|
|
5
5
|
import { createInterface } from "readline/promises";
|
|
6
|
-
import { setTimeout as
|
|
6
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
7
7
|
|
|
8
8
|
// src/db.ts
|
|
9
|
-
import
|
|
9
|
+
import { createRequire } from "module";
|
|
10
10
|
import { existsSync } from "fs";
|
|
11
11
|
import { resolve as resolve2 } from "path";
|
|
12
12
|
|
|
13
13
|
// src/config.ts
|
|
14
14
|
import { homedir } from "os";
|
|
15
|
-
import { join, resolve } from "path";
|
|
16
|
-
import { mkdirSync, readFileSync, realpathSync } from "fs";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
import { basename, join, resolve } from "path";
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
|
|
17
|
+
|
|
18
|
+
// ../swarm-core/src/types.ts
|
|
19
|
+
var MILESTONE_KINDS = [
|
|
20
|
+
"spawned",
|
|
21
|
+
"pr_opened",
|
|
22
|
+
"merged",
|
|
23
|
+
"done",
|
|
24
|
+
"failed",
|
|
25
|
+
"stalled",
|
|
26
|
+
"conflict",
|
|
27
|
+
"ci_wake",
|
|
28
|
+
// CI-побудка (TASWM-102) — тоже реконсайл-решение, веха как conflict
|
|
29
|
+
"stopped",
|
|
30
|
+
"superseded",
|
|
31
|
+
"paused",
|
|
32
|
+
"resumed",
|
|
33
|
+
"storm_start",
|
|
34
|
+
"storm_end",
|
|
35
|
+
"storm_dead",
|
|
36
|
+
"anomaly"
|
|
37
|
+
// надзор overmind (TASWM-194) — та же нотификация, что улетела в инбокс, вехой в Истории
|
|
38
|
+
];
|
|
39
|
+
var MILESTONE_KIND_SET = new Set(MILESTONE_KINDS);
|
|
40
|
+
function isMilestoneKind(kind) {
|
|
41
|
+
return MILESTONE_KIND_SET.has(kind);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ../swarm-core/src/timeline.ts
|
|
45
|
+
function buildSegments(created, events, laneEnd) {
|
|
46
|
+
const segs = [];
|
|
47
|
+
let cursor = created;
|
|
48
|
+
let phase = "queued";
|
|
49
|
+
const push = (to) => {
|
|
50
|
+
if (to > cursor) segs.push({ phase, from: cursor, to });
|
|
51
|
+
cursor = to;
|
|
52
|
+
};
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
switch (e.kind) {
|
|
55
|
+
case "step":
|
|
56
|
+
if (phase === "queued") {
|
|
57
|
+
push(e.at);
|
|
58
|
+
phase = "running";
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case "blocked":
|
|
62
|
+
push(e.at);
|
|
63
|
+
phase = "blocked";
|
|
64
|
+
break;
|
|
65
|
+
case "answered":
|
|
66
|
+
push(e.at);
|
|
67
|
+
phase = "running";
|
|
68
|
+
break;
|
|
69
|
+
case "pr_opened":
|
|
70
|
+
push(e.at);
|
|
71
|
+
phase = "review";
|
|
72
|
+
break;
|
|
73
|
+
case "merged":
|
|
74
|
+
case "done":
|
|
75
|
+
push(e.at);
|
|
76
|
+
phase = "done";
|
|
77
|
+
break;
|
|
78
|
+
case "failed":
|
|
79
|
+
push(e.at);
|
|
80
|
+
phase = "failed";
|
|
81
|
+
break;
|
|
82
|
+
case "stalled":
|
|
83
|
+
push(e.at);
|
|
84
|
+
phase = "stalled";
|
|
85
|
+
break;
|
|
86
|
+
case "paused":
|
|
87
|
+
case "storm_dead":
|
|
88
|
+
push(e.at);
|
|
89
|
+
phase = "paused";
|
|
90
|
+
break;
|
|
91
|
+
case "resumed":
|
|
92
|
+
push(e.at);
|
|
93
|
+
phase = "running";
|
|
94
|
+
break;
|
|
95
|
+
// TASWM-166: раньше эти два kind'а не двигали фазу — хвост дорожки
|
|
96
|
+
// дорисовывался ТЕКУЩЕЙ (до остановки) фазой, напр. "running" сплошным
|
|
97
|
+
// зелёным до самого конца — визуально неотличимо от реально идущего прогона.
|
|
98
|
+
case "stopped":
|
|
99
|
+
push(e.at);
|
|
100
|
+
phase = "stopped";
|
|
101
|
+
break;
|
|
102
|
+
case "superseded":
|
|
103
|
+
push(e.at);
|
|
104
|
+
phase = "superseded";
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
26
107
|
}
|
|
108
|
+
push(laneEnd);
|
|
109
|
+
return segs;
|
|
27
110
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
111
|
+
function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
|
|
112
|
+
switch (status) {
|
|
113
|
+
case "queued": {
|
|
114
|
+
const unmet = deps.filter((d) => !d.met);
|
|
115
|
+
if (unmet.length) {
|
|
116
|
+
const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
|
|
117
|
+
return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
|
|
118
|
+
}
|
|
119
|
+
return { icon: "\u23F8", text: "\u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430 / \u0441\u043F\u0430\u0432\u043D\u0430", tone: "queued" };
|
|
120
|
+
}
|
|
121
|
+
case "running":
|
|
122
|
+
return { icon: "\u25B6", text: lastStep ? `\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442: ${lastStep}` : "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", tone: "run" };
|
|
123
|
+
case "blocked":
|
|
124
|
+
return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
|
|
125
|
+
case "paused":
|
|
126
|
+
return { icon: "\u{1F4A4}", text: stopReason || "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "paused" };
|
|
127
|
+
case "storm_dead":
|
|
128
|
+
return { icon: "\u{1F4A4}", text: "\u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u0448\u0442\u043E\u0440\u043C Anthropic, \u0436\u0434\u0451\u0442 \u043E\u0442\u0431\u043E\u044F", tone: "paused" };
|
|
129
|
+
case "review": {
|
|
130
|
+
if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
|
|
131
|
+
if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
|
|
132
|
+
if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
|
|
133
|
+
return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
|
|
134
|
+
}
|
|
135
|
+
case "stalled":
|
|
136
|
+
return { icon: "\u26A0\uFE0F", text: stopReason || (lastStep ? `\u043C\u043E\u043B\u0447\u0438\u0442: ${lastStep}` : "\u043C\u043E\u043B\u0447\u0438\u0442"), tone: "stalled" };
|
|
137
|
+
case "failed":
|
|
138
|
+
return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
|
|
139
|
+
case "stopped":
|
|
140
|
+
return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "stopped" };
|
|
141
|
+
case "superseded":
|
|
142
|
+
return { icon: "\u21BA", text: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u043D\u0430 \u0434\u0440\u0443\u0433\u043E\u0439 \u0432\u0430\u0440\u0438\u0430\u043D\u0442", tone: "superseded" };
|
|
143
|
+
case "done":
|
|
144
|
+
return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
|
|
145
|
+
default:
|
|
146
|
+
return { icon: "\u2022", text: status, tone: "queued" };
|
|
147
|
+
}
|
|
37
148
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
149
|
+
function pickHeadline(lanes) {
|
|
150
|
+
const live = lanes.filter((l2) => l2.live && !l2.obsolete);
|
|
151
|
+
const by = (pred) => live.find(pred);
|
|
152
|
+
const fmt = (l2, extra) => `${l2.name} \xB7 ${l2.slug} \u2014 ${extra}`;
|
|
153
|
+
let l = by((x) => x.status === "review" && x.ci?.tone === "fail");
|
|
154
|
+
if (l) return { icon: "\u274C", text: fmt(l, "CI \u0443\u043F\u0430\u043B \u2014 \u0447\u0438\u043D\u0438\u0442\u044C"), tone: "fail" };
|
|
155
|
+
l = by((x) => x.status === "blocked");
|
|
156
|
+
if (l) return { icon: "\u2753", text: fmt(l, "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430"), tone: "blocked" };
|
|
157
|
+
l = by((x) => x.status === "stalled");
|
|
158
|
+
if (l) return { icon: "\u26A0\uFE0F", text: fmt(l, "stalled \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430"), tone: "stalled" };
|
|
159
|
+
l = by((x) => x.status === "review" && x.blocker.tone === "merge");
|
|
160
|
+
if (l) return { icon: "\u{1F535}", text: fmt(l, "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430"), tone: "merge" };
|
|
161
|
+
l = by((x) => x.status === "queued" && x.dependsOn.some((d) => !d.met));
|
|
162
|
+
if (l) {
|
|
163
|
+
const dep = l.dependsOn.find((d) => !d.met);
|
|
164
|
+
return { icon: "\u26D4", text: fmt(l, `\u0436\u0434\u0451\u0442 ${dep.slug}${dep.name ? " \xB7 " + dep.name : ""}`), tone: "dep" };
|
|
49
165
|
}
|
|
166
|
+
l = by((x) => x.status === "review");
|
|
167
|
+
if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
|
|
168
|
+
l = by((x) => x.status === "running");
|
|
169
|
+
if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
|
|
170
|
+
return null;
|
|
50
171
|
}
|
|
51
|
-
var
|
|
52
|
-
var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
|
|
53
|
-
var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
|
|
54
|
-
var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
|
|
55
|
-
var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
|
|
56
|
-
var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
|
|
57
|
-
var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
|
|
172
|
+
var DEFAULT_RUN_MS = 25 * 6e4;
|
|
58
173
|
|
|
59
|
-
// src/
|
|
174
|
+
// ../swarm-core/src/status.ts
|
|
175
|
+
var STATUS_META = {
|
|
176
|
+
queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
|
|
177
|
+
running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
|
|
178
|
+
blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
|
|
179
|
+
paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
|
|
180
|
+
// overmind был недоступен — ждёт пробуждения (TASWM-41)
|
|
181
|
+
review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
|
|
182
|
+
done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
|
|
183
|
+
failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
|
|
184
|
+
stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
|
|
185
|
+
// молчит/погиб, но ещё разруливается — не терминал
|
|
186
|
+
storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
|
|
187
|
+
// умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
|
|
188
|
+
stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
|
|
189
|
+
superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
|
|
190
|
+
};
|
|
191
|
+
var FALLBACK = STATUS_META.queued;
|
|
192
|
+
var TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
|
|
193
|
+
(s) => STATUS_META[s].terminal
|
|
194
|
+
);
|
|
195
|
+
function statusMeta(status) {
|
|
196
|
+
return STATUS_META[status] ?? FALLBACK;
|
|
197
|
+
}
|
|
198
|
+
function isTerminalStatus(status) {
|
|
199
|
+
return statusMeta(status).terminal;
|
|
200
|
+
}
|
|
201
|
+
function statusTuples() {
|
|
202
|
+
const out = {};
|
|
203
|
+
for (const [status, m] of Object.entries(STATUS_META)) {
|
|
204
|
+
out[status] = [m.pill, m.label, m.color];
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ../swarm-core/src/time.ts
|
|
210
|
+
function stamp(d = /* @__PURE__ */ new Date()) {
|
|
211
|
+
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
212
|
+
}
|
|
213
|
+
function stampFromEpochSec(sec) {
|
|
214
|
+
return stamp(new Date(sec * 1e3));
|
|
215
|
+
}
|
|
216
|
+
function sqliteMs(ts) {
|
|
217
|
+
return Date.parse(ts.replace(" ", "T") + "Z");
|
|
218
|
+
}
|
|
219
|
+
function toStamp(v) {
|
|
220
|
+
if (typeof v !== "string" || !v) return null;
|
|
221
|
+
const d = new Date(v);
|
|
222
|
+
if (Number.isNaN(d.getTime())) return v.includes("T") ? v.slice(0, 19).replace("T", " ") : v;
|
|
223
|
+
return stamp(d);
|
|
224
|
+
}
|
|
225
|
+
var DAY_MS = 864e5;
|
|
226
|
+
|
|
227
|
+
// ../swarm-core/src/windows.ts
|
|
228
|
+
var ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
|
|
229
|
+
function resolveRoyWindows(env = {}) {
|
|
230
|
+
return {
|
|
231
|
+
windowMs: Number(env.ROY_WINDOW_H || ROY_WINDOW_DEFAULTS.windowH) * 36e5,
|
|
232
|
+
maxTerminalLanes: Number(env.ROY_MAX_DONE || ROY_WINDOW_DEFAULTS.maxDone),
|
|
233
|
+
maxAxisMs: Number(env.ROY_AXIS_H || ROY_WINDOW_DEFAULTS.axisH) * 36e5
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ../swarm-core/src/names.ts
|
|
60
238
|
var DRONE_POOL = [
|
|
61
239
|
// Латинский бестиарий (15)
|
|
62
240
|
"SCARAB",
|
|
@@ -85,7 +263,7 @@ var DRONE_POOL = [
|
|
|
85
263
|
"LEVIATHAN",
|
|
86
264
|
"SURTUR"
|
|
87
265
|
];
|
|
88
|
-
var
|
|
266
|
+
var ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
|
|
89
267
|
function toRoman(n) {
|
|
90
268
|
if (n < 1 || n > 3999) return String(n);
|
|
91
269
|
const table = [
|
|
@@ -113,6 +291,86 @@ function genName(base2, g) {
|
|
|
113
291
|
function baseName(name) {
|
|
114
292
|
return name.split(" ")[0];
|
|
115
293
|
}
|
|
294
|
+
|
|
295
|
+
// src/config.ts
|
|
296
|
+
var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
|
|
297
|
+
mkdirSync(SWARM_HOME, { recursive: true });
|
|
298
|
+
function normalizeRepo(repo) {
|
|
299
|
+
const r = (repo ?? "").trim();
|
|
300
|
+
if (!r) return "";
|
|
301
|
+
try {
|
|
302
|
+
return realpathSync(r);
|
|
303
|
+
} catch {
|
|
304
|
+
return resolve(r);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
|
|
308
|
+
var WORKTREES_DIR = process.env.SWARM_WORKTREES || join(SWARM_HOME, "worktrees");
|
|
309
|
+
var RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
|
|
310
|
+
function droneWorktreePath(worktreesDir, repo, slug) {
|
|
311
|
+
return join(worktreesDir, basename(normalizeRepo(repo)), slug);
|
|
312
|
+
}
|
|
313
|
+
var DIST_BUILD = true;
|
|
314
|
+
var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
|
|
315
|
+
var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
|
|
316
|
+
function envNum(name, def) {
|
|
317
|
+
const raw = process.env[name];
|
|
318
|
+
if (raw === void 0 || raw === "") return def;
|
|
319
|
+
const n = Number(raw);
|
|
320
|
+
return Number.isFinite(n) && n >= 0 ? n : def;
|
|
321
|
+
}
|
|
322
|
+
var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
|
|
323
|
+
var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
|
|
324
|
+
var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
|
|
325
|
+
var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
|
|
326
|
+
var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
|
|
327
|
+
var SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
|
|
328
|
+
var SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
|
|
329
|
+
var SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
|
|
330
|
+
var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
|
|
331
|
+
var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
|
|
332
|
+
var SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
|
|
333
|
+
var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
|
|
334
|
+
var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
|
|
335
|
+
var SWARM_REPO = process.env.SWARM_REPO || "";
|
|
336
|
+
var SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
|
|
337
|
+
var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
|
|
338
|
+
var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
|
|
339
|
+
var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
|
|
340
|
+
function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
|
|
341
|
+
const base2 = api.replace(/\/$/, "");
|
|
342
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
343
|
+
return `${base2}${p}`;
|
|
344
|
+
}
|
|
345
|
+
function tokenFromFile() {
|
|
346
|
+
try {
|
|
347
|
+
return readFileSync(join(SWARM_HOME, "token"), "utf8").trim();
|
|
348
|
+
} catch {
|
|
349
|
+
return "";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
|
|
353
|
+
var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
|
|
354
|
+
var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
|
|
355
|
+
var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
|
|
356
|
+
var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
|
|
357
|
+
var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
|
|
358
|
+
var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
|
|
359
|
+
var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
|
|
360
|
+
var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
|
|
361
|
+
var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
|
|
362
|
+
var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
|
|
363
|
+
var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
|
|
364
|
+
var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
|
|
365
|
+
var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
|
|
366
|
+
var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
|
|
367
|
+
var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
|
|
368
|
+
var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
|
|
369
|
+
var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
|
|
370
|
+
var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
|
|
371
|
+
|
|
372
|
+
// src/names.ts
|
|
373
|
+
var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
|
|
116
374
|
function pickName(rows) {
|
|
117
375
|
const live = /* @__PURE__ */ new Set();
|
|
118
376
|
const lastUse = /* @__PURE__ */ new Map();
|
|
@@ -163,6 +421,8 @@ function decodeProbe(text) {
|
|
|
163
421
|
}
|
|
164
422
|
|
|
165
423
|
// src/db.ts
|
|
424
|
+
var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
|
|
425
|
+
var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
|
|
166
426
|
var SCHEMA = `
|
|
167
427
|
CREATE TABLE IF NOT EXISTS runs (
|
|
168
428
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -185,6 +445,8 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
185
445
|
stop_reason TEXT,
|
|
186
446
|
respawns INTEGER NOT NULL DEFAULT 0,
|
|
187
447
|
resolves_run_id INTEGER,
|
|
448
|
+
reviews_run_id INTEGER,
|
|
449
|
+
review_verdict TEXT,
|
|
188
450
|
task_title TEXT,
|
|
189
451
|
task_desc TEXT,
|
|
190
452
|
task_url TEXT,
|
|
@@ -262,6 +524,8 @@ function rowToRun(row) {
|
|
|
262
524
|
respawns: row.respawns ?? 0,
|
|
263
525
|
// старые прогоны без колонки → 0
|
|
264
526
|
resolves_run_id: row.resolves_run_id ?? null,
|
|
527
|
+
reviews_run_id: row.reviews_run_id ?? null,
|
|
528
|
+
review_verdict: row.review_verdict ?? null,
|
|
265
529
|
task_title: row.task_title ?? null,
|
|
266
530
|
task_desc: row.task_desc ?? null,
|
|
267
531
|
task_url: row.task_url ?? null,
|
|
@@ -269,12 +533,25 @@ function rowToRun(row) {
|
|
|
269
533
|
updated_at: row.updated_at
|
|
270
534
|
};
|
|
271
535
|
}
|
|
536
|
+
function loadBetterSqlite3() {
|
|
537
|
+
const require2 = createRequire(import.meta.url);
|
|
538
|
+
try {
|
|
539
|
+
return require2("better-sqlite3");
|
|
540
|
+
} catch (err) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`better-sqlite3 \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. Sqlite-\u0440\u0435\u0436\u0438\u043C (SwarmDb) \u2014 \u0434\u0435\u0444\u043E\u043B\u0442 \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u044B/\u0442\u0435\u0441\u0442\u043E\u0432; \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u043F\u0430\u043A\u0435\u0442 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u0438 sqlite \u043D\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u0435 optional dependency (npm i, \u0431\u0435\u0437 --omit=optional) \u0438\u043B\u0438 \u0437\u0430\u0434\u0430\u0439\u0442\u0435 SWARM_BACKEND=client. \u0418\u0441\u0445\u043E\u0434\u043D\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430: ${err.message}`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
272
546
|
var SwarmDb = class {
|
|
273
547
|
db;
|
|
274
548
|
defaultRepo;
|
|
549
|
+
databaseCtor;
|
|
550
|
+
// для importDb (второй Database, source)
|
|
275
551
|
constructor(path = "swarm.db", defaultRepo = "") {
|
|
276
552
|
this.defaultRepo = normalizeRepo(defaultRepo);
|
|
277
|
-
this.
|
|
553
|
+
this.databaseCtor = loadBetterSqlite3();
|
|
554
|
+
this.db = new this.databaseCtor(path);
|
|
278
555
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
279
556
|
this.db.exec(SCHEMA);
|
|
280
557
|
for (const alter of [
|
|
@@ -295,7 +572,11 @@ var SwarmDb = class {
|
|
|
295
572
|
// Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
|
|
296
573
|
"ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
|
|
297
574
|
"ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
|
|
298
|
-
"ALTER TABLE events ADD COLUMN actor TEXT"
|
|
575
|
+
"ALTER TABLE events ADD COLUMN actor TEXT",
|
|
576
|
+
// Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
|
|
577
|
+
// вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
|
|
578
|
+
"ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
|
|
579
|
+
"ALTER TABLE runs ADD COLUMN review_verdict TEXT"
|
|
299
580
|
]) {
|
|
300
581
|
try {
|
|
301
582
|
this.db.exec(alter);
|
|
@@ -315,7 +596,7 @@ var SwarmDb = class {
|
|
|
315
596
|
createRun(input) {
|
|
316
597
|
const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
|
|
317
598
|
const res = this.db.prepare(
|
|
318
|
-
"INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
599
|
+
"INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
319
600
|
).run(
|
|
320
601
|
input.task_slug,
|
|
321
602
|
name,
|
|
@@ -329,10 +610,11 @@ var SwarmDb = class {
|
|
|
329
610
|
input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
|
|
330
611
|
input.model ?? null,
|
|
331
612
|
input.decision ? JSON.stringify(input.decision) : null,
|
|
332
|
-
input.resolves_run_id ?? null
|
|
613
|
+
input.resolves_run_id ?? null,
|
|
614
|
+
input.reviews_run_id ?? null
|
|
333
615
|
);
|
|
334
616
|
const id = Number(res.lastInsertRowid);
|
|
335
|
-
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
617
|
+
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
336
618
|
this.addEvent(id, "spawned", spawnNote);
|
|
337
619
|
return this.getRun(id);
|
|
338
620
|
}
|
|
@@ -385,7 +667,7 @@ var SwarmDb = class {
|
|
|
385
667
|
*/
|
|
386
668
|
activeResolverFor(reviewRunId) {
|
|
387
669
|
const row = this.db.prepare(
|
|
388
|
-
|
|
670
|
+
`SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
|
|
389
671
|
).get(reviewRunId);
|
|
390
672
|
return row ? rowToRun(row) : null;
|
|
391
673
|
}
|
|
@@ -394,10 +676,48 @@ var SwarmDb = class {
|
|
|
394
676
|
const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
|
|
395
677
|
return row.n;
|
|
396
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR (авто-ревью, TASWM-148) —
|
|
681
|
+
* one-shot идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → повторно не
|
|
682
|
+
* спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО вердикта) в счёт НЕ идут —
|
|
683
|
+
* иначе один транзиентный сбой спавна (диск/git/gh) навсегда блокировал бы ревью этого PR без
|
|
684
|
+
* единого шанса на повтор — тихо нарушая политику владельца «каждый PR ревьюится».
|
|
685
|
+
*/
|
|
686
|
+
reviewerCountFor(reviewRunId) {
|
|
687
|
+
const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled')").get(reviewRunId);
|
|
688
|
+
return row.n;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
|
|
692
|
+
* ВСЕГО (любой статус, включая failed/stalled — специально в отличие от reviewerCountFor
|
|
693
|
+
* выше) когда-либо поднималось на review-прогон #reviewRunId. Зеркалит resolverCountFor —
|
|
694
|
+
* плоский потолок попыток. Именно это ловит карусель: reviewerCountFor исключает
|
|
695
|
+
* failed/stalled (чтобы ОДИН транзиентный сбой не блокировал ревью навсегда), но тем
|
|
696
|
+
* самым допускает бесконечный ретрай, если ревьюер стабильно умирает без done().
|
|
697
|
+
*/
|
|
698
|
+
reviewerAttemptCountFor(reviewRunId) {
|
|
699
|
+
const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ?").get(reviewRunId);
|
|
700
|
+
return row.n;
|
|
701
|
+
}
|
|
702
|
+
/** Последняя (любой статус) попытка ревьюера на review-прогон #reviewRunId (TASWM-196) —
|
|
703
|
+
* см. store.ts:lastReviewerAttemptFor. */
|
|
704
|
+
lastReviewerAttemptFor(reviewRunId) {
|
|
705
|
+
const row = this.db.prepare("SELECT * FROM runs WHERE reviews_run_id = ? ORDER BY id DESC LIMIT 1").get(reviewRunId);
|
|
706
|
+
return row ? rowToRun(row) : null;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
|
|
710
|
+
* closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
|
|
711
|
+
* не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
|
|
712
|
+
*/
|
|
713
|
+
activeReviewersFor(reviewRunId) {
|
|
714
|
+
const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
|
|
715
|
+
return rows.map(rowToRun);
|
|
716
|
+
}
|
|
397
717
|
/** Последний живой (не терминальный) прогон по slug — для swarm cancel. */
|
|
398
718
|
activeRunForSlug(slug) {
|
|
399
719
|
const row = this.db.prepare(
|
|
400
|
-
|
|
720
|
+
`SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
|
|
401
721
|
).get(slug);
|
|
402
722
|
return row ? rowToRun(row) : null;
|
|
403
723
|
}
|
|
@@ -454,7 +774,7 @@ var SwarmDb = class {
|
|
|
454
774
|
const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
|
|
455
775
|
if (already) return { imported: false, runs: already.run_count };
|
|
456
776
|
if (!existsSync(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
|
|
457
|
-
const src = new
|
|
777
|
+
const src = new this.databaseCtor(abs);
|
|
458
778
|
try {
|
|
459
779
|
const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
|
|
460
780
|
const events = src.prepare("SELECT * FROM events ORDER BY id").all();
|
|
@@ -629,11 +949,10 @@ var SwarmDb = class {
|
|
|
629
949
|
taskUrl: row.taskUrl ?? null
|
|
630
950
|
});
|
|
631
951
|
}
|
|
632
|
-
const MILESTONES = /* @__PURE__ */ new Set(["spawned", "pr_opened", "merged", "done", "failed", "stalled", "conflict", "stopped", "superseded", "paused", "resumed", "storm_start", "storm_end", "storm_dead"]);
|
|
633
952
|
for (const row of this.db.prepare(
|
|
634
953
|
"SELECT e.*, r.task_slug slug, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM events e LEFT JOIN runs r ON r.id = e.run_id"
|
|
635
954
|
).all()) {
|
|
636
|
-
if (!
|
|
955
|
+
if (!isMilestoneKind(row.kind)) continue;
|
|
637
956
|
const global = row.run_id == null;
|
|
638
957
|
items.push({
|
|
639
958
|
kind: "milestone",
|
|
@@ -694,12 +1013,13 @@ var SwarmDb = class {
|
|
|
694
1013
|
return row.n;
|
|
695
1014
|
}
|
|
696
1015
|
/**
|
|
697
|
-
* Спавн-фейлов (событий failed на
|
|
698
|
-
* Резолверные
|
|
1016
|
+
* Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
|
|
1017
|
+
* cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
|
|
1018
|
+
* считаем штормовым сигналом.
|
|
699
1019
|
*/
|
|
700
1020
|
spawnFailCountSince(cutoffSeconds) {
|
|
701
1021
|
const row = this.db.prepare(
|
|
702
|
-
"SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
|
|
1022
|
+
"SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND r.reviews_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
|
|
703
1023
|
).get(cutoffSeconds);
|
|
704
1024
|
return row.n;
|
|
705
1025
|
}
|
|
@@ -719,139 +1039,1111 @@ var SwarmDb = class {
|
|
|
719
1039
|
}
|
|
720
1040
|
};
|
|
721
1041
|
|
|
722
|
-
// src/
|
|
723
|
-
import {
|
|
724
|
-
import {
|
|
725
|
-
import {
|
|
726
|
-
import { $ as $2 } from "execa";
|
|
1042
|
+
// src/swarmClient.ts
|
|
1043
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1044
|
+
import { dirname } from "path";
|
|
1045
|
+
import { randomUUID } from "crypto";
|
|
727
1046
|
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1047
|
+
// src/resources.ts
|
|
1048
|
+
import os from "os";
|
|
1049
|
+
import { execFileSync } from "child_process";
|
|
1050
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1051
|
+
function isTransientResourceError(e) {
|
|
1052
|
+
const code = e?.code;
|
|
1053
|
+
return code === "ENFILE" || code === "EMFILE" || code === "EAGAIN";
|
|
1054
|
+
}
|
|
1055
|
+
function logTransientSpawnError(label, e) {
|
|
1056
|
+
if (isTransientResourceError(e)) {
|
|
1057
|
+
const code = e.code;
|
|
1058
|
+
console.warn(`[overmind] ${label}: \u041E\u0421 \u043D\u0435 \u0434\u0430\u0451\u0442 \u0441\u043F\u0430\u0432\u043D\u0438\u0442\u044C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u044B (${code}) \u2014 \u043F\u0440\u043E\u043F\u0443\u0441\u043A\u0430\u044E \u0441\u0438\u0433\u043D\u0430\u043B \u044D\u0442\u043E\u0433\u043E \u0442\u0438\u043A\u0430, \u043D\u0435 \u043F\u0430\u0434\u0430\u044E`);
|
|
1059
|
+
} else {
|
|
1060
|
+
console.warn(`[overmind] ${label} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F (\u043D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445 \u0437\u0430 \u044D\u0442\u043E\u0442 \u0442\u0438\u043A): ${String(e)}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/swarmClient.ts
|
|
1065
|
+
var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
|
|
1066
|
+
var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
|
|
1067
|
+
function runRef(runKey) {
|
|
1068
|
+
return { __runRef: runKey };
|
|
1069
|
+
}
|
|
1070
|
+
function isRunRef(v) {
|
|
1071
|
+
return typeof v === "object" && v !== null && "__runRef" in v;
|
|
1072
|
+
}
|
|
1073
|
+
function jsonFromTextContent(res) {
|
|
1074
|
+
const text = res?.content?.find((c) => c.type === "text")?.text;
|
|
1075
|
+
if (text) {
|
|
1076
|
+
try {
|
|
1077
|
+
return JSON.parse(text);
|
|
1078
|
+
} catch {
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return res?.structuredContent ?? null;
|
|
1082
|
+
}
|
|
1083
|
+
var SwarmClient = class _SwarmClient {
|
|
1084
|
+
call;
|
|
1085
|
+
defaultRepo;
|
|
1086
|
+
autoFlush;
|
|
1087
|
+
listLimit;
|
|
1088
|
+
// TASWM-169: репо-реестр демона — repoFullName[] на claim/heartbeat + обратная
|
|
1089
|
+
// карта repoFullName→локальный путь для трансляции run.repo из прода (см. resolveRepo).
|
|
1090
|
+
repoFullNames;
|
|
1091
|
+
repoPathByFullName;
|
|
1092
|
+
// Зеркало.
|
|
1093
|
+
runs = /* @__PURE__ */ new Map();
|
|
1094
|
+
runtime = /* @__PURE__ */ new Map();
|
|
1095
|
+
eventsLog = [];
|
|
1096
|
+
questionsLog = [];
|
|
1097
|
+
nextRunId = 1;
|
|
1098
|
+
nextEventId = 1;
|
|
1099
|
+
nextQuestionId = 1;
|
|
1100
|
+
// Корреляция с продом.
|
|
1101
|
+
keyByLocal = /* @__PURE__ */ new Map();
|
|
1102
|
+
// local run id → runKey
|
|
1103
|
+
prodIdByKey = /* @__PURE__ */ new Map();
|
|
1104
|
+
// runKey → prod cuid
|
|
1105
|
+
seenProdEventIds = /* @__PURE__ */ new Set();
|
|
1106
|
+
// дедуп событий при повторной гидрации
|
|
1107
|
+
// Outbox / flush.
|
|
1108
|
+
outboxPath;
|
|
1109
|
+
mapPath;
|
|
1110
|
+
stormPath;
|
|
1111
|
+
// durable-сайдкар глобальных (штормовых) событий
|
|
1112
|
+
pending = [];
|
|
1113
|
+
nextSeq = 1;
|
|
1114
|
+
flushing = false;
|
|
1115
|
+
flushTimer = null;
|
|
1116
|
+
backoffMs = 500;
|
|
1117
|
+
static BACKOFF_BASE = 500;
|
|
1118
|
+
static BACKOFF_MAX = 3e4;
|
|
1119
|
+
constructor(opts) {
|
|
1120
|
+
this.call = opts.call;
|
|
1121
|
+
this.defaultRepo = normalizeRepo(opts.defaultRepo ?? "");
|
|
1122
|
+
this.autoFlush = opts.autoFlush ?? true;
|
|
1123
|
+
this.listLimit = opts.listLimit ?? 200;
|
|
1124
|
+
const registry = opts.repoRegistry ?? [];
|
|
1125
|
+
this.repoFullNames = registry.map((e) => e.repoFullName);
|
|
1126
|
+
this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
|
|
1127
|
+
this.outboxPath = opts.outboxPath;
|
|
1128
|
+
this.mapPath = `${opts.outboxPath}.prodmap.json`;
|
|
1129
|
+
this.stormPath = `${opts.outboxPath}.storm.jsonl`;
|
|
1130
|
+
mkdirSync2(dirname(this.outboxPath), { recursive: true });
|
|
1131
|
+
this.recover();
|
|
1132
|
+
}
|
|
1133
|
+
// ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
|
|
1134
|
+
recover() {
|
|
1135
|
+
if (existsSync2(this.mapPath)) {
|
|
1136
|
+
try {
|
|
1137
|
+
const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
|
|
1138
|
+
for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
|
|
1139
|
+
} catch {
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (existsSync2(this.outboxPath)) {
|
|
1143
|
+
const txt = readFileSync3(this.outboxPath, "utf8");
|
|
1144
|
+
for (const line of txt.split("\n")) {
|
|
1145
|
+
const s = line.trim();
|
|
1146
|
+
if (!s) continue;
|
|
1147
|
+
try {
|
|
1148
|
+
this.pending.push(JSON.parse(s));
|
|
1149
|
+
} catch {
|
|
743
1150
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1151
|
+
}
|
|
1152
|
+
this.nextSeq = this.pending.reduce((m, e) => Math.max(m, e.seq), 0) + 1;
|
|
1153
|
+
if (this.autoFlush && this.pending.length) this.scheduleFlush();
|
|
1154
|
+
}
|
|
1155
|
+
this.replayGlobalEvents();
|
|
1156
|
+
}
|
|
1157
|
+
// Реплей глобальных (штормовых) событий из сайдкара в зеркало на старте. Идут в
|
|
1158
|
+
// eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
|
|
1159
|
+
// переприсваиваем локально (id событий — только порядок, не корреляция с продом).
|
|
1160
|
+
replayGlobalEvents() {
|
|
1161
|
+
if (!existsSync2(this.stormPath)) return;
|
|
1162
|
+
let txt;
|
|
1163
|
+
try {
|
|
1164
|
+
txt = readFileSync3(this.stormPath, "utf8");
|
|
1165
|
+
} catch {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
for (const line of txt.split("\n")) {
|
|
1169
|
+
const s = line.trim();
|
|
1170
|
+
if (!s) continue;
|
|
1171
|
+
try {
|
|
1172
|
+
const g = JSON.parse(s);
|
|
1173
|
+
this.eventsLog.push({
|
|
1174
|
+
id: this.nextEventId++,
|
|
1175
|
+
run_id: null,
|
|
1176
|
+
kind: g.kind,
|
|
1177
|
+
text: g.text ?? null,
|
|
1178
|
+
actor: g.actor ?? null,
|
|
1179
|
+
at: g.at
|
|
1180
|
+
});
|
|
1181
|
+
} catch {
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
// Дописать глобальное событие в durable-сайдкар (append-only, синхронно — как outbox).
|
|
1186
|
+
persistGlobalEvent(ev) {
|
|
1187
|
+
try {
|
|
1188
|
+
appendFileSync(this.stormPath, JSON.stringify(ev) + "\n");
|
|
1189
|
+
} catch {
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// ── зеркало: наложить рантайм на durable-прогон ──────────────────────────────
|
|
1193
|
+
mat(d) {
|
|
1194
|
+
const rt = this.runtime.get(d.id);
|
|
1195
|
+
const { runKey: _k, ...rest } = d;
|
|
1196
|
+
return { ...rest, pid: rt?.pid ?? null, worktree_path: rt?.worktree_path ?? null };
|
|
1197
|
+
}
|
|
1198
|
+
runKeyFor(localId) {
|
|
1199
|
+
return this.keyByLocal.get(localId);
|
|
1200
|
+
}
|
|
1201
|
+
// cuid прода → локальный id (по runKey гидрированного прогона `p:<cuid>`).
|
|
1202
|
+
// Для маппинга прод heroId → локальный hero_run_id при гидрации. Резолвится, когда
|
|
1203
|
+
// прогон-герой присутствует в зеркале; иначе null (уточнится следующим рефрешем).
|
|
1204
|
+
localIdForProdId(prodId) {
|
|
1205
|
+
return [...this.runs.values()].find((r) => r.runKey === `p:${prodId}`)?.id;
|
|
1206
|
+
}
|
|
1207
|
+
// ── outbox: поставить операцию в очередь (зеркало уже обновлено вызывающим) ───
|
|
1208
|
+
enqueue(e) {
|
|
1209
|
+
const full = { ...e, seq: this.nextSeq++ };
|
|
1210
|
+
this.pending.push(full);
|
|
1211
|
+
try {
|
|
1212
|
+
appendFileSync(this.outboxPath, JSON.stringify(full) + "\n");
|
|
1213
|
+
} catch {
|
|
1214
|
+
}
|
|
1215
|
+
this.scheduleFlush();
|
|
1216
|
+
}
|
|
1217
|
+
// Прод-патч прогона: локальные поля Run → поля swarm_run_upsert (см. TASWM-74).
|
|
1218
|
+
// pid/worktree_path (рантайм), respawns (нет в схеме тула) и снимок задачи (task_*,
|
|
1219
|
+
// прод берёт join'ом из Task) в прод НЕ уходят. kind → role (плоский enum). Self-ref
|
|
1220
|
+
// прогонов hero_run_id/resolves_run_id/superseded_by → heroId/resolvesRunId/
|
|
1221
|
+
// supersededById как ОТЛОЖЕННЫЕ ссылки (RunRef) — cuid резолвится на флаше.
|
|
1222
|
+
runFields(patch) {
|
|
1223
|
+
const out = {};
|
|
1224
|
+
if ("status" in patch && patch.status !== void 0) out.status = patch.status;
|
|
1225
|
+
if ("area" in patch) out.area = patch.area ?? null;
|
|
1226
|
+
if ("model" in patch) out.model = patch.model ?? null;
|
|
1227
|
+
if ("name" in patch) out.droneName = patch.name ?? null;
|
|
1228
|
+
if ("cost_usd" in patch && patch.cost_usd !== void 0) out.costUsd = patch.cost_usd;
|
|
1229
|
+
if ("pr_url" in patch) out.prUrl = patch.pr_url ?? null;
|
|
1230
|
+
if ("branch" in patch) out.branch = patch.branch ?? null;
|
|
1231
|
+
if ("repo" in patch) out.repo = patch.repo ?? null;
|
|
1232
|
+
if ("stop_reason" in patch) out.stopReason = patch.stop_reason ?? null;
|
|
1233
|
+
if ("depends_on" in patch && patch.depends_on) out.dependsOn = patch.depends_on;
|
|
1234
|
+
if ("decision" in patch) out.decision = patch.decision ? JSON.stringify(patch.decision) : null;
|
|
1235
|
+
if ("kind" in patch && patch.kind !== void 0) out.role = patch.kind;
|
|
1236
|
+
if ("review_verdict" in patch) out.reviewVerdict = patch.review_verdict ?? null;
|
|
1237
|
+
this.setRunRefField(out, "heroId", patch, "hero_run_id");
|
|
1238
|
+
this.setRunRefField(out, "resolvesRunId", patch, "resolves_run_id");
|
|
1239
|
+
this.setRunRefField(out, "supersededById", patch, "superseded_by");
|
|
1240
|
+
this.setRunRefField(out, "reviewsRunId", patch, "reviews_run_id");
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
// Положить self-ref прогона в прод-патч: локальный number → отложенная ссылка
|
|
1244
|
+
// {__runRef: runKey} (резолв в cuid на флаше, resolveArgs/materializeRefs). Явный
|
|
1245
|
+
// null → очистка поля. Локальный id без runKey (прогон не в зеркале) — пропускаем.
|
|
1246
|
+
setRunRefField(out, prodKey, patch, localKey) {
|
|
1247
|
+
if (!(localKey in patch)) return;
|
|
1248
|
+
const localId = patch[localKey];
|
|
1249
|
+
if (localId == null) {
|
|
1250
|
+
out[prodKey] = null;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const key = this.keyByLocal.get(localId);
|
|
1254
|
+
if (key) out[prodKey] = runRef(key);
|
|
1255
|
+
}
|
|
1256
|
+
enqueueRun(run, fields) {
|
|
1257
|
+
if (Object.keys(fields).length === 0) return;
|
|
1258
|
+
this.enqueue({ t: "run", runKey: run.runKey, taskRef: run.task_slug, fields });
|
|
1259
|
+
}
|
|
1260
|
+
// ── runs ─────────────────────────────────────────────────────────────────────
|
|
1261
|
+
createRun(input) {
|
|
1262
|
+
const rows = [...this.runs.values()].map((r) => ({ id: r.id, name: r.name, status: r.status }));
|
|
1263
|
+
const name = input.name ?? pickName(rows);
|
|
1264
|
+
const id = this.nextRunId++;
|
|
1265
|
+
const now = stamp();
|
|
1266
|
+
const d = {
|
|
1267
|
+
id,
|
|
1268
|
+
runKey: randomUUID(),
|
|
1269
|
+
task_slug: input.task_slug,
|
|
1270
|
+
name,
|
|
1271
|
+
kind: input.kind ?? "drone",
|
|
1272
|
+
// роль прогона: дефолт — рабочий дрон (TASWM-78)
|
|
1273
|
+
hero_run_id: input.hero_run_id ?? null,
|
|
1274
|
+
// отряд героя (задел иерархии)
|
|
1275
|
+
repo: normalizeRepo(input.repo || this.defaultRepo),
|
|
1276
|
+
branch: null,
|
|
1277
|
+
status: "queued",
|
|
1278
|
+
area: input.area ?? null,
|
|
1279
|
+
depends_on: input.depends_on && input.depends_on.length ? input.depends_on : [],
|
|
1280
|
+
model: input.model ?? null,
|
|
1281
|
+
cost_usd: 0,
|
|
1282
|
+
pr_url: null,
|
|
1283
|
+
superseded_by: null,
|
|
1284
|
+
decision: input.decision ?? null,
|
|
1285
|
+
stop_reason: null,
|
|
1286
|
+
respawns: 0,
|
|
1287
|
+
resolves_run_id: input.resolves_run_id ?? null,
|
|
1288
|
+
reviews_run_id: input.reviews_run_id ?? null,
|
|
1289
|
+
review_verdict: null,
|
|
1290
|
+
task_title: null,
|
|
1291
|
+
task_desc: null,
|
|
1292
|
+
task_url: null,
|
|
1293
|
+
created_at: now,
|
|
1294
|
+
updated_at: now
|
|
1295
|
+
};
|
|
1296
|
+
this.runs.set(id, d);
|
|
1297
|
+
this.keyByLocal.set(id, d.runKey);
|
|
1298
|
+
this.enqueueRun(d, this.runFields({
|
|
1299
|
+
status: "queued",
|
|
1300
|
+
area: d.area,
|
|
1301
|
+
model: d.model,
|
|
1302
|
+
name: d.name,
|
|
1303
|
+
repo: d.repo,
|
|
1304
|
+
depends_on: d.depends_on,
|
|
1305
|
+
decision: d.decision,
|
|
1306
|
+
kind: d.kind,
|
|
1307
|
+
hero_run_id: d.hero_run_id,
|
|
1308
|
+
resolves_run_id: d.resolves_run_id,
|
|
1309
|
+
// резолвер конфликта → связь на review-прогон
|
|
1310
|
+
reviews_run_id: d.reviews_run_id
|
|
1311
|
+
// ревьюер (авто-ревью, TASWM-148) → связь на review-прогон
|
|
1312
|
+
}));
|
|
1313
|
+
const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
|
|
1314
|
+
this.addEvent(id, "spawned", spawnNote);
|
|
1315
|
+
return this.mat(d);
|
|
1316
|
+
}
|
|
1317
|
+
getRun(id) {
|
|
1318
|
+
const d = this.runs.get(id);
|
|
1319
|
+
return d ? this.mat(d) : null;
|
|
1320
|
+
}
|
|
1321
|
+
listRuns(status) {
|
|
1322
|
+
const all = [...this.runs.values()].filter((r) => status ? r.status === status : true);
|
|
1323
|
+
all.sort((a, b) => b.id - a.id);
|
|
1324
|
+
return all.map((d) => this.mat(d));
|
|
1325
|
+
}
|
|
1326
|
+
/** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
|
|
1327
|
+
squadRuns(heroId) {
|
|
1328
|
+
return [...this.runs.values()].filter((r) => r.hero_run_id === heroId).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
|
|
1329
|
+
}
|
|
1330
|
+
updateRun(id, patch) {
|
|
1331
|
+
const d = this.runs.get(id);
|
|
1332
|
+
if (!d) return null;
|
|
1333
|
+
if (Object.keys(patch).length === 0) return this.mat(d);
|
|
1334
|
+
if ("pid" in patch || "worktree_path" in patch) {
|
|
1335
|
+
const rt = this.runtime.get(id) ?? { pid: null, worktree_path: null };
|
|
1336
|
+
if ("pid" in patch) rt.pid = patch.pid ?? null;
|
|
1337
|
+
if ("worktree_path" in patch) rt.worktree_path = patch.worktree_path ?? null;
|
|
1338
|
+
this.runtime.set(id, rt);
|
|
1339
|
+
}
|
|
1340
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1341
|
+
if (k === "pid" || k === "worktree_path") continue;
|
|
1342
|
+
d[k] = v;
|
|
1343
|
+
}
|
|
1344
|
+
d.updated_at = stamp();
|
|
1345
|
+
this.enqueueRun(d, this.runFields(patch));
|
|
1346
|
+
return this.mat(d);
|
|
1347
|
+
}
|
|
1348
|
+
incRespawn(id) {
|
|
1349
|
+
const d = this.runs.get(id);
|
|
1350
|
+
if (!d) return 0;
|
|
1351
|
+
d.respawns += 1;
|
|
1352
|
+
d.updated_at = stamp();
|
|
1353
|
+
return d.respawns;
|
|
1354
|
+
}
|
|
1355
|
+
cancel(id, reason) {
|
|
1356
|
+
const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
|
|
1357
|
+
if (run) this.addEvent(id, "stopped", reason);
|
|
1358
|
+
return run;
|
|
1359
|
+
}
|
|
1360
|
+
activeResolverFor(reviewRunId) {
|
|
1361
|
+
const cands = [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId && !RESOLVER_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
|
|
1362
|
+
return cands.length ? this.mat(cands[0]) : null;
|
|
1363
|
+
}
|
|
1364
|
+
resolverCountFor(reviewRunId) {
|
|
1365
|
+
return [...this.runs.values()].filter((r) => r.resolves_run_id === reviewRunId).length;
|
|
1366
|
+
}
|
|
1367
|
+
// failed/stalled ревьюеры (сорвался спавн ДО вердикта) в счёт НЕ идут — см. db.ts:reviewerCountFor.
|
|
1368
|
+
reviewerCountFor(reviewRunId) {
|
|
1369
|
+
return [...this.runs.values()].filter(
|
|
1370
|
+
(r) => r.reviews_run_id === reviewRunId && r.status !== "failed" && r.status !== "stalled"
|
|
1371
|
+
).length;
|
|
1372
|
+
}
|
|
1373
|
+
// Жёсткий кап ревью-спавнов (TASWM-204) — см. db.ts:reviewerAttemptCountFor.
|
|
1374
|
+
reviewerAttemptCountFor(reviewRunId) {
|
|
1375
|
+
return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).length;
|
|
1376
|
+
}
|
|
1377
|
+
// Последняя попытка ревьюера (TASWM-196) — см. db.ts:lastReviewerAttemptFor.
|
|
1378
|
+
lastReviewerAttemptFor(reviewRunId) {
|
|
1379
|
+
const cands = [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).sort((a, b) => b.id - a.id);
|
|
1380
|
+
return cands.length ? this.mat(cands[0]) : null;
|
|
1381
|
+
}
|
|
1382
|
+
// Живые/ждущие ревьюер-прогоны (TASWM-204) — см. db.ts:activeReviewersFor.
|
|
1383
|
+
activeReviewersFor(reviewRunId) {
|
|
1384
|
+
return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId && !TERMINAL_STATUSES.includes(r.status)).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
|
|
1385
|
+
}
|
|
1386
|
+
activeRunForSlug(slug) {
|
|
1387
|
+
const cands = [...this.runs.values()].filter((r) => r.task_slug === slug && !SLUG_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
|
|
1388
|
+
return cands.length ? this.mat(cands[0]) : null;
|
|
1389
|
+
}
|
|
1390
|
+
runningAreas(repo) {
|
|
1391
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1392
|
+
for (const r of this.runs.values()) {
|
|
1393
|
+
if (!(r.status === "running" || r.status === "blocked")) continue;
|
|
1394
|
+
if (r.area == null) continue;
|
|
1395
|
+
if (repo !== void 0 && r.repo !== repo) continue;
|
|
1396
|
+
seen.add(r.area);
|
|
1397
|
+
}
|
|
1398
|
+
return [...seen];
|
|
1399
|
+
}
|
|
1400
|
+
unmetDeps(depends_on) {
|
|
1401
|
+
if (!depends_on || depends_on.length === 0) return [];
|
|
1402
|
+
const done = /* @__PURE__ */ new Set();
|
|
1403
|
+
for (const r of this.runs.values()) {
|
|
1404
|
+
if (r.status === "done" && r.resolves_run_id == null) done.add(r.task_slug);
|
|
1405
|
+
}
|
|
1406
|
+
return depends_on.filter((slug) => !done.has(slug));
|
|
1407
|
+
}
|
|
1408
|
+
distinctRepos() {
|
|
1409
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1410
|
+
for (const r of this.runs.values()) if (r.repo) seen.add(r.repo);
|
|
1411
|
+
return [...seen];
|
|
1412
|
+
}
|
|
1413
|
+
// ── questions ──────────────────────────────────────────────────────────────
|
|
1414
|
+
ask(run_id, text, options) {
|
|
1415
|
+
const q = {
|
|
1416
|
+
id: this.nextQuestionId++,
|
|
1417
|
+
run_id,
|
|
1418
|
+
text,
|
|
1419
|
+
options: options && options.length ? options : null,
|
|
1420
|
+
status: "open",
|
|
1421
|
+
answer: null,
|
|
1422
|
+
answered_by: null,
|
|
1423
|
+
escalated: false,
|
|
1424
|
+
asked_at: stamp(),
|
|
1425
|
+
answered_at: null
|
|
1426
|
+
};
|
|
1427
|
+
this.questionsLog.push(q);
|
|
1428
|
+
this.updateRun(run_id, { status: "blocked" });
|
|
1429
|
+
this.addEvent(run_id, "blocked", text);
|
|
1430
|
+
const key = this.runKeyFor(run_id);
|
|
1431
|
+
if (key) this.enqueue({ t: "ask", runKey: key, text });
|
|
1432
|
+
return q;
|
|
1433
|
+
}
|
|
1434
|
+
escalateQuestion(id) {
|
|
1435
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1436
|
+
if (q) q.escalated = true;
|
|
1437
|
+
}
|
|
1438
|
+
withdrawQuestion(id, reason) {
|
|
1439
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1440
|
+
if (q && q.status === "open") {
|
|
1441
|
+
q.status = "withdrawn";
|
|
1442
|
+
q.answer = reason;
|
|
1443
|
+
q.answered_at = stamp();
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
answer(question_id, answer, by = "human") {
|
|
1447
|
+
const q = this.questionsLog.find((x) => x.id === question_id);
|
|
1448
|
+
if (q && q.status === "open") {
|
|
1449
|
+
q.status = "answered";
|
|
1450
|
+
q.answer = answer;
|
|
1451
|
+
q.answered_by = by;
|
|
1452
|
+
q.answered_at = stamp();
|
|
1453
|
+
this.updateRun(q.run_id, { status: "running" });
|
|
1454
|
+
this.addEvent(q.run_id, "answered", answer, by);
|
|
1455
|
+
}
|
|
1456
|
+
return q ? { ...q } : null;
|
|
1457
|
+
}
|
|
1458
|
+
supersede(oldId, newId, by = "human") {
|
|
1459
|
+
this.updateRun(oldId, { superseded_by: newId });
|
|
1460
|
+
this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
|
|
1461
|
+
}
|
|
1462
|
+
getQuestion(id) {
|
|
1463
|
+
const q = this.questionsLog.find((x) => x.id === id);
|
|
1464
|
+
return q ? { ...q } : null;
|
|
1465
|
+
}
|
|
1466
|
+
openQuestions() {
|
|
1467
|
+
return this.questionsLog.filter((q) => q.status === "open").sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
|
|
1468
|
+
}
|
|
1469
|
+
runIdsWithQuestions() {
|
|
1470
|
+
return [...new Set(this.questionsLog.map((q) => q.run_id))];
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Открытые вопросы дронов отряда Героя #heroId (вопрос → его прогон → hero_run_id).
|
|
1474
|
+
* Гейт «отвечать только своим» для answer_drone + список для squad_status (TASWM-81).
|
|
1475
|
+
*/
|
|
1476
|
+
openQuestionsFor(heroId) {
|
|
1477
|
+
return this.questionsLog.filter((q) => q.status === "open" && this.runs.get(q.run_id)?.hero_run_id === heroId).sort((a, b) => a.id - b.id).map((q) => ({ ...q }));
|
|
1478
|
+
}
|
|
1479
|
+
// ── events / хроника ─────────────────────────────────────────────────────────
|
|
1480
|
+
addEvent(run_id, kind, text, actor = null) {
|
|
1481
|
+
const ev = { id: this.nextEventId++, run_id, kind, text: text ?? null, actor: actor ?? null, at: stamp() };
|
|
1482
|
+
this.eventsLog.push(ev);
|
|
1483
|
+
if (run_id == null) {
|
|
1484
|
+
this.persistGlobalEvent({ kind, text: ev.text, actor: ev.actor, at: ev.at });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const key = this.runKeyFor(run_id);
|
|
1488
|
+
if (key) this.enqueue({ t: "event", runKey: key, kind, text: text ?? null, actor: actor ?? null });
|
|
1489
|
+
}
|
|
1490
|
+
events(run_id) {
|
|
1491
|
+
return this.eventsLog.filter((e) => e.run_id === run_id).sort((a, b) => a.id - b.id).map((e) => ({ ...e }));
|
|
1492
|
+
}
|
|
1493
|
+
history(limit = 50) {
|
|
1494
|
+
const items = [];
|
|
1495
|
+
const runById = (id) => id == null ? null : this.runs.get(id) ?? null;
|
|
1496
|
+
const nameOf = (r, runId) => r?.name ?? `#${runId}`;
|
|
1497
|
+
for (const q of this.questionsLog) {
|
|
1498
|
+
const r = runById(q.run_id);
|
|
1499
|
+
items.push({
|
|
1500
|
+
kind: "qa",
|
|
1501
|
+
at: q.answered_at ?? q.asked_at,
|
|
1502
|
+
slug: r?.task_slug ?? "?",
|
|
1503
|
+
name: nameOf(r, q.run_id),
|
|
1504
|
+
repo: r?.repo ?? "",
|
|
1505
|
+
area: r?.area ?? null,
|
|
1506
|
+
qid: q.id,
|
|
1507
|
+
question: q.text,
|
|
1508
|
+
options: q.options,
|
|
1509
|
+
answer: q.answer,
|
|
1510
|
+
answered_by: q.answered_by,
|
|
1511
|
+
escalated: q.escalated,
|
|
1512
|
+
open: q.status === "open",
|
|
1513
|
+
title: r?.task_title ?? null,
|
|
1514
|
+
pr: r?.pr_url ?? null,
|
|
1515
|
+
taskUrl: r?.task_url ?? null
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
for (const e of this.eventsLog) {
|
|
1519
|
+
if (!isMilestoneKind(e.kind)) continue;
|
|
1520
|
+
const global = e.run_id == null;
|
|
1521
|
+
const r = runById(e.run_id);
|
|
1522
|
+
items.push({
|
|
1523
|
+
kind: "milestone",
|
|
1524
|
+
at: e.at,
|
|
1525
|
+
slug: r?.task_slug ?? (global ? "\u0440\u043E\u0439" : "?"),
|
|
1526
|
+
name: global ? "\u0440\u043E\u0439" : nameOf(r, e.run_id),
|
|
1527
|
+
repo: r?.repo ?? "",
|
|
1528
|
+
event: e.kind,
|
|
1529
|
+
text: e.text,
|
|
1530
|
+
actor: e.actor,
|
|
1531
|
+
title: r?.task_title ?? null,
|
|
1532
|
+
pr: r?.pr_url ?? null,
|
|
1533
|
+
taskUrl: r?.task_url ?? null
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
|
|
1537
|
+
return items.slice(0, limit);
|
|
1538
|
+
}
|
|
1539
|
+
mergedToday() {
|
|
1540
|
+
const today = stamp().slice(0, 10);
|
|
1541
|
+
return this.eventsLog.filter((e) => e.kind === "merged" && e.at.slice(0, 10) === today).length;
|
|
1542
|
+
}
|
|
1543
|
+
// ── штормовой режим ──────────────────────────────────────────────────────────
|
|
1544
|
+
lastGlobal(kinds) {
|
|
1545
|
+
for (let i = this.eventsLog.length - 1; i >= 0; i--) {
|
|
1546
|
+
const e = this.eventsLog[i];
|
|
1547
|
+
if (e.run_id == null && kinds.includes(e.kind)) return e;
|
|
1548
|
+
}
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
stormActive() {
|
|
1552
|
+
return this.lastGlobal(["storm_start", "storm_end"])?.kind === "storm_start";
|
|
1553
|
+
}
|
|
1554
|
+
currentStormStartedAt() {
|
|
1555
|
+
const e = this.lastGlobal(["storm_start", "storm_end"]);
|
|
1556
|
+
return e?.kind === "storm_start" ? e.at : null;
|
|
1557
|
+
}
|
|
1558
|
+
startStorm(reason) {
|
|
1559
|
+
this.addEvent(null, "storm_start", reason);
|
|
1560
|
+
}
|
|
1561
|
+
endStorm(reason) {
|
|
1562
|
+
this.addEvent(null, "storm_end", reason);
|
|
1563
|
+
}
|
|
1564
|
+
recordDeath(runId, note) {
|
|
1565
|
+
this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
|
|
1566
|
+
}
|
|
1567
|
+
deathCountSince(cutoffSeconds) {
|
|
1568
|
+
const cutoff = stampFromEpochSec(cutoffSeconds);
|
|
1569
|
+
return this.eventsLog.filter((e) => e.kind === "died" && e.at >= cutoff).length;
|
|
1570
|
+
}
|
|
1571
|
+
spawnFailCountSince(cutoffSeconds) {
|
|
1572
|
+
const cutoff = stampFromEpochSec(cutoffSeconds);
|
|
1573
|
+
return this.eventsLog.filter((e) => {
|
|
1574
|
+
if (e.kind !== "failed" || e.run_id == null || e.at < cutoff) return false;
|
|
1575
|
+
const r = this.runs.get(e.run_id);
|
|
1576
|
+
return !!r && r.resolves_run_id == null && r.reviews_run_id == null;
|
|
1577
|
+
}).length;
|
|
1578
|
+
}
|
|
1579
|
+
recordStormProbe(probe, ext) {
|
|
1580
|
+
this.addEvent(null, "storm_probe", encodeProbe({ probe, ext }));
|
|
1581
|
+
}
|
|
1582
|
+
latestStormProbe() {
|
|
1583
|
+
const e = this.lastGlobal(["storm_probe"]);
|
|
1584
|
+
return e ? { rec: decodeProbe(e.text), at: e.at } : null;
|
|
1585
|
+
}
|
|
1586
|
+
stormProbeRecordsSince(sinceIso) {
|
|
1587
|
+
return this.eventsLog.filter((e) => e.run_id == null && e.kind === "storm_probe" && e.at >= sinceIso).sort((a, b) => a.id - b.id).map((e) => decodeProbe(e.text));
|
|
1588
|
+
}
|
|
1589
|
+
// ── гидрация зеркала из прода (старт + реконсайл-рефреш) ──────────────────────
|
|
1590
|
+
async hydrate() {
|
|
1591
|
+
let res;
|
|
1592
|
+
try {
|
|
1593
|
+
res = await this.call("swarm_list_runs", { limit: this.listLimit });
|
|
1594
|
+
} catch {
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
if (res?.isError) return;
|
|
1598
|
+
const runs = res.structuredContent?.runs ?? [];
|
|
1599
|
+
for (const pr of runs) this.hydrateRun(pr);
|
|
1600
|
+
for (const pr of runs) this.linkRefs(pr);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Реконсайл рантайма после рестарта демона (client-режим). pid/worktree_path живут в
|
|
1604
|
+
* ОТДЕЛЬНОЙ Map (машинное — в прод не уходят) и рестарт демона НЕ переживают: гидрация
|
|
1605
|
+
* из прода приносит running/blocked-прогоны с pid=null. Но демон и дроны — РАЗНЫЕ
|
|
1606
|
+
* detached-процессы (launcher: detached+unref), поэтому рестарт демона дронов НЕ убивает
|
|
1607
|
+
* — часть их ещё жива. Сопоставляем каждый running/blocked-прогон зеркала с живым
|
|
1608
|
+
* процессом (ps, injected listProcesses) по детерминированному worktree
|
|
1609
|
+
* (<worktreesDir>/<repo-basename>/<slug>):
|
|
1610
|
+
* • нашёлся живой процесс → восстанавливаем pid+worktree_path (дрон жив — overmind его
|
|
1611
|
+
* НЕ похоронит и не заспавнит второго поверх);
|
|
1612
|
+
* • не нашёлся → дрон не пережил рестарт: восстанавливаем worktree_path, если каталог
|
|
1613
|
+
* цел (для respawn --continue), pid оставляем null. overmind (tickOnce шаг 3) ловит
|
|
1614
|
+
* running/blocked с pid=null как смерть и оживляет/усыновляет/стопорит по общей ветке.
|
|
1615
|
+
* Рантайм пишем ПРЯМО в runtime-Map (в обход updateRun) — чисто локально, без outbox и
|
|
1616
|
+
* без прод-раунд-трипа. Идемпотентно; вызывается один раз на старте после hydrate().
|
|
1617
|
+
* Возвращает счётчики (для лога демона) + reconciled (TASWM-145): false → listProcesses()
|
|
1618
|
+
* бросил (ENFILE и т.п.), реконсайл этого запуска ПРОПУЩЕН целиком (не «confirmed пусто» —
|
|
1619
|
+
* см. коммент в catch ниже). Вызывающий (runDaemon) ОБЯЗАН пропустить startup-sync
|
|
1620
|
+
* (syncCrashedOnStartup) при reconciled=false: та трактует pid==null как «не пережил
|
|
1621
|
+
* рестарт» и безусловно хоронит (stalled/done) — при пропущенном реконсайле это massively
|
|
1622
|
+
* похоронило бы ВСЕХ running/blocked, включая реально живых detached-дронов, тем же
|
|
1623
|
+
* транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
|
|
1624
|
+
*/
|
|
1625
|
+
reconcileRuntime(opts) {
|
|
1626
|
+
const wtExists = opts.worktreeExists ?? existsSync2;
|
|
1627
|
+
const pidByWorktree = /* @__PURE__ */ new Map();
|
|
1628
|
+
try {
|
|
1629
|
+
for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
logTransientSpawnError("ps (reconcileRuntime)", e);
|
|
1632
|
+
return { alive: 0, dead: 0, reconciled: false };
|
|
1633
|
+
}
|
|
1634
|
+
let alive = 0;
|
|
1635
|
+
let dead = 0;
|
|
1636
|
+
for (const d of this.runs.values()) {
|
|
1637
|
+
if (d.status !== "running" && d.status !== "blocked") continue;
|
|
1638
|
+
if (this.runtime.get(d.id)?.pid != null) continue;
|
|
1639
|
+
const workerWt = droneWorktreePath(opts.worktreesDir, d.repo || this.defaultRepo, d.task_slug);
|
|
1640
|
+
let matchWt = null;
|
|
1641
|
+
let matchPid = null;
|
|
1642
|
+
if (d.resolves_run_id == null && d.reviews_run_id == null) {
|
|
1643
|
+
const p = pidByWorktree.get(workerWt);
|
|
1644
|
+
if (p != null) {
|
|
1645
|
+
matchWt = workerWt;
|
|
1646
|
+
matchPid = p;
|
|
1647
|
+
}
|
|
1648
|
+
} else if (d.resolves_run_id != null) {
|
|
1649
|
+
const prefix = `${workerWt}-resolve-`;
|
|
1650
|
+
for (const [wt, p] of pidByWorktree) {
|
|
1651
|
+
if (wt.startsWith(prefix)) {
|
|
1652
|
+
matchWt = wt;
|
|
1653
|
+
matchPid = p;
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
} else {
|
|
1658
|
+
const reviewed = this.runs.get(d.reviews_run_id);
|
|
1659
|
+
const reviewedWt = reviewed ? droneWorktreePath(opts.worktreesDir, reviewed.repo || this.defaultRepo, reviewed.task_slug) : workerWt;
|
|
1660
|
+
const prefix = `${reviewedWt}-review-`;
|
|
1661
|
+
for (const [wt, p] of pidByWorktree) {
|
|
1662
|
+
if (wt.startsWith(prefix)) {
|
|
1663
|
+
matchWt = wt;
|
|
1664
|
+
matchPid = p;
|
|
1665
|
+
break;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (matchPid != null && matchWt) {
|
|
1670
|
+
this.runtime.set(d.id, { pid: matchPid, worktree_path: matchWt });
|
|
1671
|
+
alive++;
|
|
1672
|
+
} else {
|
|
1673
|
+
const wt = d.resolves_run_id == null && d.reviews_run_id == null && wtExists(workerWt) ? workerWt : null;
|
|
1674
|
+
this.runtime.set(d.id, { pid: null, worktree_path: wt });
|
|
1675
|
+
dead++;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
return { alive, dead, reconciled: true };
|
|
1679
|
+
}
|
|
1680
|
+
// Долинковать self-ref прогона по прод-cuid → локальному id (после того как все
|
|
1681
|
+
// прогоны гидрированы). Идемпотентно: ставит найденное, иначе не трогает.
|
|
1682
|
+
linkRefs(pr) {
|
|
1683
|
+
const localId = this.localIdForProdId(String(pr.id ?? ""));
|
|
1684
|
+
const local = localId === void 0 ? void 0 : this.runs.get(localId);
|
|
1685
|
+
if (!local) return;
|
|
1686
|
+
const hero = pr.heroId ?? null;
|
|
1687
|
+
if (hero) local.hero_run_id = this.localIdForProdId(hero) ?? local.hero_run_id;
|
|
1688
|
+
const res = pr.resolvesRunId ?? null;
|
|
1689
|
+
if (res) local.resolves_run_id = this.localIdForProdId(res) ?? local.resolves_run_id;
|
|
1690
|
+
const sup = pr.supersededById ?? null;
|
|
1691
|
+
if (sup) local.superseded_by = this.localIdForProdId(sup) ?? local.superseded_by;
|
|
1692
|
+
const rev = pr.reviewsRunId ?? null;
|
|
1693
|
+
if (rev) local.reviews_run_id = this.localIdForProdId(rev) ?? local.reviews_run_id;
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Авто-интейк (TASWM-100): взять следующую claimable-задачу владельца токена через
|
|
1697
|
+
* прод-тул swarm_claim_work и завести её прогон в зеркале. claim атомарен НА СЕРВЕРЕ
|
|
1698
|
+
* (FIFO по createdAt + pg_advisory_xact_lock + дедуп «нет активного прогона») — гонку
|
|
1699
|
+
* двух тиков overmind и двойной claim решает прод, клиенту делать этого не надо. Непустой
|
|
1700
|
+
* ответ → ГИДРИРУЕМ созданный прогон как `p:<cuid>` (тот же путь, что hydrate/реконсайл),
|
|
1701
|
+
* а НЕ createRun: локальный create завёл бы ВТОРОЙ прогон с create-outbox по taskRef и
|
|
1702
|
+
* задвоил бы зеркало с уже существующим прод-прогоном. Гидрация лишь зеркалит прод — его
|
|
1703
|
+
* cuid известен сразу, никакого create-flush. Возвращает локальный Run (queued; overmind
|
|
1704
|
+
* заспавнит его следующим тиком) либо null: нет claimable-задачи / прод недоступен / тул
|
|
1705
|
+
* отверг — во всех случаях тик продолжается, следующий ретраит.
|
|
1706
|
+
*
|
|
1707
|
+
* TASWM-169: repoFullNames — ЖИВОЙ реестр демона на момент ЭТОГО вызова, а не
|
|
1708
|
+
* последняя heartbeat-декларация (heartbeat — своя кадансность, ~15с; claim — ~60с;
|
|
1709
|
+
* плюс окно до первого успешного heartbeat при рестарте) — свежий аргумент здесь
|
|
1710
|
+
* убирает то окно рассинхрона целиком. Пусто (демон без SWARM_REPOS) → ключ не
|
|
1711
|
+
* шлём — прод фолбэкается на последнюю heartbeat-декларацию (или без фильтра).
|
|
1712
|
+
*/
|
|
1713
|
+
async claim() {
|
|
1714
|
+
let res;
|
|
1715
|
+
try {
|
|
1716
|
+
res = await this.call("swarm_claim_work", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
|
|
1717
|
+
} catch {
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
if (res?.isError) return null;
|
|
1721
|
+
const sc = res.structuredContent ?? {};
|
|
1722
|
+
if (!sc.claimed || typeof sc.run !== "object" || sc.run === null) return null;
|
|
1723
|
+
const local = this.hydrateRun(sc.run);
|
|
1724
|
+
return local ? this.mat(local) : null;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Живой потолок дронов владельца из Taskless (TASWM-126): читает who_am_i.maxDrones
|
|
1728
|
+
* (личная настройка uiPrefs.swarm.maxDrones, дефолт 3). overmind капит им ВСЕ спавны —
|
|
1729
|
+
* очередь (шаг 1), резолвер (шаг 2), авто-claim (шаг 5) — чтобы активных дронов не было
|
|
1730
|
+
* больше числа из UI (инцидент 15.07: 4-6 дронов разом уронили ноут). who_am_i отдаёт
|
|
1731
|
+
* плоский JSON в text-контенте (не {display,data}). Прод недоступен / нет валидного числа
|
|
1732
|
+
* → null: ридер (makeMaxDronesReader) берёт env-фолбэк.
|
|
1733
|
+
*/
|
|
1734
|
+
async ownerMaxDrones() {
|
|
1735
|
+
let res;
|
|
1736
|
+
try {
|
|
1737
|
+
res = await this.call("who_am_i", {});
|
|
1738
|
+
} catch {
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
if (res?.isError) return null;
|
|
1742
|
+
const obj = jsonFromTextContent(res);
|
|
1743
|
+
const n = obj?.maxDrones;
|
|
1744
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Live verifyMode проекта из Taskless (TASWM-171): читает swarm_repo_config по
|
|
1748
|
+
* repoFullName (owner/repo из origin — см. git.remoteRepoFullName) — Project.swarmConfig.
|
|
1749
|
+
* verifyMode ("local"/"ci"). Прод недоступен / repo не смэплен на swarm-enabled проект
|
|
1750
|
+
* владельца токена (config=null в ответе) → null: ридер (makeVerifyModeReader) берёт
|
|
1751
|
+
* env-фолбэк SWARM_VERIFY.
|
|
1752
|
+
*/
|
|
1753
|
+
async repoVerifyMode(repoFullName) {
|
|
1754
|
+
let res;
|
|
1755
|
+
try {
|
|
1756
|
+
res = await this.call("swarm_repo_config", { repoFullName });
|
|
1757
|
+
} catch {
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
if (res?.isError) return null;
|
|
1761
|
+
const sc = res.structuredContent ?? {};
|
|
1762
|
+
const v = sc.config?.verifyMode;
|
|
1763
|
+
return v === "local" || v === "ci" ? v : null;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Пульс overmind в прод (TASWM-129): swarm_heartbeat обновляет User.swarmHeartbeatAt —
|
|
1767
|
+
* дашборд /swarm сравнивает свежесть со своим порогом устаревания («нет связи с
|
|
1768
|
+
* овермайндом» вместо устаревшего «живого» роя). Best-effort, как ownerMaxDrones/claim:
|
|
1769
|
+
* прод недоступен/тул отверг → просто не обновили в этом тике, следующий ретраит —
|
|
1770
|
+
* не роняем тик демона из-за упавшего пульса.
|
|
1771
|
+
*
|
|
1772
|
+
* TASWM-169: заодно декларирует repoFullNames (репо-реестр демона) — та же
|
|
1773
|
+
* кадансность, что и пульс, естественная точка «сказать серверу, что обслуживаю».
|
|
1774
|
+
* Пустой реестр (демон не сконфигурирован на multi-repo/SWARM_REPOS) → ключ не
|
|
1775
|
+
* шлём вовсе (undefined) — прод НЕ трогает прошлую декларацию (см. swarm.ts), не
|
|
1776
|
+
* стирает её ложным «репо больше нет» из-за демона без SWARM_REPOS в env.
|
|
1777
|
+
*/
|
|
1778
|
+
async heartbeat() {
|
|
1779
|
+
try {
|
|
1780
|
+
await this.call("swarm_heartbeat", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
|
|
1781
|
+
} catch {
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Встроенный надзор (TASWM-194): нотификация аномалии в инбокс владельца — прод-MCP
|
|
1786
|
+
* `swarm_notify`, ПРЯМОЙ вызов (НЕ outbox): аномалия — «сообщи СЕЙЧАС», не durable-факт,
|
|
1787
|
+
* который обязан пережить рестарт демона в неизменном порядке (как run-апдейты/события) —
|
|
1788
|
+
* задержка на цикл outbox-флаша (обычно доли секунды, но в худшем случае — до
|
|
1789
|
+
* восстановления сети) тут не оправдана.
|
|
1790
|
+
* runId (локальный numeric) резолвим в прод-cuid по той же карте, что flush аутбокса
|
|
1791
|
+
* (runKeyFor→prodIdByKey); прогон ещё не успел долететь до прода (cuid неизвестен) →
|
|
1792
|
+
* шлём без runId (нотификация всё равно уйдёт, просто без SwarmEvent-вехи на прогоне).
|
|
1793
|
+
*
|
|
1794
|
+
* БРОСАЕТ при сбое (транспорт ИЛИ isError:true от тула) — НЕ глотает, в отличие от
|
|
1795
|
+
* heartbeat() выше (code-review TASWM-194): вызывающий (overmind.ts tickOnce) коммитит
|
|
1796
|
+
* AnomalyTracker ТОЛЬКО после успешного return — молча проглоченная ошибка означала бы,
|
|
1797
|
+
* что трекер считает недоставленную нотификацию отправленной и молчит renotifyCooldownMs
|
|
1798
|
+
* (по умолчанию час) без единой реальной попытки. `res?.isError` — тот же паттерн, что
|
|
1799
|
+
* ownerMaxDrones() чуть выше: registerTool ловит throw внутри run() и превращает его в
|
|
1800
|
+
* `{isError:true}`, а не в отклонённый промис — без этой проверки схемная/бизнес-ошибка
|
|
1801
|
+
* тула (напр. TASWM-194 review: body > zod max) молча резолвилась бы успехом.
|
|
1802
|
+
*/
|
|
1803
|
+
async notifyAnomaly(signal) {
|
|
1804
|
+
const prodRunId = signal.runId != null ? this.prodIdByKey.get(this.runKeyFor(signal.runId) ?? "") : void 0;
|
|
1805
|
+
const res = await this.call("swarm_notify", {
|
|
1806
|
+
tag: signal.key,
|
|
1807
|
+
title: signal.title,
|
|
1808
|
+
body: signal.body,
|
|
1809
|
+
...signal.href ? { href: signal.href } : {},
|
|
1810
|
+
...prodRunId ? { runId: prodRunId } : {}
|
|
1811
|
+
});
|
|
1812
|
+
if (res?.isError) throw new Error(`swarm_notify \u043E\u0442\u0432\u0435\u0440\u0433 \xAB${signal.key}\xBB`);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* TASWM-169: прод присылает run.repo как GitHub `owner/repo` для задач, взятых
|
|
1816
|
+
* через claim-фильтр (matchedRepo в swarm_claim_work, см. api/mcp/tools/swarm.ts) —
|
|
1817
|
+
* сервер не знает локальных fs-путей. Транслируем в путь по СВОЕМУ репо-реестру
|
|
1818
|
+
* (см. repoRegistry.ts), т.к. git-операции (addWorktree/droneWorktreePath) ждут
|
|
1819
|
+
* именно путь. Не нашли в реестре (репо не наш / ручной `--repo <path>` спавн, где
|
|
1820
|
+
* repo — ВСЕГДА уже путь, старый формат) → возвращаем как есть без изменений
|
|
1821
|
+
* (обратная совместимость: раньше pr.repo ВСЕГДА был локальным путём).
|
|
1822
|
+
*/
|
|
1823
|
+
resolveRepo(raw) {
|
|
1824
|
+
if (!raw) return "";
|
|
1825
|
+
return this.repoPathByFullName.get(raw) ?? raw;
|
|
1826
|
+
}
|
|
1827
|
+
hydrateRun(pr) {
|
|
1828
|
+
const prodId = String(pr.id ?? "");
|
|
1829
|
+
if (!prodId) return null;
|
|
1830
|
+
const runKey = `p:${prodId}`;
|
|
1831
|
+
this.prodIdByKey.set(runKey, prodId);
|
|
1832
|
+
let local = [...this.runs.values()].find((r) => r.runKey === runKey);
|
|
1833
|
+
if (!local) local = [...this.runs.values()].find((r) => this.prodIdByKey.get(r.runKey) === prodId);
|
|
1834
|
+
if (!local) {
|
|
1835
|
+
const id = this.nextRunId++;
|
|
1836
|
+
local = {
|
|
1837
|
+
id,
|
|
1838
|
+
runKey,
|
|
1839
|
+
task_slug: "",
|
|
1840
|
+
name: `#${id}`,
|
|
1841
|
+
kind: "drone",
|
|
1842
|
+
hero_run_id: null,
|
|
1843
|
+
repo: "",
|
|
1844
|
+
branch: null,
|
|
1845
|
+
status: "queued",
|
|
1846
|
+
area: null,
|
|
1847
|
+
depends_on: [],
|
|
1848
|
+
model: null,
|
|
1849
|
+
cost_usd: 0,
|
|
1850
|
+
pr_url: null,
|
|
1851
|
+
superseded_by: null,
|
|
1852
|
+
decision: null,
|
|
1853
|
+
stop_reason: null,
|
|
1854
|
+
respawns: 0,
|
|
1855
|
+
resolves_run_id: null,
|
|
1856
|
+
reviews_run_id: null,
|
|
1857
|
+
review_verdict: null,
|
|
1858
|
+
task_title: null,
|
|
1859
|
+
task_desc: null,
|
|
1860
|
+
task_url: null,
|
|
1861
|
+
created_at: stamp(),
|
|
1862
|
+
updated_at: stamp()
|
|
1863
|
+
};
|
|
1864
|
+
this.runs.set(id, local);
|
|
1865
|
+
this.keyByLocal.set(id, runKey);
|
|
1866
|
+
}
|
|
1867
|
+
const slug = pr.taskSlug ?? pr.task?.slug ?? pr.taskId ?? local.task_slug;
|
|
1868
|
+
local.task_slug = slug || local.task_slug;
|
|
1869
|
+
local.task_title = pr.taskTitle ?? local.task_title;
|
|
1870
|
+
local.task_desc = pr.taskDesc ?? local.task_desc;
|
|
1871
|
+
local.name = pr.droneName ?? local.name;
|
|
1872
|
+
local.kind = pr.role ?? local.kind;
|
|
1873
|
+
const heroProd = pr.heroId ?? null;
|
|
1874
|
+
if (heroProd) local.hero_run_id = this.localIdForProdId(heroProd) ?? local.hero_run_id;
|
|
1875
|
+
const resProd = pr.resolvesRunId ?? null;
|
|
1876
|
+
if (resProd) local.resolves_run_id = this.localIdForProdId(resProd) ?? local.resolves_run_id;
|
|
1877
|
+
const supProd = pr.supersededById ?? null;
|
|
1878
|
+
if (supProd) local.superseded_by = this.localIdForProdId(supProd) ?? local.superseded_by;
|
|
1879
|
+
const revProd = pr.reviewsRunId ?? null;
|
|
1880
|
+
if (revProd) local.reviews_run_id = this.localIdForProdId(revProd) ?? local.reviews_run_id;
|
|
1881
|
+
local.review_verdict = pr.reviewVerdict ?? local.review_verdict;
|
|
1882
|
+
local.repo = this.resolveRepo(pr.repo ?? null);
|
|
1883
|
+
local.branch = pr.branch ?? null;
|
|
1884
|
+
local.status = pr.status ?? local.status;
|
|
1885
|
+
local.area = pr.area ?? null;
|
|
1886
|
+
local.depends_on = Array.isArray(pr.dependsOn) ? pr.dependsOn : [];
|
|
1887
|
+
local.model = pr.model ?? null;
|
|
1888
|
+
local.cost_usd = typeof pr.costUsd === "number" ? pr.costUsd : 0;
|
|
1889
|
+
local.pr_url = pr.prUrl ?? null;
|
|
1890
|
+
local.stop_reason = pr.stopReason ?? null;
|
|
1891
|
+
local.respawns = typeof pr.respawns === "number" ? pr.respawns : 0;
|
|
1892
|
+
local.decision = parseDecision(pr.decision);
|
|
1893
|
+
local.created_at = toStamp(pr.createdAt) ?? local.created_at;
|
|
1894
|
+
local.updated_at = toStamp(pr.updatedAt) ?? local.updated_at;
|
|
1895
|
+
const evs = pr.events ?? [];
|
|
1896
|
+
for (const pe of evs) {
|
|
1897
|
+
const eid = String(pe.id ?? "");
|
|
1898
|
+
if (eid && this.seenProdEventIds.has(eid)) continue;
|
|
1899
|
+
if (eid) this.seenProdEventIds.add(eid);
|
|
1900
|
+
const kind = String(pe.kind ?? "");
|
|
1901
|
+
const text = pe.text ?? null;
|
|
1902
|
+
const at = toStamp(pe.at) ?? stamp();
|
|
1903
|
+
if (kind === "question") {
|
|
1904
|
+
this.questionsLog.push({
|
|
1905
|
+
id: this.nextQuestionId++,
|
|
1906
|
+
run_id: local.id,
|
|
1907
|
+
text: text ?? "",
|
|
1908
|
+
options: null,
|
|
1909
|
+
status: "open",
|
|
1910
|
+
answer: null,
|
|
1911
|
+
answered_by: null,
|
|
1912
|
+
escalated: false,
|
|
1913
|
+
asked_at: at,
|
|
1914
|
+
answered_at: null
|
|
1915
|
+
});
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
const localKind = kind === "answer" ? "answered" : kind;
|
|
1919
|
+
this.eventsLog.push({
|
|
1920
|
+
id: this.nextEventId++,
|
|
1921
|
+
run_id: local.id,
|
|
1922
|
+
kind: localKind,
|
|
1923
|
+
text,
|
|
1924
|
+
actor: pe.actor ?? null,
|
|
1925
|
+
at
|
|
1926
|
+
});
|
|
1927
|
+
if (kind === "answer" || kind === "answered") {
|
|
1928
|
+
const open = [...this.questionsLog].reverse().find((q) => q.run_id === local.id && q.status === "open");
|
|
1929
|
+
if (open) {
|
|
1930
|
+
open.status = "answered";
|
|
1931
|
+
open.answer = text;
|
|
1932
|
+
open.answered_by = pe.actor ?? "human";
|
|
1933
|
+
open.answered_at = at;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return local;
|
|
1938
|
+
}
|
|
1939
|
+
// ── flush outbox в прод ──────────────────────────────────────────────────────
|
|
1940
|
+
scheduleFlush() {
|
|
1941
|
+
if (!this.autoFlush || this.flushing || this.flushTimer || this.pending.length === 0) return;
|
|
1942
|
+
this.flushTimer = setTimeout(() => {
|
|
1943
|
+
this.flushTimer = null;
|
|
1944
|
+
void this.flush();
|
|
1945
|
+
}, this.backoffMs);
|
|
1946
|
+
if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
|
|
1947
|
+
}
|
|
1948
|
+
// TASWM-202: create-флаш без прод-id слал taskId=e.taskRef НАПРЯМУЮ — а taskRef это
|
|
1949
|
+
// человеческий слаг (task_slug, напр. «TASWM-171»), не cuid. Резолверы конфликта
|
|
1950
|
+
// рождаются ЛОКАЛЬНО (createRun с uuid-runKey) и попадают в outbox БЕЗ прод-id —
|
|
1951
|
+
// обычные прогоны рождаются в проде (claim) и гидрируются сразу с cuid, эта ветка
|
|
1952
|
+
// их не касается. Прод create-схема ждёт cuid → отбивает слаг → строго
|
|
1953
|
+
// упорядоченный flush стоит на этой голове НАВЕЧНО (ядовитая голова, инцидент
|
|
1954
|
+
// 14.07/18.07, TASWM-201 — общий карантин; здесь — конкретный источник). Резолвим
|
|
1955
|
+
// слаг→cuid через get_task (кэш — taskIdCache) ПЕРЕД тем как слать create.
|
|
1956
|
+
taskIdCache = /* @__PURE__ */ new Map();
|
|
1957
|
+
// task_slug → cuid прода
|
|
1958
|
+
// Create для этого runKey резолвился окончательно неудачно (задача не найдена/
|
|
1959
|
+
// недоступна) — событие/вопросы того же прогона тоже никогда не получат prod-id,
|
|
1960
|
+
// без карантина они бы бросали «нет prod-id» на каждом флаше и СНОВА вставали
|
|
1961
|
+
// ядовитой головой (просто на одну запись ниже по очереди).
|
|
1962
|
+
poisonedRunKeys = /* @__PURE__ */ new Set();
|
|
1963
|
+
// "transient" — прод/сеть недоступны, ответа нет или ошибка НЕ про отсутствие задачи
|
|
1964
|
+
// (offline-стаб CLI, auth-протух, 5xx): НЕ терминально — запись остаётся в outbox и
|
|
1965
|
+
// повторится на следующем флаше (иначе сетевой сбой = потеря прогона). null —
|
|
1966
|
+
// терминально: прод ЯВНО ответил «задачи нет» (task_not_found / not_found в тексте
|
|
1967
|
+
// tool-ошибки) либо успешный ответ без id — только это карантиним.
|
|
1968
|
+
async resolveTaskCuid(taskRef) {
|
|
1969
|
+
const cached = this.taskIdCache.get(taskRef);
|
|
1970
|
+
if (cached) return cached;
|
|
1971
|
+
let res;
|
|
1972
|
+
try {
|
|
1973
|
+
res = await this.call("get_task", { id: taskRef });
|
|
1974
|
+
} catch {
|
|
1975
|
+
return "transient";
|
|
770
1976
|
}
|
|
1977
|
+
if (!res) return "transient";
|
|
1978
|
+
if (res.isError) {
|
|
1979
|
+
const text = (res.content ?? []).map((c) => c.text ?? "").join(" ");
|
|
1980
|
+
return /not_found/i.test(text) ? null : "transient";
|
|
1981
|
+
}
|
|
1982
|
+
const id = res.structuredContent?.task?.id;
|
|
1983
|
+
if (!id) return null;
|
|
1984
|
+
this.taskIdCache.set(taskRef, id);
|
|
1985
|
+
return id;
|
|
771
1986
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const who = unmet.map((d) => d.name ? `${d.slug}\xB7${d.name}` : d.slug).join(", ");
|
|
781
|
-
return { icon: "\u26D4", text: `\u0436\u0434\u0451\u0442 ${who}`, tone: "dep" };
|
|
1987
|
+
async resolveArgs(e) {
|
|
1988
|
+
if (e.t === "run") {
|
|
1989
|
+
const fields = this.materializeRefs(e.fields);
|
|
1990
|
+
const prodId2 = this.prodIdByKey.get(e.runKey);
|
|
1991
|
+
if (prodId2) return { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } };
|
|
1992
|
+
const taskId = await this.resolveTaskCuid(e.taskRef);
|
|
1993
|
+
if (taskId === "transient") {
|
|
1994
|
+
throw new Error(`slug\u2192cuid \u0434\u043B\u044F \xAB${e.taskRef}\xBB \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D (\u043F\u0440\u043E\u0434 \u043C\u043E\u0440\u0433\u043D\u0443\u043B) \u2014 retry`);
|
|
782
1995
|
}
|
|
783
|
-
|
|
1996
|
+
if (!taskId) {
|
|
1997
|
+
console.error(
|
|
1998
|
+
`[swarm-client] create-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: \u043F\u0440\u043E\u0434 \u043D\u0435 \u0437\u043D\u0430\u0435\u0442 \u0437\u0430\u0434\u0430\u0447\u0443 \xAB${e.taskRef}\xBB (runKey=${e.runKey}) \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`
|
|
1999
|
+
);
|
|
2000
|
+
this.poisonedRunKeys.add(e.runKey);
|
|
2001
|
+
return "skip";
|
|
2002
|
+
}
|
|
2003
|
+
return { tool: "swarm_run_upsert", args: { taskId, ...fields } };
|
|
784
2004
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
|
|
789
|
-
case "paused":
|
|
790
|
-
return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "blocked" };
|
|
791
|
-
case "review": {
|
|
792
|
-
if (!pr) return { icon: "\u{1F440}", text: "review \u0431\u0435\u0437 PR", tone: "review" };
|
|
793
|
-
if (!ci) return { icon: "\u23F3", text: "CI: \u0436\u0434\u0443 \u0441\u0442\u0430\u0442\u0443\u0441\u2026", tone: "review" };
|
|
794
|
-
if (ci.tone === "merge") return { icon: "\u{1F535}", text: "CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u043C\u0435\u0440\u0436\u0430", tone: "merge" };
|
|
795
|
-
return { icon: ci.tone === "fail" ? "\u274C" : "\u23F3", text: ci.text.replace(/^[^ ]+ /, ""), tone: ci.tone };
|
|
2005
|
+
if (this.poisonedRunKeys.has(e.runKey)) {
|
|
2006
|
+
console.error(`[swarm-client] ${e.t}-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: create \u043F\u0440\u043E\u0433\u043E\u043D\u0430 runKey=${e.runKey} \u043D\u0435 \u0440\u0435\u0437\u043E\u043B\u0432\u0438\u043B\u0441\u044F \u0440\u0430\u043D\u0435\u0435 \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`);
|
|
2007
|
+
return "skip";
|
|
796
2008
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
case "stopped":
|
|
802
|
-
return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
|
|
803
|
-
case "done":
|
|
804
|
-
return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
|
|
805
|
-
default:
|
|
806
|
-
return { icon: "\u2022", text: status, tone: "queued" };
|
|
2009
|
+
const prodId = this.prodIdByKey.get(e.runKey);
|
|
2010
|
+
if (!prodId) throw new Error(`\u043D\u0435\u0442 prod-id \u0434\u043B\u044F ${e.runKey} \u2014 \u043F\u0440\u043E\u0433\u043E\u043D \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D \u0432 \u043F\u0440\u043E\u0434\u0435`);
|
|
2011
|
+
if (e.t === "event") return { tool: "swarm_event_append", args: { runId: prodId, kind: e.kind, text: e.text, actor: e.actor } };
|
|
2012
|
+
return { tool: "swarm_ask", args: { runId: prodId, text: e.text } };
|
|
807
2013
|
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
2014
|
+
// Резолв отложенных self-ref ({__runRef: runKey}) в прод-cuid на флаше. Цель уже
|
|
2015
|
+
// создана в проде (её create-запись с меньшим seq флашится раньше) → cuid известен.
|
|
2016
|
+
// Не резолвится (ещё не создан) — поле выкидываем: лучше не слать, чем слать мусор;
|
|
2017
|
+
// догонит следующий апдейт/реконсайл. Обычные значения (null/строки) — как есть.
|
|
2018
|
+
materializeRefs(fields) {
|
|
2019
|
+
const out = {};
|
|
2020
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
2021
|
+
if (isRunRef(v)) {
|
|
2022
|
+
const cuid = this.prodIdByKey.get(v.__runRef);
|
|
2023
|
+
if (cuid) out[k] = cuid;
|
|
2024
|
+
} else {
|
|
2025
|
+
out[k] = v;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return out;
|
|
2029
|
+
}
|
|
2030
|
+
/** Слить накопленное в прод. Возвращает число успешно отправленных операций.
|
|
2031
|
+
* Порядок сохраняется; ошибка/недоступность → стоп, буфер остаётся, retry-backoff. */
|
|
2032
|
+
async flush() {
|
|
2033
|
+
if (this.flushing) return 0;
|
|
2034
|
+
this.flushing = true;
|
|
2035
|
+
let sent = 0;
|
|
2036
|
+
try {
|
|
2037
|
+
while (this.pending.length) {
|
|
2038
|
+
const e = this.pending[0];
|
|
2039
|
+
let call;
|
|
2040
|
+
try {
|
|
2041
|
+
call = await this.resolveArgs(e);
|
|
2042
|
+
} catch {
|
|
2043
|
+
break;
|
|
2044
|
+
}
|
|
2045
|
+
if (call === "skip") {
|
|
2046
|
+
this.pending.shift();
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
let res;
|
|
2050
|
+
try {
|
|
2051
|
+
res = await this.call(call.tool, call.args);
|
|
2052
|
+
} catch {
|
|
2053
|
+
break;
|
|
2054
|
+
}
|
|
2055
|
+
if (res?.isError) break;
|
|
2056
|
+
if (e.t === "run") {
|
|
2057
|
+
const run = res.structuredContent?.run;
|
|
2058
|
+
if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
|
|
2059
|
+
this.prodIdByKey.set(e.runKey, run.id);
|
|
2060
|
+
this.persistMap();
|
|
2061
|
+
}
|
|
2062
|
+
} else {
|
|
2063
|
+
const evId = res.structuredContent?.event?.id;
|
|
2064
|
+
if (evId) this.seenProdEventIds.add(evId);
|
|
2065
|
+
}
|
|
2066
|
+
this.pending.shift();
|
|
2067
|
+
sent++;
|
|
2068
|
+
}
|
|
2069
|
+
this.persistPending();
|
|
2070
|
+
if (this.pending.length === 0) {
|
|
2071
|
+
this.backoffMs = _SwarmClient.BACKOFF_BASE;
|
|
2072
|
+
} else {
|
|
2073
|
+
this.backoffMs = Math.min(this.backoffMs * 2, _SwarmClient.BACKOFF_MAX);
|
|
2074
|
+
this.scheduleFlush();
|
|
2075
|
+
}
|
|
2076
|
+
} finally {
|
|
2077
|
+
this.flushing = false;
|
|
2078
|
+
}
|
|
2079
|
+
return sent;
|
|
2080
|
+
}
|
|
2081
|
+
persistPending() {
|
|
2082
|
+
try {
|
|
2083
|
+
writeFileSync2(this.outboxPath, this.pending.map((e) => JSON.stringify(e)).join("\n") + (this.pending.length ? "\n" : ""));
|
|
2084
|
+
} catch {
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
persistMap() {
|
|
2088
|
+
try {
|
|
2089
|
+
writeFileSync2(this.mapPath, JSON.stringify(Object.fromEntries(this.prodIdByKey)));
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
/** Незалитых операций в буфере (для тестов/наблюдаемости). */
|
|
2094
|
+
pendingCount() {
|
|
2095
|
+
return this.pending.length;
|
|
2096
|
+
}
|
|
2097
|
+
close() {
|
|
2098
|
+
if (this.flushTimer) {
|
|
2099
|
+
clearTimeout(this.flushTimer);
|
|
2100
|
+
this.flushTimer = null;
|
|
2101
|
+
}
|
|
2102
|
+
void this.flush();
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
function parseDecision(v) {
|
|
2106
|
+
if (!v || typeof v !== "string") return null;
|
|
2107
|
+
try {
|
|
2108
|
+
return JSON.parse(v);
|
|
2109
|
+
} catch {
|
|
2110
|
+
return null;
|
|
825
2111
|
}
|
|
826
|
-
l = by((x) => x.status === "review");
|
|
827
|
-
if (l) return { icon: "\u23F3", text: fmt(l, "review \u2014 CI \u0438\u0434\u0451\u0442"), tone: "review" };
|
|
828
|
-
l = by((x) => x.status === "running");
|
|
829
|
-
if (l) return { icon: "\u25B6", text: `${live.filter((x) => x.status === "running").length} \u0434\u0440\u043E\u043D\u0430 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435 \u2014 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u043E\u043A \u043D\u0435\u0442`, tone: "run" };
|
|
830
|
-
return null;
|
|
831
2112
|
}
|
|
832
2113
|
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
2114
|
+
// src/repoRegistry.ts
|
|
2115
|
+
import { $ } from "execa";
|
|
2116
|
+
function parseRepoFullName(remoteUrl) {
|
|
2117
|
+
const m = remoteUrl.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(\.git)?\/?$/i);
|
|
2118
|
+
if (!m) return null;
|
|
2119
|
+
return `${m[1]}/${m[2]}`.toLowerCase();
|
|
2120
|
+
}
|
|
2121
|
+
async function resolveRepoFullName(path) {
|
|
2122
|
+
const res = await $({ reject: false })`git -C ${path} remote get-url origin`;
|
|
2123
|
+
if (res.exitCode !== 0) return null;
|
|
2124
|
+
return parseRepoFullName(res.stdout);
|
|
2125
|
+
}
|
|
2126
|
+
async function buildRepoRegistry(paths) {
|
|
2127
|
+
const resolved = await Promise.all(
|
|
2128
|
+
paths.map(async (raw) => {
|
|
2129
|
+
const path = normalizeRepo(raw);
|
|
2130
|
+
if (!path) return null;
|
|
2131
|
+
const repoFullName = await resolveRepoFullName(path);
|
|
2132
|
+
return repoFullName ? { repoFullName, path } : null;
|
|
2133
|
+
})
|
|
2134
|
+
);
|
|
2135
|
+
return resolved.filter((e) => e !== null);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/web.ts
|
|
2139
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2140
|
+
import { basename as basename2 } from "path";
|
|
2141
|
+
import { serve } from "@hono/node-server";
|
|
850
2142
|
|
|
851
2143
|
// src/diff.ts
|
|
852
2144
|
import { statSync } from "fs";
|
|
853
2145
|
import { join as join2 } from "path";
|
|
854
|
-
import { $ } from "execa";
|
|
2146
|
+
import { $ as $2 } from "execa";
|
|
855
2147
|
var MAX_FILES = 200;
|
|
856
2148
|
var MAX_LINES_PER_FILE = 1500;
|
|
857
2149
|
var MAX_UNTRACKED = 60;
|
|
@@ -971,12 +2263,12 @@ function truncateFile(f) {
|
|
|
971
2263
|
}
|
|
972
2264
|
async function resolveBase(wt) {
|
|
973
2265
|
const tryRef = async (ref) => {
|
|
974
|
-
const sha = (await $({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
|
|
2266
|
+
const sha = (await $2({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
|
|
975
2267
|
if (!sha) return null;
|
|
976
|
-
const mb = (await $({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
|
|
2268
|
+
const mb = (await $2({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
|
|
977
2269
|
return mb || null;
|
|
978
2270
|
};
|
|
979
|
-
const originHead = (await $({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
|
|
2271
|
+
const originHead = (await $2({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
|
|
980
2272
|
const candidates = [originHead, "origin/main", "origin/master", "main", "master"].filter(Boolean);
|
|
981
2273
|
for (const c of candidates) {
|
|
982
2274
|
const mb = await tryRef(c);
|
|
@@ -995,16 +2287,16 @@ async function collectRunDiff(worktree, opts = {}) {
|
|
|
995
2287
|
const stub = (reason) => ({ ok: false, reason, pr: opts.pr ?? null, files: [], added: 0, removed: 0 });
|
|
996
2288
|
const gone = opts.status === "queued" ? "\u0434\u0440\u043E\u043D \u0435\u0449\u0451 \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u043A\u043E\u0434\u0430 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442" : "worktree \u0443\u0431\u0440\u0430\u043D \u2014 \u043A\u043E\u0434 \u0441\u043C\u043E\u0442\u0440\u0438 \u0432 PR";
|
|
997
2289
|
if (!worktree) return stub(gone);
|
|
998
|
-
const isWt = (await $({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
|
|
2290
|
+
const isWt = (await $2({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
|
|
999
2291
|
if (isWt !== "true") return stub(gone);
|
|
1000
2292
|
const base2 = await resolveBase(worktree);
|
|
1001
2293
|
const from = base2 ?? "HEAD";
|
|
1002
|
-
const trackedTxt = (await $({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
|
|
2294
|
+
const trackedTxt = (await $2({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
|
|
1003
2295
|
let files = parseUnifiedDiff(trackedTxt);
|
|
1004
|
-
const untrackedRaw = (await $({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
|
|
2296
|
+
const untrackedRaw = (await $2({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
|
|
1005
2297
|
const untracked = untrackedRaw.split("\0").filter(Boolean).slice(0, MAX_UNTRACKED);
|
|
1006
2298
|
for (const rel of untracked) {
|
|
1007
|
-
const t = (await $({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
|
|
2299
|
+
const t = (await $2({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
|
|
1008
2300
|
for (const pf of parseUnifiedDiff(t)) {
|
|
1009
2301
|
pf.status = "added";
|
|
1010
2302
|
if (!pf.path || pf.path === "/dev/null") pf.path = rel;
|
|
@@ -1027,9 +2319,49 @@ async function collectRunDiff(worktree, opts = {}) {
|
|
|
1027
2319
|
};
|
|
1028
2320
|
}
|
|
1029
2321
|
|
|
2322
|
+
// src/gh.ts
|
|
2323
|
+
import { $ as $3 } from "execa";
|
|
2324
|
+
var realChecksRunner = async (pr) => (await $3({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
|
|
2325
|
+
async function prCheckRows(pr, run) {
|
|
2326
|
+
try {
|
|
2327
|
+
const out = await run(pr);
|
|
2328
|
+
const rows = JSON.parse(out);
|
|
2329
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
2330
|
+
return rows.map((r) => ({ name: r.name ?? "?", bucket: (r.bucket ?? "").toLowerCase(), link: r.link ?? "" }));
|
|
2331
|
+
} catch {
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
async function ghChecks(pr, run = realChecksRunner) {
|
|
2336
|
+
const rows = await prCheckRows(pr, run);
|
|
2337
|
+
if (!rows) return null;
|
|
2338
|
+
let pass = 0, fail = 0, pending2 = 0;
|
|
2339
|
+
for (const r of rows) {
|
|
2340
|
+
if (r.bucket === "pass") pass++;
|
|
2341
|
+
else if (r.bucket === "fail" || r.bucket === "cancel") fail++;
|
|
2342
|
+
else if (r.bucket === "pending") pending2++;
|
|
2343
|
+
}
|
|
2344
|
+
const total = rows.length;
|
|
2345
|
+
let bucket, text, tone;
|
|
2346
|
+
if (fail > 0) {
|
|
2347
|
+
bucket = "fail";
|
|
2348
|
+
tone = "fail";
|
|
2349
|
+
text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
|
|
2350
|
+
} else if (pending2 > 0) {
|
|
2351
|
+
bucket = "pending";
|
|
2352
|
+
tone = "review";
|
|
2353
|
+
text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
|
|
2354
|
+
} else {
|
|
2355
|
+
bucket = "pass";
|
|
2356
|
+
tone = "merge";
|
|
2357
|
+
text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
|
|
2358
|
+
}
|
|
2359
|
+
return { bucket, text, tone, total, pass, fail, pending: pending2 };
|
|
2360
|
+
}
|
|
2361
|
+
|
|
1030
2362
|
// src/oauth.ts
|
|
1031
2363
|
import { createHash, randomBytes } from "crypto";
|
|
1032
|
-
import { readFileSync as
|
|
2364
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync } from "fs";
|
|
1033
2365
|
import { join as join3 } from "path";
|
|
1034
2366
|
var REGISTER = "/api/oauth/register";
|
|
1035
2367
|
var AUTHORIZE = "/api/oauth/authorize";
|
|
@@ -1121,13 +2453,13 @@ function toStore(clientId, t, now) {
|
|
|
1121
2453
|
}
|
|
1122
2454
|
function loadStore() {
|
|
1123
2455
|
try {
|
|
1124
|
-
return JSON.parse(
|
|
2456
|
+
return JSON.parse(readFileSync4(STORE_PATH, "utf8"));
|
|
1125
2457
|
} catch {
|
|
1126
2458
|
return null;
|
|
1127
2459
|
}
|
|
1128
2460
|
}
|
|
1129
2461
|
function saveStore(s) {
|
|
1130
|
-
|
|
2462
|
+
writeFileSync3(STORE_PATH, JSON.stringify(s, null, 2), { mode: 384 });
|
|
1131
2463
|
}
|
|
1132
2464
|
function clearStore() {
|
|
1133
2465
|
try {
|
|
@@ -1137,7 +2469,7 @@ function clearStore() {
|
|
|
1137
2469
|
}
|
|
1138
2470
|
function loadClientId() {
|
|
1139
2471
|
try {
|
|
1140
|
-
return JSON.parse(
|
|
2472
|
+
return JSON.parse(readFileSync4(CLIENT_PATH, "utf8")).clientId;
|
|
1141
2473
|
} catch {
|
|
1142
2474
|
return null;
|
|
1143
2475
|
}
|
|
@@ -1146,7 +2478,7 @@ async function ensureClientId(api, redirectUri, fetchFn = fetch) {
|
|
|
1146
2478
|
const existing = loadClientId();
|
|
1147
2479
|
if (existing) return existing;
|
|
1148
2480
|
const clientId = await registerClient({ api, redirectUri }, fetchFn);
|
|
1149
|
-
|
|
2481
|
+
writeFileSync3(CLIENT_PATH, JSON.stringify({ clientId }, null, 2), { mode: 384 });
|
|
1150
2482
|
return clientId;
|
|
1151
2483
|
}
|
|
1152
2484
|
async function ensureAccess(store, opts = {}) {
|
|
@@ -1168,13 +2500,11 @@ async function ensureAccess(store, opts = {}) {
|
|
|
1168
2500
|
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1169
2501
|
var jsonForScript = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
1170
2502
|
var TASKLESS_MARK = `<svg viewBox="0 0 60 54" width="19" height="19" fill="none" aria-hidden="true" style="display:block"><g transform="matrix(1.0512,0,0,1.0512,0,-0.021)"><path fill="currentColor" fill-rule="nonzero" d="M46.92,0 L10.24,0 C4.59,0 0,4.59 0,10.24 L0,10.4 C0,16.05 4.59,20.64 10.24,20.64 L16.84,20.64 C19.56,20.64 22.12,19.59 24.05,17.67 L36.06,5.77 C37.25,4.6 38.82,3.95 40.49,3.95 L46.93,3.95 C50.4,3.95 53.22,6.77 53.22,10.24 L53.22,10.4 C53.22,13.87 50.4,16.69 46.93,16.69 L39.82,16.69 C37.05,16.69 34.36,17.83 32.44,19.83 L21.13,31.58 C19.28,33.5 18.27,36.02 18.27,38.68 L18.27,40.94 C18.27,46.59 22.86,51.18 28.51,51.18 C34.16,51.18 38.75,46.59 38.75,40.94 L38.75,20.74 C39.1,20.68 39.46,20.64 39.82,20.64 L46.93,20.64 C52.58,20.64 57.17,16.05 57.17,10.4 L57.17,10.24 C57.17,4.59 52.58,0 46.93,0 L46.92,0 Z M21.26,14.87 C20.07,16.04 18.5,16.69 16.83,16.69 L10.23,16.69 C6.76,16.69 3.94,13.87 3.94,10.4 L3.94,10.24 C3.94,6.77 6.76,3.95 10.23,3.95 L32.27,3.95 L21.25,14.87 L21.26,14.87 Z M34.79,40.94 C34.79,44.41 31.97,47.23 28.5,47.23 C25.03,47.23 22.21,44.41 22.21,40.94 L22.21,38.68 C22.21,37.05 22.83,35.5 23.97,34.32 L34.79,23.07 L34.79,40.94 L34.79,40.94 Z"/></g></svg>`;
|
|
1171
|
-
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1172
|
-
var sqliteMs = (ts) => Date.parse(ts.replace(" ", "T") + "Z");
|
|
1173
2503
|
var sqliteIso = (ts) => {
|
|
1174
2504
|
const ms = sqliteMs(ts);
|
|
1175
2505
|
return Number.isNaN(ms) ? ts : new Date(ms).toISOString();
|
|
1176
2506
|
};
|
|
1177
|
-
function roySnapshot(db2, now = Date.now()) {
|
|
2507
|
+
function roySnapshot(db2, now = Date.now(), overmindLog = []) {
|
|
1178
2508
|
const all = db2.listRuns();
|
|
1179
2509
|
const c = (s) => all.filter((r) => r.status === s).length;
|
|
1180
2510
|
const withQ = new Set(db2.runIdsWithQuestions());
|
|
@@ -1244,13 +2574,15 @@ function roySnapshot(db2, now = Date.now()) {
|
|
|
1244
2574
|
active: db2.stormActive(),
|
|
1245
2575
|
since: db2.currentStormStartedAt(),
|
|
1246
2576
|
dead: all.filter((r) => r.status === "storm_dead").length
|
|
1247
|
-
}
|
|
2577
|
+
},
|
|
2578
|
+
overmindLog
|
|
1248
2579
|
};
|
|
1249
2580
|
}
|
|
1250
|
-
var
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
2581
|
+
var {
|
|
2582
|
+
windowMs: TL_RECENT_TERMINAL_MS,
|
|
2583
|
+
maxTerminalLanes: TL_MAX_TERMINAL_LANES,
|
|
2584
|
+
maxAxisMs: TL_MAX_AXIS_MS
|
|
2585
|
+
} = resolveRoyWindows(process.env);
|
|
1254
2586
|
var ciCache = /* @__PURE__ */ new Map();
|
|
1255
2587
|
var CI_TTL = 2e4;
|
|
1256
2588
|
var CI_MOCK = (() => {
|
|
@@ -1266,50 +2598,18 @@ function mockCi(bucket) {
|
|
|
1266
2598
|
if (b === "pending") return { bucket: "pending", tone: "review", text: "\u23F3 CI \u0438\u0434\u0451\u0442 (1/2)", total: 2, pass: 1, fail: 0, pending: 1 };
|
|
1267
2599
|
return { bucket: "pass", tone: "merge", text: "\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443", total: 2, pass: 2, fail: 0, pending: 0 };
|
|
1268
2600
|
}
|
|
1269
|
-
async function ghChecks(pr) {
|
|
1270
|
-
try {
|
|
1271
|
-
const { stdout: out } = await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`;
|
|
1272
|
-
const rows = JSON.parse(out);
|
|
1273
|
-
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
1274
|
-
let pass = 0, fail = 0, pending2 = 0;
|
|
1275
|
-
for (const r of rows) {
|
|
1276
|
-
const b = (r.bucket || "").toLowerCase();
|
|
1277
|
-
if (b === "pass") pass++;
|
|
1278
|
-
else if (b === "fail" || b === "cancel") fail++;
|
|
1279
|
-
else if (b === "pending") pending2++;
|
|
1280
|
-
}
|
|
1281
|
-
const total = rows.length;
|
|
1282
|
-
let bucket, text, tone;
|
|
1283
|
-
if (fail > 0) {
|
|
1284
|
-
bucket = "fail";
|
|
1285
|
-
tone = "fail";
|
|
1286
|
-
text = `\u274C CI \u0443\u043F\u0430\u043B (${fail}/${total})`;
|
|
1287
|
-
} else if (pending2 > 0) {
|
|
1288
|
-
bucket = "pending";
|
|
1289
|
-
tone = "review";
|
|
1290
|
-
text = `\u23F3 CI \u0438\u0434\u0451\u0442 (${pass}/${total})`;
|
|
1291
|
-
} else {
|
|
1292
|
-
bucket = "pass";
|
|
1293
|
-
tone = "merge";
|
|
1294
|
-
text = `\u2705 CI \u0437\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u043A \u043C\u0435\u0440\u0436\u0443`;
|
|
1295
|
-
}
|
|
1296
|
-
return { bucket, text, tone, total, pass, fail, pending: pending2 };
|
|
1297
|
-
} catch {
|
|
1298
|
-
return null;
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
2601
|
async function refreshCI(prs) {
|
|
1302
2602
|
await Promise.all(prs.map(async (pr) => {
|
|
1303
2603
|
const ci = await ghChecks(pr);
|
|
1304
2604
|
ciCache.set(pr, { ci, at: Date.now() });
|
|
1305
2605
|
}));
|
|
1306
2606
|
}
|
|
1307
|
-
function timelineState(db2, now = Date.now()) {
|
|
2607
|
+
function timelineState(db2, now = Date.now(), overmindLog = []) {
|
|
1308
2608
|
const runs = db2.listRuns().slice().sort((a, b) => a.id - b.id);
|
|
1309
2609
|
const doneNameBySlug = /* @__PURE__ */ new Map();
|
|
1310
2610
|
const activeBySlug = /* @__PURE__ */ new Map();
|
|
1311
2611
|
for (const r of runs) {
|
|
1312
|
-
if (!
|
|
2612
|
+
if (!isTerminalStatus(r.status)) activeBySlug.set(r.task_slug, { id: r.id, name: r.name });
|
|
1313
2613
|
if (r.status === "done") doneNameBySlug.set(r.task_slug, r.name);
|
|
1314
2614
|
}
|
|
1315
2615
|
const openQ = /* @__PURE__ */ new Map();
|
|
@@ -1319,7 +2619,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1319
2619
|
const lanes = [];
|
|
1320
2620
|
const reviewPRs = [];
|
|
1321
2621
|
for (const r of runs) {
|
|
1322
|
-
const live = !
|
|
2622
|
+
const live = !isTerminalStatus(r.status);
|
|
1323
2623
|
const recencyGated = !live || r.status === "stalled";
|
|
1324
2624
|
if (recencyGated && sqliteMs(r.updated_at) < cutoff) continue;
|
|
1325
2625
|
const events = db2.events(r.id).map((e) => ({ kind: e.kind, text: e.text ?? null, at: sqliteIso(e.at) }));
|
|
@@ -1347,7 +2647,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1347
2647
|
id: r.id,
|
|
1348
2648
|
name: r.name,
|
|
1349
2649
|
slug: r.task_slug,
|
|
1350
|
-
repo:
|
|
2650
|
+
repo: basename2(r.repo || "") || "?",
|
|
1351
2651
|
status: r.status,
|
|
1352
2652
|
area: r.area,
|
|
1353
2653
|
start: sqliteIso(r.created_at),
|
|
@@ -1361,7 +2661,11 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1361
2661
|
ci,
|
|
1362
2662
|
pr: r.pr_url,
|
|
1363
2663
|
dependsOn: deps,
|
|
1364
|
-
cost: r.cost_usd || 0
|
|
2664
|
+
cost: r.cost_usd || 0,
|
|
2665
|
+
role: r.kind,
|
|
2666
|
+
heroRunId: r.hero_run_id,
|
|
2667
|
+
reviewVerdict: r.review_verdict,
|
|
2668
|
+
model: r.model
|
|
1365
2669
|
});
|
|
1366
2670
|
}
|
|
1367
2671
|
const liveLanes = lanes.filter((l) => l.live);
|
|
@@ -1391,7 +2695,7 @@ function timelineState(db2, now = Date.now()) {
|
|
|
1391
2695
|
const headline = pickHeadline(shown);
|
|
1392
2696
|
const stale = [...new Set(reviewPRs)].filter((pr) => now - (ciCache.get(pr)?.at ?? 0) > CI_TTL);
|
|
1393
2697
|
if (stale.length) void refreshCI(stale);
|
|
1394
|
-
return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown };
|
|
2698
|
+
return { now: nowIso, axisStart, axisEnd: nowIso, counters: c, headline, lanes: shown, overmindLog };
|
|
1395
2699
|
}
|
|
1396
2700
|
var CSS = `
|
|
1397
2701
|
:root {
|
|
@@ -1529,7 +2833,7 @@ main { max-width: 1080px; margin: 0 auto; width: 100%; }
|
|
|
1529
2833
|
.pill .sd { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
1530
2834
|
.pill.run { color: var(--primary); background: var(--primary-soft); } .pill.run .sd { animation: pulse 1.8s infinite; }
|
|
1531
2835
|
.pill.review { color: var(--info); background: color-mix(in oklch, var(--info) 14%, transparent); }
|
|
1532
|
-
.pill.blocked { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
|
|
2836
|
+
.pill.blocked, .pill.paused, .pill.storm { color: var(--warning); background: color-mix(in oklch, var(--warning) 15%, transparent); }
|
|
1533
2837
|
.pill.done { color: var(--success); background: color-mix(in oklch, var(--success) 13%, transparent); }
|
|
1534
2838
|
.pill.stalled, .pill.failed { color: var(--destructive); background: color-mix(in oklch, var(--destructive) 14%, transparent); }
|
|
1535
2839
|
.pill.stopped, .pill.queued, .pill.superseded { color: var(--muted-foreground); background: var(--secondary); }
|
|
@@ -1822,9 +3126,11 @@ var GLYPH = {
|
|
|
1822
3126
|
hex:'<path d="M12 2l8 4.6v9.2L12 21l-8-5.2V6.6L12 2z" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linejoin="round"/><circle cx="12" cy="12" r="2" fill="currentColor"/>'
|
|
1823
3127
|
};
|
|
1824
3128
|
var GK = ['claw','spore','eye','hex'];
|
|
1825
|
-
|
|
3129
|
+
/* \u041A\u0430\u0440\u0442\u0430 \u0441\u0442\u0430\u0442\u0443\u0441\u2192[pill,label,color] \u0438 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u043E\u0441\u0442\u044C \u2014 \u0438\u0437 swarm-core (statusTuples/
|
|
3130
|
+
TERMINAL_STATUSES), \u0430 \u043D\u0435 \u0445\u0430\u0440\u0434\u043A\u043E\u0434-\u043A\u043E\u043F\u0438\u044F (\u043E\u043D\u0430 \u0443\u0436\u0435 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u043B\u0430\u0441\u044C: paused/storm_dead). */
|
|
3131
|
+
var ST = ${jsonForScript(statusTuples())};
|
|
1826
3132
|
var ORDER = { running:0, blocked:1, paused:2, storm_dead:3, review:4, stalled:5, queued:6, stopped:7, done:8, failed:9, superseded:10 };
|
|
1827
|
-
var TERM = {
|
|
3133
|
+
var TERM = ${jsonForScript(Object.fromEntries(TERMINAL_STATUSES.map((s) => [s, 1])))};
|
|
1828
3134
|
var EVL = { spawned:'\u0434\u0440\u043E\u043D \u0437\u0430\u043F\u0443\u0449\u0435\u043D', step:'', blocked:'\u0432\u043E\u043F\u0440\u043E\u0441 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0443', answered:'\u043F\u043E\u043B\u0443\u0447\u0435\u043D \u043E\u0442\u0432\u0435\u0442', pr_opened:'PR \u043E\u0442\u043A\u0440\u044B\u0442', merged:'PR \u0441\u043C\u0435\u0440\u0436\u0451\u043D, \u0437\u0430\u0434\u0430\u0447\u0430 \u0437\u0430\u043A\u0440\u044B\u0442\u0430', done:'\u0433\u043E\u0442\u043E\u0432\u043E', failed:'\u0434\u0440\u043E\u043D \u0443\u043F\u0430\u043B', stalled:'\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431', respawned:'\u0430\u0432\u0442\u043E-\u043E\u0436\u0438\u0432\u043B\u0435\u043D\u0438\u0435', paused:'\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D', resumed:'overmind \u0432\u0435\u0440\u043D\u0443\u043B\u0441\u044F \u2014 \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u043B', rescued:'worktree \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D \u2014 \u0437\u0430\u0431\u0435\u0440\u0438 \u0440\u0443\u043A\u0430\u043C\u0438', conflict:'\u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442 \u0441 main (merge-main-in)', died:'\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440', storm_start:'\u26A1 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 \u2014 Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442', storm_end:'\u0441\u0432\u044F\u0437\u044C \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u2014 \u0440\u043E\u0439 \u043F\u0440\u043E\u0431\u0443\u0436\u0434\u0430\u0435\u0442\u0441\u044F', storm_dead:'\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 (\u0448\u0442\u043E\u0440\u043C)', stopped:'\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D', superseded:'\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D' };
|
|
1829
3135
|
var MDOT = { merged:'--success', done:'--success', pr_opened:'--info', spawned:'--muted-foreground', stalled:'--destructive', failed:'--destructive', respawned:'--warning', paused:'--warning', resumed:'--success', rescued:'--destructive', conflict:'--warning', died:'--destructive', storm_start:'--destructive', storm_end:'--success', storm_dead:'--warning', stopped:'--muted-foreground', superseded:'--muted-foreground' };
|
|
1830
3136
|
|
|
@@ -1857,7 +3163,7 @@ function cardStep(d){
|
|
|
1857
3163
|
if(d.status==='stalled') return d.reason || '\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431';
|
|
1858
3164
|
if(d.status==='stopped') return d.stopReason || d.reason || '\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D';
|
|
1859
3165
|
if(d.status==='storm_dead') return '\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 (Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442), worktree \u0446\u0435\u043B, \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043E\u0442\u0431\u043E\u044F';
|
|
1860
|
-
if(d.status==='paused') return '\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430';
|
|
3166
|
+
if(d.status==='paused') return d.stopReason || '\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430'; // TASWM-151: \u0440\u0435\u0441\u0443\u0440\u0441-\u0440\u0430\u0437\u0433\u0440\u0443\u0437\u043A\u0430 \u0437\u0430\u0434\u0430\u0451\u0442 \u0441\u0432\u043E\u0439 stopReason
|
|
1861
3167
|
if(d.status==='review') { var n=prNum(d.pr); return 'PR'+(n?(' #'+n):'')+' \u043D\u0430 \u0440\u0435\u0432\u044C\u044E'; }
|
|
1862
3168
|
if(d.status==='done') return '\u0441\u043C\u0435\u0440\u0436\u0435\u043D';
|
|
1863
3169
|
if(d.status==='queued') return (d.waiting&&d.waiting.length) ? '\u0436\u0434\u0451\u0442: '+d.waiting.join(', ') : '\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430';
|
|
@@ -2326,14 +3632,15 @@ async function runDiff(db2, id, now = Date.now()) {
|
|
|
2326
3632
|
diffCache.set(id, { at: now, payload });
|
|
2327
3633
|
return payload;
|
|
2328
3634
|
}
|
|
2329
|
-
|
|
3635
|
+
var readNarratorLog = (log) => log?.() ?? [];
|
|
3636
|
+
function eventsStream(db2, narratorLog) {
|
|
2330
3637
|
let iv;
|
|
2331
3638
|
let last = "";
|
|
2332
3639
|
const stream = new ReadableStream({
|
|
2333
3640
|
start(controller) {
|
|
2334
3641
|
const enc = new TextEncoder();
|
|
2335
3642
|
const send = () => {
|
|
2336
|
-
const snap = JSON.stringify(roySnapshot(db2));
|
|
3643
|
+
const snap = JSON.stringify(roySnapshot(db2, Date.now(), readNarratorLog(narratorLog)));
|
|
2337
3644
|
if (snap === last) return;
|
|
2338
3645
|
last = snap;
|
|
2339
3646
|
try {
|
|
@@ -2367,11 +3674,11 @@ function makeHandler(deps) {
|
|
|
2367
3674
|
const p = url.pathname;
|
|
2368
3675
|
if (p === "/") {
|
|
2369
3676
|
const store = auth();
|
|
2370
|
-
return html(store ? renderDashboard(store.email, roySnapshot(deps.db), taskUrlBase) : renderLogin());
|
|
3677
|
+
return html(store ? renderDashboard(store.email, roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)), taskUrlBase) : renderLogin());
|
|
2371
3678
|
}
|
|
2372
|
-
if (p === "/events") return eventsStream(deps.db);
|
|
2373
|
-
if (p === "/state") return Response.json(roySnapshot(deps.db));
|
|
2374
|
-
if (p === "/timeline") return Response.json(timelineState(deps.db));
|
|
3679
|
+
if (p === "/events") return eventsStream(deps.db, deps.narratorLog);
|
|
3680
|
+
if (p === "/state") return Response.json(roySnapshot(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
|
|
3681
|
+
if (p === "/timeline") return Response.json(timelineState(deps.db, Date.now(), readNarratorLog(deps.narratorLog)));
|
|
2375
3682
|
const diffMatch = p.match(/^\/run\/(\d+)\/diff$/);
|
|
2376
3683
|
if (diffMatch) {
|
|
2377
3684
|
if (!auth()) return unauthorized();
|
|
@@ -2449,39 +3756,106 @@ function makeHandler(deps) {
|
|
|
2449
3756
|
function startWeb(deps, port = WEB_PORT) {
|
|
2450
3757
|
const handle = makeHandler(deps);
|
|
2451
3758
|
const server = serve({ fetch: handle, port, hostname: "127.0.0.1" });
|
|
3759
|
+
server.on("error", (err) => {
|
|
3760
|
+
const e = err;
|
|
3761
|
+
if (e.code === "EADDRINUSE") {
|
|
3762
|
+
console.error(`\u043F\u043E\u0440\u0442 ${port} \u0437\u0430\u043D\u044F\u0442 \u2014 \u043F\u043E\u0445\u043E\u0436\u0435, overmind \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${port}).`);
|
|
3763
|
+
} else {
|
|
3764
|
+
console.error(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0430\u0441\u044C: ${e.message}`);
|
|
3765
|
+
}
|
|
3766
|
+
process.exit(1);
|
|
3767
|
+
});
|
|
2452
3768
|
return { port, server };
|
|
2453
3769
|
}
|
|
2454
3770
|
|
|
2455
3771
|
// src/cli.ts
|
|
2456
|
-
import { cancelRun, respawnRun, OVERMIND_ENTRY } from "./overmind.js";
|
|
3772
|
+
import { cancelRun, respawnRun, runDaemon, OVERMIND_ENTRY, listDroneProcesses } from "./overmind.js";
|
|
2457
3773
|
|
|
2458
3774
|
// src/launcher.ts
|
|
2459
3775
|
import { spawn } from "child_process";
|
|
2460
|
-
import { readdirSync, writeFileSync as
|
|
3776
|
+
import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync3, openSync, readdirSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
2461
3777
|
import { homedir as homedir2 } from "os";
|
|
2462
3778
|
import { join as join4 } from "path";
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
3779
|
+
var ISOLATION_FLAGS = ["--setting-sources", "project", "--strict-mcp-config"];
|
|
3780
|
+
function claudeArgs(cfgPath, prompt, continueSession = false, model, pluginDirs = []) {
|
|
3781
|
+
const pluginFlags = pluginDirs.flatMap((dir) => ["--plugin-dir", dir]);
|
|
3782
|
+
const base2 = [
|
|
3783
|
+
"--dangerously-skip-permissions",
|
|
3784
|
+
...ISOLATION_FLAGS,
|
|
3785
|
+
"--mcp-config",
|
|
3786
|
+
cfgPath,
|
|
3787
|
+
...pluginFlags,
|
|
3788
|
+
"--output-format",
|
|
3789
|
+
"json",
|
|
3790
|
+
"-p",
|
|
3791
|
+
prompt
|
|
3792
|
+
];
|
|
3793
|
+
const withModel = model ? ["--model", model, ...base2] : base2;
|
|
3794
|
+
return continueSession ? ["--continue", ...withModel] : withModel;
|
|
3795
|
+
}
|
|
3796
|
+
function roleDir(worktree, kind) {
|
|
3797
|
+
return join4(worktree, ".claude", "roles", kind);
|
|
3798
|
+
}
|
|
3799
|
+
function nextRunLogPath(runLogsDir, runId) {
|
|
3800
|
+
const dir = join4(runLogsDir, String(runId));
|
|
3801
|
+
mkdirSync3(dir, { recursive: true });
|
|
3802
|
+
let files = [];
|
|
3803
|
+
try {
|
|
3804
|
+
files = readdirSync(dir);
|
|
3805
|
+
} catch {
|
|
3806
|
+
}
|
|
3807
|
+
let next = 0;
|
|
3808
|
+
for (const f of files) {
|
|
3809
|
+
const m = /^(\d+)\.json/.exec(f);
|
|
3810
|
+
if (m) next = Math.max(next, Number(m[1]) + 1);
|
|
3811
|
+
}
|
|
3812
|
+
return join4(dir, `${next}.json`);
|
|
3813
|
+
}
|
|
3814
|
+
function openNextRunLog(runLogsDir, runId) {
|
|
3815
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
3816
|
+
try {
|
|
3817
|
+
return openSync(nextRunLogPath(runLogsDir, runId), "wx");
|
|
3818
|
+
} catch (e) {
|
|
3819
|
+
if (e.code !== "EEXIST") throw e;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
throw new Error(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0439\u0442\u0438 \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u0439 run-log \u0444\u0430\u0439\u043B \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${runId} (\u0433\u043E\u043D\u043A\u0430?)`);
|
|
2466
3823
|
}
|
|
2467
3824
|
function makeLauncher(cfg) {
|
|
2468
3825
|
return ({ worktree, run, prompt, continueSession }) => {
|
|
3826
|
+
const isCompiled = cfg.overmindEntry.endsWith(".js");
|
|
3827
|
+
if (!isCompiled && !cfg.overmindEntry.endsWith(".ts")) {
|
|
3828
|
+
throw new Error(`overmindEntry \u043D\u0438 .ts, \u043D\u0438 .js: ${cfg.overmindEntry}`);
|
|
3829
|
+
}
|
|
2469
3830
|
const mcpConfig = {
|
|
2470
3831
|
mcpServers: {
|
|
2471
3832
|
overmind: {
|
|
2472
|
-
command: "bun",
|
|
2473
|
-
args: ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
|
|
2474
|
-
|
|
3833
|
+
command: isCompiled ? process.execPath : "bun",
|
|
3834
|
+
args: isCompiled ? [cfg.overmindEntry, "--drone", "--run", String(run.id)] : ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
|
|
3835
|
+
// SWARM_TASK_SLUG (TASWM-211): task_slug зафиксирован ЗДЕСЬ, на спавне — устойчив к
|
|
3836
|
+
// пересозданию локального стора между спавном и вызовами read_task (droneServer.ts
|
|
3837
|
+
// доверяет ему, а не db.getRun(run.id).task_slug, который может протухнуть/переехать).
|
|
3838
|
+
env: { SWARM_DB: cfg.dbPath, SWARM_TASK_SLUG: run.task_slug }
|
|
2475
3839
|
}
|
|
2476
3840
|
}
|
|
2477
3841
|
};
|
|
2478
3842
|
const cfgPath = join4(worktree, ".swarm-mcp.json");
|
|
2479
|
-
|
|
2480
|
-
const
|
|
3843
|
+
writeFileSync4(cfgPath, JSON.stringify(mcpConfig, null, 2));
|
|
3844
|
+
const model = run.model ?? process.env.SWARM_DRONE_MODEL ?? void 0;
|
|
3845
|
+
const dir = roleDir(worktree, run.kind);
|
|
3846
|
+
const pluginDirs = existsSync3(dir) ? [dir] : [];
|
|
3847
|
+
let stdio = "ignore";
|
|
3848
|
+
let outFd;
|
|
3849
|
+
if (cfg.runLogsDir) {
|
|
3850
|
+
outFd = openNextRunLog(cfg.runLogsDir, run.id);
|
|
3851
|
+
stdio = ["ignore", outFd, "ignore"];
|
|
3852
|
+
}
|
|
3853
|
+
const child = spawn(cfg.claudeBin ?? "claude", claudeArgs(cfgPath, prompt, continueSession, model, pluginDirs), {
|
|
2481
3854
|
cwd: worktree,
|
|
2482
3855
|
detached: true,
|
|
2483
|
-
stdio
|
|
3856
|
+
stdio
|
|
2484
3857
|
});
|
|
3858
|
+
if (outFd !== void 0) closeSync(outFd);
|
|
2485
3859
|
child.unref();
|
|
2486
3860
|
if (child.pid == null) throw new Error("\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u043D\u0435\u0442 pid)");
|
|
2487
3861
|
return child.pid;
|
|
@@ -2489,11 +3863,132 @@ function makeLauncher(cfg) {
|
|
|
2489
3863
|
}
|
|
2490
3864
|
|
|
2491
3865
|
// src/git.ts
|
|
2492
|
-
import { $ as $
|
|
3866
|
+
import { $ as $4 } from "execa";
|
|
3867
|
+
|
|
3868
|
+
// src/procCleanup.ts
|
|
3869
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
3870
|
+
function sleep(ms) {
|
|
3871
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
3872
|
+
}
|
|
3873
|
+
function parseLsofCwd(output) {
|
|
3874
|
+
const rows = [];
|
|
3875
|
+
let pid = null;
|
|
3876
|
+
for (const line of output.split("\n")) {
|
|
3877
|
+
const pMatch = /^p(\d+)$/.exec(line);
|
|
3878
|
+
if (pMatch) {
|
|
3879
|
+
pid = Number(pMatch[1]);
|
|
3880
|
+
} else if (line.startsWith("n") && pid != null) {
|
|
3881
|
+
rows.push({ pid, cwd: line.slice(1) });
|
|
3882
|
+
pid = null;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
return rows;
|
|
3886
|
+
}
|
|
3887
|
+
function cwdUnder(cwd, dir) {
|
|
3888
|
+
return cwd === dir || cwd.startsWith(`${dir}/`);
|
|
3889
|
+
}
|
|
3890
|
+
function pidsWithCwdUnder(dir) {
|
|
3891
|
+
let out;
|
|
3892
|
+
try {
|
|
3893
|
+
out = execFileSync2("lsof", ["-a", "-d", "cwd", "-F", "pn"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
3894
|
+
} catch (e) {
|
|
3895
|
+
logTransientSpawnError("lsof", e);
|
|
3896
|
+
return [];
|
|
3897
|
+
}
|
|
3898
|
+
return parseLsofCwd(out).filter((r) => cwdUnder(r.cwd, dir));
|
|
3899
|
+
}
|
|
3900
|
+
function pidAlive(pid) {
|
|
3901
|
+
try {
|
|
3902
|
+
process.kill(pid, 0);
|
|
3903
|
+
return true;
|
|
3904
|
+
} catch (e) {
|
|
3905
|
+
return e.code === "EPERM";
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
async function killPidsGraceful(pids, opts = {}) {
|
|
3909
|
+
const graceMs = opts.graceMs ?? 3e3;
|
|
3910
|
+
const pollMs = opts.pollMs ?? 150;
|
|
3911
|
+
const kill = opts.kill ?? ((pid, signal) => process.kill(pid, signal));
|
|
3912
|
+
const isAlive = opts.isAlive ?? pidAlive;
|
|
3913
|
+
const sleepFn = opts.sleepFn ?? sleep;
|
|
3914
|
+
const signalled = [];
|
|
3915
|
+
for (const pid of pids) {
|
|
3916
|
+
try {
|
|
3917
|
+
kill(pid, "SIGTERM");
|
|
3918
|
+
signalled.push(pid);
|
|
3919
|
+
} catch {
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
if (!signalled.length) return signalled;
|
|
3923
|
+
const deadline = Date.now() + graceMs;
|
|
3924
|
+
while (Date.now() < deadline && signalled.some((pid) => isAlive(pid))) {
|
|
3925
|
+
await sleepFn(pollMs);
|
|
3926
|
+
}
|
|
3927
|
+
for (const pid of signalled) {
|
|
3928
|
+
try {
|
|
3929
|
+
if (isAlive(pid)) kill(pid, "SIGKILL");
|
|
3930
|
+
} catch {
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
return signalled;
|
|
3934
|
+
}
|
|
3935
|
+
function composeProjectPrefix(worktreeDirName) {
|
|
3936
|
+
return worktreeDirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3937
|
+
}
|
|
3938
|
+
function containerBelongsToPrefix(name, prefix) {
|
|
3939
|
+
return name === prefix || name.startsWith(`${prefix}-`);
|
|
3940
|
+
}
|
|
3941
|
+
function dockerContainerNames() {
|
|
3942
|
+
try {
|
|
3943
|
+
return execFileSync2("docker", ["ps", "-a", "--format", "{{.Names}}"], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
3944
|
+
} catch (e) {
|
|
3945
|
+
logTransientSpawnError("docker ps", e);
|
|
3946
|
+
return [];
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
function removeContainers(names) {
|
|
3950
|
+
const removed = [];
|
|
3951
|
+
for (const name of names) {
|
|
3952
|
+
try {
|
|
3953
|
+
execFileSync2("docker", ["rm", "-f", name], { encoding: "utf8" });
|
|
3954
|
+
removed.push(name);
|
|
3955
|
+
} catch (e) {
|
|
3956
|
+
console.warn(`[procCleanup] docker rm -f ${name} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F: ${String(e)}`);
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
return removed;
|
|
3960
|
+
}
|
|
3961
|
+
async function cleanupWorktreeProcesses(worktree, opts = {}) {
|
|
3962
|
+
try {
|
|
3963
|
+
const found = pidsWithCwdUnder(worktree);
|
|
3964
|
+
if (found.length) {
|
|
3965
|
+
const killed = await killPidsGraceful(
|
|
3966
|
+
found.map((f) => f.pid),
|
|
3967
|
+
opts
|
|
3968
|
+
);
|
|
3969
|
+
if (killed.length) console.log(`[procCleanup] worktree ${worktree}: \u043F\u043E\u0433\u0430\u0448\u0435\u043D\u043E \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 ${killed.length} (${killed.join(", ")})`);
|
|
3970
|
+
}
|
|
3971
|
+
} catch (e) {
|
|
3972
|
+
console.error(`[procCleanup] \u0433\u0430\u0448\u0435\u043D\u0438\u0435 \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u043E\u0441\u044C:`, e);
|
|
3973
|
+
}
|
|
3974
|
+
try {
|
|
3975
|
+
const prefix = composeProjectPrefix(worktree.split("/").filter(Boolean).pop() ?? "");
|
|
3976
|
+
if (!prefix) return;
|
|
3977
|
+
const names = dockerContainerNames().filter((n) => containerBelongsToPrefix(n, prefix));
|
|
3978
|
+
if (!names.length) return;
|
|
3979
|
+
const removed = removeContainers(names);
|
|
3980
|
+
if (removed.length) console.log(`[procCleanup] worktree ${worktree}: \u0441\u043D\u0435\u0441\u0435\u043D\u043E docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 ${removed.length} (${removed.join(", ")})`);
|
|
3981
|
+
} catch (e) {
|
|
3982
|
+
console.error(`[procCleanup] \u0441\u043D\u043E\u0441 docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F:`, e);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
// src/git.ts
|
|
2493
3987
|
async function removeWorktree(repo, path) {
|
|
2494
|
-
const branch = (await $
|
|
2495
|
-
await
|
|
2496
|
-
|
|
3988
|
+
const branch = (await $4({ reject: false })`git -C ${path} symbolic-ref --quiet --short HEAD`).stdout.trim();
|
|
3989
|
+
await cleanupWorktreeProcesses(path);
|
|
3990
|
+
await $4({ reject: false })`git -C ${repo} worktree remove --force ${path}`;
|
|
3991
|
+
if (branch) await $4({ reject: false })`git -C ${repo} branch -D ${branch}`;
|
|
2497
3992
|
}
|
|
2498
3993
|
|
|
2499
3994
|
// src/taskless.ts
|
|
@@ -2501,19 +3996,19 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2501
3996
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2502
3997
|
|
|
2503
3998
|
// src/workspace.ts
|
|
2504
|
-
import { readFileSync as
|
|
3999
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, unlinkSync as unlinkSync2 } from "fs";
|
|
2505
4000
|
import { join as join5 } from "path";
|
|
2506
4001
|
var WORKSPACE_PATH = join5(SWARM_HOME, "workspace.json");
|
|
2507
4002
|
function loadWorkspace() {
|
|
2508
4003
|
try {
|
|
2509
|
-
const w = JSON.parse(
|
|
4004
|
+
const w = JSON.parse(readFileSync5(WORKSPACE_PATH, "utf8"));
|
|
2510
4005
|
return w && typeof w.id === "string" && w.id ? w : null;
|
|
2511
4006
|
} catch {
|
|
2512
4007
|
return null;
|
|
2513
4008
|
}
|
|
2514
4009
|
}
|
|
2515
4010
|
function saveWorkspace(w) {
|
|
2516
|
-
|
|
4011
|
+
writeFileSync5(WORKSPACE_PATH, JSON.stringify(w, null, 2), { mode: 384 });
|
|
2517
4012
|
}
|
|
2518
4013
|
function clearWorkspace() {
|
|
2519
4014
|
try {
|
|
@@ -2543,7 +4038,7 @@ async function connectSwarmMcp(cfg) {
|
|
|
2543
4038
|
}
|
|
2544
4039
|
if (!token) token = TASKLESS_TOKEN;
|
|
2545
4040
|
if (!token) return null;
|
|
2546
|
-
const url = new URL(
|
|
4041
|
+
const url = new URL(mcpEndpoint(api));
|
|
2547
4042
|
const transport = new StreamableHTTPClientTransport(url, {
|
|
2548
4043
|
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
2549
4044
|
});
|
|
@@ -2633,7 +4128,189 @@ async function openBrowser(url) {
|
|
|
2633
4128
|
}
|
|
2634
4129
|
}
|
|
2635
4130
|
|
|
4131
|
+
// src/screenshot.ts
|
|
4132
|
+
var DESKTOP_VIEWPORT = { name: "desktop", width: 1360, height: 940 };
|
|
4133
|
+
var MOBILE_VIEWPORT = { name: "mobile", width: 390, height: 844 };
|
|
4134
|
+
var DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
|
|
4135
|
+
var DEFAULT_SHOT_TIMEOUT_MS = 2e4;
|
|
4136
|
+
async function captureScreenshots(launchBrowser, opts) {
|
|
4137
|
+
const viewports = opts.viewports ?? DEFAULT_VIEWPORTS;
|
|
4138
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SHOT_TIMEOUT_MS;
|
|
4139
|
+
const fullPage = opts.fullPage ?? true;
|
|
4140
|
+
const browser = await launchBrowser();
|
|
4141
|
+
try {
|
|
4142
|
+
const results = [];
|
|
4143
|
+
for (const vp of viewports) {
|
|
4144
|
+
const ctx = await browser.newContext({
|
|
4145
|
+
viewport: { width: vp.width, height: vp.height },
|
|
4146
|
+
...opts.storageStatePath ? { storageState: opts.storageStatePath } : {}
|
|
4147
|
+
});
|
|
4148
|
+
try {
|
|
4149
|
+
const page = await ctx.newPage();
|
|
4150
|
+
await page.goto(opts.url, { timeout: timeoutMs, waitUntil: "load" });
|
|
4151
|
+
const path = `${opts.outPrefix}-${vp.name}.png`;
|
|
4152
|
+
await page.screenshot({ path, fullPage, timeout: timeoutMs });
|
|
4153
|
+
results.push({ viewport: vp.name, path });
|
|
4154
|
+
} finally {
|
|
4155
|
+
await ctx.close();
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
return results;
|
|
4159
|
+
} finally {
|
|
4160
|
+
await browser.close();
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
async function launchChromium(opts = {}) {
|
|
4164
|
+
let mod;
|
|
4165
|
+
try {
|
|
4166
|
+
mod = await import("playwright-core");
|
|
4167
|
+
} catch {
|
|
4168
|
+
throw new Error(
|
|
4169
|
+
"playwright-core \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438 \u043E\u0434\u0438\u043D \u0440\u0430\u0437: (\u0432 \u044D\u0442\u043E\u043C \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u043E) pnpm --filter @taskless-app/swarm add -D playwright-core; (\u043E\u0442\u0434\u0435\u043B\u044C\u043D\u043E) npm i -D playwright-core \u2014 \u0438 \u0432 \u043E\u0431\u043E\u0438\u0445 \u0441\u043B\u0443\u0447\u0430\u044F\u0445 npx playwright install chromium."
|
|
4170
|
+
);
|
|
4171
|
+
}
|
|
4172
|
+
return mod.chromium.launch({ headless: true, ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {} });
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// src/shotCli.ts
|
|
4176
|
+
function isFlagValue(v) {
|
|
4177
|
+
return v !== void 0 && !v.startsWith("--");
|
|
4178
|
+
}
|
|
4179
|
+
function parseShotArgs(argv) {
|
|
4180
|
+
const url = argv[0];
|
|
4181
|
+
const outPrefix = argv[1];
|
|
4182
|
+
if (!url || !outPrefix) return null;
|
|
4183
|
+
let timeoutMs = SWARM_SHOT_TIMEOUT_MS;
|
|
4184
|
+
let storageStatePath = null;
|
|
4185
|
+
for (let i = 2; i < argv.length; i++) {
|
|
4186
|
+
if (argv[i] === "--timeout" && isFlagValue(argv[i + 1])) {
|
|
4187
|
+
const n = Number(argv[i + 1]);
|
|
4188
|
+
if (Number.isFinite(n) && n >= 0) timeoutMs = n;
|
|
4189
|
+
i++;
|
|
4190
|
+
} else if (argv[i] === "--storage-state" && isFlagValue(argv[i + 1])) {
|
|
4191
|
+
storageStatePath = argv[i + 1];
|
|
4192
|
+
i++;
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
return { url, outPrefix, timeoutMs, storageStatePath };
|
|
4196
|
+
}
|
|
4197
|
+
var VIEWPORTS_COUNT = DEFAULT_VIEWPORTS.length;
|
|
4198
|
+
var LAUNCH_BUDGET_MS = 15e3;
|
|
4199
|
+
async function runShotCli(argv, deps = {}) {
|
|
4200
|
+
const log = deps.log ?? ((l) => console.log(l));
|
|
4201
|
+
const error = deps.error ?? ((l) => console.error(l));
|
|
4202
|
+
const args = parseShotArgs(argv);
|
|
4203
|
+
if (!args) {
|
|
4204
|
+
error("\u043D\u0443\u0436\u043D\u043E: swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]");
|
|
4205
|
+
return 1;
|
|
4206
|
+
}
|
|
4207
|
+
const launchBudgetMs = deps.launchBudgetMs ?? LAUNCH_BUDGET_MS;
|
|
4208
|
+
const launched = { browser: null };
|
|
4209
|
+
const trackedLaunch = async () => {
|
|
4210
|
+
const launch = deps.launchBrowser ?? (() => launchChromium({ timeoutMs: launchBudgetMs }));
|
|
4211
|
+
const browser = await launch();
|
|
4212
|
+
launched.browser = browser;
|
|
4213
|
+
return browser;
|
|
4214
|
+
};
|
|
4215
|
+
const budgetMs = args.timeoutMs * VIEWPORTS_COUNT + launchBudgetMs;
|
|
4216
|
+
let timer;
|
|
4217
|
+
const watchdog = new Promise((_, reject) => {
|
|
4218
|
+
timer = setTimeout(
|
|
4219
|
+
() => reject(new Error(`watchdog ${budgetMs}\u043C\u0441 \u0438\u0441\u0442\u0451\u043A \u2014 \u0447\u0442\u043E-\u0442\u043E \u0437\u0430\u0432\u0438\u0441\u043B\u043E \u0432\u043D\u0435 per-viewport \u0442\u0430\u0439\u043C\u0430\u0443\u0442\u0430`)),
|
|
4220
|
+
budgetMs
|
|
4221
|
+
);
|
|
4222
|
+
});
|
|
4223
|
+
try {
|
|
4224
|
+
const results = await Promise.race([
|
|
4225
|
+
captureScreenshots(trackedLaunch, {
|
|
4226
|
+
url: args.url,
|
|
4227
|
+
outPrefix: args.outPrefix,
|
|
4228
|
+
timeoutMs: args.timeoutMs,
|
|
4229
|
+
storageStatePath: args.storageStatePath
|
|
4230
|
+
}),
|
|
4231
|
+
watchdog
|
|
4232
|
+
]);
|
|
4233
|
+
for (const r of results) log(`${r.viewport} \u2192 ${r.path}`);
|
|
4234
|
+
return 0;
|
|
4235
|
+
} catch (e) {
|
|
4236
|
+
error(`swarm shot: ${e instanceof Error ? e.message : String(e)}`);
|
|
4237
|
+
return 1;
|
|
4238
|
+
} finally {
|
|
4239
|
+
clearTimeout(timer);
|
|
4240
|
+
await launched.browser?.close().catch(() => {
|
|
4241
|
+
});
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
// package.json
|
|
4246
|
+
var package_default = {
|
|
4247
|
+
name: "@taskless-app/swarm",
|
|
4248
|
+
version: "0.2.2",
|
|
4249
|
+
type: "module",
|
|
4250
|
+
description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
|
|
4251
|
+
keywords: [
|
|
4252
|
+
"ai-agents",
|
|
4253
|
+
"coding-agents",
|
|
4254
|
+
"swarm",
|
|
4255
|
+
"orchestration",
|
|
4256
|
+
"autonomous-agents",
|
|
4257
|
+
"mcp",
|
|
4258
|
+
"taskless",
|
|
4259
|
+
"cli"
|
|
4260
|
+
],
|
|
4261
|
+
license: "SEE LICENSE IN LICENSE",
|
|
4262
|
+
author: "Ilya Smurov <ilya@smurov.com>",
|
|
4263
|
+
homepage: "https://github.com/ilyasmurov/taskless/tree/main/packages/swarm-daemon#readme",
|
|
4264
|
+
repository: {
|
|
4265
|
+
type: "git",
|
|
4266
|
+
url: "git+https://github.com/ilyasmurov/taskless.git",
|
|
4267
|
+
directory: "packages/swarm-daemon"
|
|
4268
|
+
},
|
|
4269
|
+
engines: {
|
|
4270
|
+
node: ">=22"
|
|
4271
|
+
},
|
|
4272
|
+
bin: {
|
|
4273
|
+
swarm: "dist/cli.js"
|
|
4274
|
+
},
|
|
4275
|
+
files: [
|
|
4276
|
+
"dist",
|
|
4277
|
+
"docs/HERO.md"
|
|
4278
|
+
],
|
|
4279
|
+
scripts: {
|
|
4280
|
+
overmind: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/overmind.ts',
|
|
4281
|
+
swarm: 'NODE_OPTIONS="${NODE_OPTIONS:-} --conditions=development" tsx src/cli.ts',
|
|
4282
|
+
build: "tsup",
|
|
4283
|
+
start: "node dist/overmind.js",
|
|
4284
|
+
prepublishOnly: "pnpm run build",
|
|
4285
|
+
test: "vitest run && SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
|
|
4286
|
+
"test:client": "SWARM_TEST_BACKEND=client vitest run src/overmind.test.ts src/overmind.storm.test.ts src/db.storm.test.ts",
|
|
4287
|
+
"test:watch": "vitest",
|
|
4288
|
+
typecheck: "tsc -p tsconfig.json --noEmit",
|
|
4289
|
+
lint: "echo 'no lint in swarm-daemon yet'"
|
|
4290
|
+
},
|
|
4291
|
+
dependencies: {
|
|
4292
|
+
"@hono/node-server": "^2.0.10",
|
|
4293
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
4294
|
+
execa: "^10.0.0",
|
|
4295
|
+
zod: "3.25.76"
|
|
4296
|
+
},
|
|
4297
|
+
optionalDependencies: {
|
|
4298
|
+
"better-sqlite3": "^12.11.1"
|
|
4299
|
+
},
|
|
4300
|
+
devDependencies: {
|
|
4301
|
+
"@taskless/swarm-core": "workspace:*",
|
|
4302
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
4303
|
+
"@types/node": "22.10.5",
|
|
4304
|
+
"playwright-core": "1.49.1",
|
|
4305
|
+
tsup: "^8.5.1",
|
|
4306
|
+
tsx: "4.19.2",
|
|
4307
|
+
typescript: "5.7.3",
|
|
4308
|
+
vitest: "2.1.8"
|
|
4309
|
+
}
|
|
4310
|
+
};
|
|
4311
|
+
|
|
2636
4312
|
// src/cli.ts
|
|
4313
|
+
var PACKAGE_VERSION = package_default.version;
|
|
2637
4314
|
function takeRepo(argv) {
|
|
2638
4315
|
const i = argv.indexOf("--repo");
|
|
2639
4316
|
if (i === -1) return { repo: null, rest: argv };
|
|
@@ -2687,7 +4364,7 @@ async function runLogin(db2, opts) {
|
|
|
2687
4364
|
loadStore,
|
|
2688
4365
|
beforeToken: null,
|
|
2689
4366
|
now: Date.now,
|
|
2690
|
-
sleep,
|
|
4367
|
+
sleep: sleep2,
|
|
2691
4368
|
timeoutMs: 5 * 6e4,
|
|
2692
4369
|
pollMs: 1e3
|
|
2693
4370
|
});
|
|
@@ -2716,7 +4393,7 @@ async function runLogin(db2, opts) {
|
|
|
2716
4393
|
if (!chosen) return 1;
|
|
2717
4394
|
saveWorkspace(toWorkspaceSel(chosen));
|
|
2718
4395
|
console.log(`\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${chosen.name} (${chosen.slug}) \u2192 ${SWARM_HOME}/workspace.json`);
|
|
2719
|
-
console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \
|
|
4396
|
+
console.log("\u0433\u043E\u0442\u043E\u0432\u043E. \u041F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: swarm start --repo <\u043F\u0443\u0442\u044C \u043A \u0440\u0435\u043F\u043E>");
|
|
2720
4397
|
return 0;
|
|
2721
4398
|
} finally {
|
|
2722
4399
|
web.server.close();
|
|
@@ -2737,14 +4414,16 @@ var STATUS_MARK = {
|
|
|
2737
4414
|
function fmtRun(r) {
|
|
2738
4415
|
const mark = STATUS_MARK[r.status] ?? "?";
|
|
2739
4416
|
const area = r.area ? ` [${r.area}]` : "";
|
|
2740
|
-
const repo = r.repo ? ` (${
|
|
4417
|
+
const repo = r.repo ? ` (${basename3(r.repo)})` : "";
|
|
2741
4418
|
const q = r.status === "blocked" ? " \u2190 \u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430" : "";
|
|
2742
4419
|
const why = r.status === "stopped" && r.stop_reason ? ` \u2014 ${r.stop_reason}` : "";
|
|
2743
4420
|
return `${mark} #${r.id} ${r.name} ${r.task_slug}${repo}${area} ${r.status}${q}${why}`;
|
|
2744
4421
|
}
|
|
2745
4422
|
function usage() {
|
|
2746
|
-
console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432
|
|
4423
|
+
console.log(`swarm \u2014 \u0440\u043E\u0439 \u0434\u0440\u043E\u043D\u043E\u0432 (v${PACKAGE_VERSION})
|
|
2747
4424
|
|
|
4425
|
+
swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
4426
|
+
swarm start [--repo <path>] \u043F\u043E\u0434\u043D\u044F\u0442\u044C \u0434\u0435\u043C\u043E\u043D\u0430: \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0435\u0442 \u043E\u0447\u0435\u0440\u0435\u0434\u044C, \u0441\u043F\u0430\u0432\u043D\u0438\u0442/\u0431\u0443\u0434\u0438\u0442 \u0434\u0440\u043E\u043D\u043E\u0432
|
|
2748
4427
|
swarm ls [status] \u0441\u043F\u0438\u0441\u043E\u043A \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 (\u043E\u043F\u0446. \u0444\u0438\u043B\u044C\u0442\u0440 \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0443)
|
|
2749
4428
|
swarm spawn <slug> [area] [--repo <path>] [--after <s1,s2>] \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u044C (--after \u2192 \u0436\u0434\u0430\u0442\u044C done \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0435\u0439)
|
|
2750
4429
|
swarm spawn <slug> --hero [--name HYDRA] [--repo <path>] [--after <s1,s2>] \u0413\u0435\u0440\u043E\u0439-\u043B\u0435\u0439\u0442\u0435\u043D\u0430\u043D\u0442 \u044D\u043F\u0438\u043A\u0430
|
|
@@ -2754,14 +4433,98 @@ function usage() {
|
|
|
2754
4433
|
swarm cancel <slug> <\u043F\u0440\u0438\u0447\u0438\u043D\u0430\u2026> \u043E\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u0434\u0440\u043E\u043D\u0430 (\u0441\u0442\u0430\u0442\u0443\u0441 \xAB\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\xBB + \u043F\u0440\u0438\u0447\u0438\u043D\u0430; \u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u043E\u0441\u0442\u0430\u0451\u0442\u0441\u044F)
|
|
2755
4434
|
swarm import-db <path> --repo <path> \u0438\u043C\u043F\u043E\u0440\u0442 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u043E\u0439 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E)
|
|
2756
4435
|
swarm log <runId> \u043B\u043E\u0433 \u043F\u0440\u043E\u0433\u043E\u043D\u0430
|
|
2757
|
-
swarm login [--workspace <slug|id>] [--no-open] \u0432\u0445\u043E\u0434 \u0432 \u043F\u0440\u043E\u0434 + \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
2758
4436
|
swarm logout \u0437\u0430\u0431\u044B\u0442\u044C \u0442\u043E\u043A\u0435\u043D\u044B \u0438 \u0432\u044B\u0431\u043E\u0440 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0430
|
|
2759
4437
|
swarm whoami \u043A\u0442\u043E \u0432\u043E\u0448\u0451\u043B \u0438 \u0432 \u043A\u0430\u043A\u043E\u043C \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u0435
|
|
2760
4438
|
swarm web \u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430 (\u0432\u0445\u043E\u0434 + \u0441\u0442\u0430\u0442\u0443\u0441) \u043D\u0430 127.0.0.1
|
|
4439
|
+
swarm shot <url> <out-prefix> [--timeout <ms>] [--storage-state <path>]
|
|
4440
|
+
headless-\u0441\u043A\u0440\u0438\u043D desktop+mobile \u041E\u0414\u041D\u041E\u0419 \u043A\u043E\u043C\u0430\u043D\u0434\u043E\u0439 (TASWM-40):
|
|
4441
|
+
<out-prefix>-desktop.png, <out-prefix>-mobile.png
|
|
4442
|
+
swarm --version \u0432\u0435\u0440\u0441\u0438\u044F \u043F\u0430\u043A\u0435\u0442\u0430
|
|
2761
4443
|
`);
|
|
2762
4444
|
}
|
|
2763
|
-
|
|
4445
|
+
async function makeCliStore(repo) {
|
|
4446
|
+
if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
|
|
4447
|
+
const failing = async () => ({ isError: true });
|
|
4448
|
+
const [call, repoRegistry] = await Promise.all([
|
|
4449
|
+
connectSwarmMcp().then((c) => c ?? failing),
|
|
4450
|
+
buildRepoRegistry(SWARM_REPO_PATHS)
|
|
4451
|
+
]);
|
|
4452
|
+
const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo, repoRegistry });
|
|
4453
|
+
await client.hydrate();
|
|
4454
|
+
client.reconcileRuntime({ worktreesDir: WORKTREES_DIR, listProcesses: listDroneProcesses });
|
|
4455
|
+
return client;
|
|
4456
|
+
}
|
|
4457
|
+
async function finishClient(db2) {
|
|
4458
|
+
if (!(db2 instanceof SwarmClient)) return;
|
|
4459
|
+
await db2.flush();
|
|
4460
|
+
db2.close();
|
|
4461
|
+
process.exit(0);
|
|
4462
|
+
}
|
|
2764
4463
|
var [cmd, ...rawArgs] = process.argv.slice(2);
|
|
4464
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
4465
|
+
console.log(PACKAGE_VERSION);
|
|
4466
|
+
process.exit(0);
|
|
4467
|
+
}
|
|
4468
|
+
if (cmd === "logout") {
|
|
4469
|
+
clearStore();
|
|
4470
|
+
clearWorkspace();
|
|
4471
|
+
console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
|
|
4472
|
+
process.exit(0);
|
|
4473
|
+
}
|
|
4474
|
+
if (cmd === "whoami") {
|
|
4475
|
+
const store = loadStore();
|
|
4476
|
+
if (!store) {
|
|
4477
|
+
console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
|
|
4478
|
+
process.exit(0);
|
|
4479
|
+
}
|
|
4480
|
+
const ws = loadWorkspace();
|
|
4481
|
+
console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
|
|
4482
|
+
console.log(
|
|
4483
|
+
ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
|
|
4484
|
+
);
|
|
4485
|
+
process.exit(0);
|
|
4486
|
+
}
|
|
4487
|
+
if (cmd === "import-db") {
|
|
4488
|
+
const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
|
|
4489
|
+
const path = args[0];
|
|
4490
|
+
const repo = repoFlag ?? SWARM_REPO;
|
|
4491
|
+
if (!path || !repo) {
|
|
4492
|
+
console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
|
|
4493
|
+
process.exit(1);
|
|
4494
|
+
}
|
|
4495
|
+
const sdb = new SwarmDb(DB_PATH, repo);
|
|
4496
|
+
const res = sdb.importDb(path, repo);
|
|
4497
|
+
if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
|
|
4498
|
+
else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
|
|
4499
|
+
sdb.close();
|
|
4500
|
+
process.exit(0);
|
|
4501
|
+
}
|
|
4502
|
+
if (cmd === "start") {
|
|
4503
|
+
if (SWARM_BACKEND !== "client") {
|
|
4504
|
+
console.warn("SWARM_BACKEND=client \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u0434\u0435\u043C\u043E\u043D \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C sqlite-\u0440\u0435\u0436\u0438\u043C\u0435, \u0411\u0415\u0417 \u0441\u0432\u044F\u0437\u0438 \u0441 \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441\u043E\u043C Taskless (\u043D\u0435 \u0443\u0432\u0438\u0434\u0438\u0442 \u0437\u0430\u0434\u0430\u0447\u0438, \u043D\u0435 \u043F\u043E\u0448\u043B\u0451\u0442 \u043F\u0443\u043B\u044C\u0441).");
|
|
4505
|
+
}
|
|
4506
|
+
const { repo: repoFlag } = takeRepo(rawArgs);
|
|
4507
|
+
const repo = repoFlag ?? SWARM_REPO;
|
|
4508
|
+
if (!repo) {
|
|
4509
|
+
console.warn("SWARM_REPO \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u043F\u0440\u043E\u0433\u043E\u043D\u044B \u0431\u0435\u0437 \u044F\u0432\u043D\u043E\u0433\u043E repo \u043D\u0435 \u0437\u0430\u043F\u0443\u0441\u0442\u044F\u0442\u0441\u044F (\u043D\u0443\u0436\u0435\u043D spawn --repo).");
|
|
4510
|
+
}
|
|
4511
|
+
try {
|
|
4512
|
+
await runDaemon({ repo });
|
|
4513
|
+
} catch (e) {
|
|
4514
|
+
console.error(`\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0441\u044F: ${e instanceof Error ? e.message : e}`);
|
|
4515
|
+
process.exit(1);
|
|
4516
|
+
}
|
|
4517
|
+
process.exit(0);
|
|
4518
|
+
}
|
|
4519
|
+
if (cmd === "shot") {
|
|
4520
|
+
const code = await runShotCli(rawArgs);
|
|
4521
|
+
process.exit(code);
|
|
4522
|
+
}
|
|
4523
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
4524
|
+
usage();
|
|
4525
|
+
process.exit(0);
|
|
4526
|
+
}
|
|
4527
|
+
var db = await makeCliStore(SWARM_REPO);
|
|
2765
4528
|
switch (cmd) {
|
|
2766
4529
|
case "ls": {
|
|
2767
4530
|
const runs = db.listRuns(rawArgs[0]);
|
|
@@ -2812,7 +4575,7 @@ switch (cmd) {
|
|
|
2812
4575
|
console.error(`\u043F\u0440\u043E\u0433\u043E\u043D #${runId} \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D (running) \u2014 \u0431\u0443\u0434\u0438\u0442\u044C \u043D\u0435\u0447\u0435\u0433\u043E`);
|
|
2813
4576
|
process.exit(1);
|
|
2814
4577
|
}
|
|
2815
|
-
const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH });
|
|
4578
|
+
const launch = makeLauncher({ overmindEntry: OVERMIND_ENTRY, dbPath: DB_PATH, runLogsDir: RUN_LOGS_DIR });
|
|
2816
4579
|
await respawnRun(
|
|
2817
4580
|
{ db, launch, repoFallback: SWARM_REPO, worktreesDir: WORKTREES_DIR, getTask: () => Promise.resolve(null) },
|
|
2818
4581
|
run,
|
|
@@ -2821,19 +4584,6 @@ switch (cmd) {
|
|
|
2821
4584
|
console.log(`\u0440\u0430\u0437\u0431\u0443\u0436\u0435\u043D (--continue): ${fmtRun(db.getRun(runId))}`);
|
|
2822
4585
|
break;
|
|
2823
4586
|
}
|
|
2824
|
-
case "import-db": {
|
|
2825
|
-
const { repo: repoFlag, rest: args } = takeRepo(rawArgs);
|
|
2826
|
-
const path = args[0];
|
|
2827
|
-
const repo = repoFlag ?? SWARM_REPO;
|
|
2828
|
-
if (!path || !repo) {
|
|
2829
|
-
console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
|
|
2830
|
-
process.exit(1);
|
|
2831
|
-
}
|
|
2832
|
-
const res = db.importDb(path, repo);
|
|
2833
|
-
if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
|
|
2834
|
-
else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);
|
|
2835
|
-
break;
|
|
2836
|
-
}
|
|
2837
4587
|
case "questions": {
|
|
2838
4588
|
const qs = db.openQuestions();
|
|
2839
4589
|
if (qs.length === 0) console.log("\u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0432\u043E\u043F\u0440\u043E\u0441\u043E\u0432 \u043D\u0435\u0442");
|
|
@@ -2887,7 +4637,7 @@ switch (cmd) {
|
|
|
2887
4637
|
process.exit(1);
|
|
2888
4638
|
}
|
|
2889
4639
|
const run = db.getRun(runId);
|
|
2890
|
-
if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${
|
|
4640
|
+
if (run) console.log(`#${run.id} ${run.name} ${run.task_slug}${run.repo ? ` (${basename3(run.repo)})` : ""}`);
|
|
2891
4641
|
const events = db.events(runId);
|
|
2892
4642
|
if (events.length === 0) console.log("\u0441\u043E\u0431\u044B\u0442\u0438\u0439 \u043D\u0435\u0442");
|
|
2893
4643
|
else events.forEach((e) => console.log(`${e.at} ${e.kind}${e.text ? " " + e.text : ""}`));
|
|
@@ -2900,25 +4650,6 @@ switch (cmd) {
|
|
|
2900
4650
|
db.close();
|
|
2901
4651
|
process.exit(code);
|
|
2902
4652
|
}
|
|
2903
|
-
case "logout": {
|
|
2904
|
-
clearStore();
|
|
2905
|
-
clearWorkspace();
|
|
2906
|
-
console.log("\u0432\u044B\u0448\u0435\u043B: \u0443\u0434\u0430\u043B\u0435\u043D\u044B oauth.json \u0438 workspace.json (DCR-\u043A\u043B\u0438\u0435\u043D\u0442 \u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D \u0434\u043B\u044F \u0441\u043B\u0435\u0434. \u0432\u0445\u043E\u0434\u0430).");
|
|
2907
|
-
break;
|
|
2908
|
-
}
|
|
2909
|
-
case "whoami": {
|
|
2910
|
-
const store = loadStore();
|
|
2911
|
-
if (!store) {
|
|
2912
|
-
console.log("\u043D\u0435 \u0432 \u0441\u0435\u0441\u0441\u0438\u0438. \u0412\u043E\u0439\u0442\u0438: swarm login");
|
|
2913
|
-
break;
|
|
2914
|
-
}
|
|
2915
|
-
const ws = loadWorkspace();
|
|
2916
|
-
console.log(`\u0432\u043E\u0448\u0451\u043B \u043A\u0430\u043A ${store.email ?? "?"}`);
|
|
2917
|
-
console.log(
|
|
2918
|
-
ws ? `\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: ${ws.name ?? ws.slug ?? ws.id} (${ws.slug ?? ws.id})` : "\u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441: \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D (\u0441\u0435\u0440\u0432\u0435\u0440 \u043F\u0440\u0438\u043C\u0435\u043D\u0438\u0442 \u0444\u043E\u043B\u0431\u044D\u043A) \u2014 \u0432\u044B\u0431\u0435\u0440\u0438: swarm login"
|
|
2919
|
-
);
|
|
2920
|
-
break;
|
|
2921
|
-
}
|
|
2922
4653
|
case "web": {
|
|
2923
4654
|
const web = startWeb({ db });
|
|
2924
4655
|
console.log(`\u0432\u0435\u0431-\u043C\u043E\u0440\u0434\u0430: http://127.0.0.1:${web.port} \u2190 \u043E\u0442\u043A\u0440\u043E\u0439 \u0438 \u0432\u043E\u0439\u0434\u0438 \u0447\u0435\u0440\u0435\u0437 Taskless`);
|
|
@@ -2929,4 +4660,5 @@ switch (cmd) {
|
|
|
2929
4660
|
default:
|
|
2930
4661
|
usage();
|
|
2931
4662
|
}
|
|
4663
|
+
await finishClient(db);
|
|
2932
4664
|
db.close();
|