@frumu/tandem-panel 0.4.0 → 0.4.3
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/.env.example +20 -1
- package/README.md +44 -15
- package/bin/cli.js +134 -0
- package/bin/init-env.js +10 -84
- package/bin/service-local.sh +63 -0
- package/bin/service-runner.js +48 -0
- package/bin/setup.js +3412 -263
- package/dist/assets/index-4aX9RGDL.css +1 -0
- package/dist/assets/index-CpH1IYO9.js +22 -0
- package/dist/assets/markdown-DMcD1LHz.js +60 -0
- package/dist/assets/motion-BCvrfAt1.js +9 -0
- package/dist/assets/preact-vendor-jo0muZ28.js +1 -0
- package/dist/assets/react-query-PgFuErlI.js +1 -0
- package/dist/assets/vendor-CdeM8LjL.js +42 -0
- package/dist/index.html +7 -2
- package/package.json +15 -5
- package/dist/assets/index-DV9yLSAS.js +0 -1680
- package/dist/assets/index-DVs8ZgEj.css +0 -1
package/bin/setup.js
CHANGED
|
@@ -4,14 +4,15 @@ import { spawn } from "child_process";
|
|
|
4
4
|
import { createServer } from "http";
|
|
5
5
|
import { readFileSync, existsSync, createReadStream, createWriteStream } from "fs";
|
|
6
6
|
import { mkdir, readdir, stat, rm, readFile, writeFile } from "fs/promises";
|
|
7
|
-
import { randomBytes } from "crypto";
|
|
8
|
-
import { join, dirname, extname, normalize, resolve, basename } from "path";
|
|
7
|
+
import { createHash, randomBytes } from "crypto";
|
|
8
|
+
import { join, dirname, extname, normalize, resolve, basename, relative } from "path";
|
|
9
9
|
import { Transform } from "stream";
|
|
10
10
|
import { pipeline } from "stream/promises";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
12
|
import { createRequire } from "module";
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
-
import {
|
|
14
|
+
import { ensureBootstrapEnv, resolveEnvLoadOrder } from "../lib/setup/env.js";
|
|
15
|
+
import { createSwarmApiHandler } from "../server/routes/swarm.js";
|
|
15
16
|
|
|
16
17
|
function parseDotEnv(content) {
|
|
17
18
|
const out = {};
|
|
@@ -22,7 +23,10 @@ function parseDotEnv(content) {
|
|
|
22
23
|
if (idx <= 0) continue;
|
|
23
24
|
const key = line.slice(0, idx).trim();
|
|
24
25
|
let value = line.slice(idx + 1).trim();
|
|
25
|
-
if (
|
|
26
|
+
if (
|
|
27
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
28
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
29
|
+
) {
|
|
26
30
|
value = value.slice(1, -1);
|
|
27
31
|
}
|
|
28
32
|
out[key] = value;
|
|
@@ -79,34 +83,56 @@ const cli = parseCliArgs(process.argv.slice(2));
|
|
|
79
83
|
const rawArgs = process.argv.slice(2);
|
|
80
84
|
const initRequested = cli.has("init");
|
|
81
85
|
const resetTokenRequested = cli.has("reset-token");
|
|
86
|
+
const explicitEnvFile = String(cli.value("env-file") || "").trim();
|
|
82
87
|
const installServicesRequested = cli.has("install-services");
|
|
88
|
+
const serviceOpRaw = String(cli.value("service-op") || "")
|
|
89
|
+
.trim()
|
|
90
|
+
.toLowerCase();
|
|
91
|
+
const serviceOp = ["status", "start", "stop", "restart", "enable", "disable", "logs"].includes(
|
|
92
|
+
serviceOpRaw
|
|
93
|
+
)
|
|
94
|
+
? serviceOpRaw
|
|
95
|
+
: "";
|
|
83
96
|
const serviceModeRaw = String(cli.value("service-mode") || "both")
|
|
84
97
|
.trim()
|
|
85
98
|
.toLowerCase();
|
|
86
99
|
const serviceMode = ["both", "engine", "panel"].includes(serviceModeRaw) ? serviceModeRaw : "both";
|
|
87
100
|
const serviceUserArg = String(cli.value("service-user") || "").trim();
|
|
88
|
-
const serviceSetupOnly =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
const serviceSetupOnly =
|
|
102
|
+
rawArgs.length > 0 &&
|
|
103
|
+
rawArgs.every((arg) => {
|
|
104
|
+
if (arg === "--install-services") return true;
|
|
105
|
+
if (arg.startsWith("--service-op")) return true;
|
|
106
|
+
if (arg.startsWith("--service-mode")) return true;
|
|
107
|
+
if (arg.startsWith("--service-user")) return true;
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
94
110
|
const cwdEnvPath = resolve(process.cwd(), ".env");
|
|
111
|
+
for (const envPath of resolveEnvLoadOrder({ explicitEnvFile, cwd: process.cwd() })) {
|
|
112
|
+
loadDotEnvFile(envPath);
|
|
113
|
+
}
|
|
95
114
|
|
|
96
115
|
if (initRequested) {
|
|
97
|
-
const result =
|
|
116
|
+
const result = await ensureBootstrapEnv({
|
|
117
|
+
cwd: process.cwd(),
|
|
118
|
+
envPath: explicitEnvFile || undefined,
|
|
119
|
+
overwrite: resetTokenRequested,
|
|
120
|
+
});
|
|
98
121
|
console.log("[Tandem Control Panel] Environment initialized.");
|
|
99
122
|
console.log(`[Tandem Control Panel] .env: ${result.envPath}`);
|
|
100
123
|
console.log(`[Tandem Control Panel] Engine URL: ${result.engineUrl}`);
|
|
101
|
-
console.log(
|
|
124
|
+
console.log(
|
|
125
|
+
`[Tandem Control Panel] Panel URL: http://${result.panelHost}:${result.panelPort}`
|
|
126
|
+
);
|
|
102
127
|
console.log(`[Tandem Control Panel] Token: ${result.token}`);
|
|
103
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
process.argv.slice(2).length === 1 ||
|
|
130
|
+
(process.argv.slice(2).length === 2 && resetTokenRequested)
|
|
131
|
+
) {
|
|
104
132
|
process.exit(0);
|
|
105
133
|
}
|
|
106
134
|
}
|
|
107
135
|
|
|
108
|
-
loadDotEnvFile(cwdEnvPath);
|
|
109
|
-
|
|
110
136
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
111
137
|
const DIST_DIR = join(__dirname, "..", "dist");
|
|
112
138
|
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
|
|
@@ -125,9 +151,20 @@ function resolveDefaultChannelUploadsRoot() {
|
|
|
125
151
|
}
|
|
126
152
|
|
|
127
153
|
const PORTAL_PORT = Number.parseInt(process.env.TANDEM_CONTROL_PANEL_PORT || "39732", 10);
|
|
154
|
+
const PANEL_HOST = (process.env.TANDEM_CONTROL_PANEL_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
|
155
|
+
const PANEL_PUBLIC_URL = String(process.env.TANDEM_CONTROL_PANEL_PUBLIC_URL || "").trim();
|
|
128
156
|
const ENGINE_HOST = (process.env.TANDEM_ENGINE_HOST || "127.0.0.1").trim();
|
|
129
157
|
const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 10);
|
|
130
|
-
const ENGINE_URL = (
|
|
158
|
+
const ENGINE_URL = (
|
|
159
|
+
process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
|
|
160
|
+
).replace(/\/+$/, "");
|
|
161
|
+
const SWARM_RUNS_PATH = resolve(homedir(), ".tandem", "control-panel", "swarm-runs.json");
|
|
162
|
+
const SWARM_HIDDEN_RUNS_PATH = resolve(
|
|
163
|
+
homedir(),
|
|
164
|
+
".tandem",
|
|
165
|
+
"control-panel",
|
|
166
|
+
"swarm-hidden-runs.json"
|
|
167
|
+
);
|
|
131
168
|
const AUTO_START_ENGINE = (process.env.TANDEM_CONTROL_PANEL_AUTO_START_ENGINE || "1") !== "0";
|
|
132
169
|
const CONFIGURED_ENGINE_TOKEN = (
|
|
133
170
|
process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
|
|
@@ -136,7 +173,9 @@ const CONFIGURED_ENGINE_TOKEN = (
|
|
|
136
173
|
).trim();
|
|
137
174
|
const SESSION_TTL_MS =
|
|
138
175
|
Number.parseInt(process.env.TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES || "1440", 10) * 60 * 1000;
|
|
139
|
-
const FILES_ROOT = resolve(
|
|
176
|
+
const FILES_ROOT = resolve(
|
|
177
|
+
process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot()
|
|
178
|
+
);
|
|
140
179
|
const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "control-panel")
|
|
141
180
|
.trim()
|
|
142
181
|
.replace(/\\/g, "/")
|
|
@@ -144,11 +183,30 @@ const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "cont
|
|
|
144
183
|
.replace(/\/+$/, "");
|
|
145
184
|
const MAX_UPLOAD_BYTES = Math.max(
|
|
146
185
|
1,
|
|
147
|
-
Number.parseInt(
|
|
148
|
-
250 * 1024 * 1024
|
|
186
|
+
Number.parseInt(
|
|
187
|
+
process.env.TANDEM_CONTROL_PANEL_MAX_UPLOAD_BYTES || `${250 * 1024 * 1024}`,
|
|
188
|
+
10
|
|
189
|
+
) || 250 * 1024 * 1024
|
|
149
190
|
);
|
|
150
191
|
const require = createRequire(import.meta.url);
|
|
151
192
|
const SETUP_ENTRYPOINT = fileURLToPath(import.meta.url);
|
|
193
|
+
const CONTROL_PANEL_PACKAGE = (() => {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
196
|
+
} catch {
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
})();
|
|
200
|
+
const CONTROL_PANEL_VERSION = String(CONTROL_PANEL_PACKAGE?.version || "0.0.0").trim() || "0.0.0";
|
|
201
|
+
const CONTROL_PANEL_BUILD_FINGERPRINT = (() => {
|
|
202
|
+
try {
|
|
203
|
+
const source = readFileSync(SETUP_ENTRYPOINT);
|
|
204
|
+
const digest = createHash("sha1").update(source).digest("hex").slice(0, 8);
|
|
205
|
+
return `${CONTROL_PANEL_VERSION}-${digest}`;
|
|
206
|
+
} catch {
|
|
207
|
+
return `${CONTROL_PANEL_VERSION}-unknown`;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
152
210
|
|
|
153
211
|
const log = (msg) => console.log(`[Tandem Control Panel] ${msg}`);
|
|
154
212
|
const err = (msg) => console.error(`[Tandem Control Panel] ERROR: ${msg}`);
|
|
@@ -190,10 +248,137 @@ const swarmState = {
|
|
|
190
248
|
objective: "",
|
|
191
249
|
workspaceRoot: REPO_ROOT,
|
|
192
250
|
maxTasks: 3,
|
|
251
|
+
maxAgents: 3,
|
|
252
|
+
workflowId: "swarm.blackboard.default",
|
|
253
|
+
modelProvider: "",
|
|
254
|
+
modelId: "",
|
|
255
|
+
mcpServers: [],
|
|
256
|
+
repoRoot: "",
|
|
257
|
+
preflight: {
|
|
258
|
+
gitAvailable: null,
|
|
259
|
+
repoReady: true,
|
|
260
|
+
autoInitialized: false,
|
|
261
|
+
code: "workspace_ready",
|
|
262
|
+
reason: "",
|
|
263
|
+
guidance: "",
|
|
264
|
+
},
|
|
193
265
|
lastError: "",
|
|
266
|
+
executorState: "idle",
|
|
267
|
+
executorReason: "",
|
|
268
|
+
executorMode: "context_steps",
|
|
269
|
+
resolvedModelProvider: "",
|
|
270
|
+
resolvedModelId: "",
|
|
271
|
+
modelResolutionSource: "none",
|
|
272
|
+
verificationMode: "strict",
|
|
273
|
+
runId: "",
|
|
274
|
+
attachedPid: null,
|
|
275
|
+
buildVersion: CONTROL_PANEL_VERSION,
|
|
276
|
+
buildFingerprint: CONTROL_PANEL_BUILD_FINGERPRINT,
|
|
277
|
+
buildStartedAt: Date.now(),
|
|
194
278
|
};
|
|
279
|
+
const swarmRunControllers = new Map();
|
|
195
280
|
const swarmSseClients = new Set();
|
|
196
281
|
|
|
282
|
+
function createSwarmRunController(runId = "", overrides = {}) {
|
|
283
|
+
return {
|
|
284
|
+
status: "idle",
|
|
285
|
+
startedAt: null,
|
|
286
|
+
stoppedAt: null,
|
|
287
|
+
objective: "",
|
|
288
|
+
workspaceRoot: REPO_ROOT,
|
|
289
|
+
maxTasks: 3,
|
|
290
|
+
maxAgents: 3,
|
|
291
|
+
workflowId: "swarm.blackboard.default",
|
|
292
|
+
modelProvider: "",
|
|
293
|
+
modelId: "",
|
|
294
|
+
mcpServers: [],
|
|
295
|
+
repoRoot: "",
|
|
296
|
+
lastError: "",
|
|
297
|
+
executorState: "idle",
|
|
298
|
+
executorReason: "",
|
|
299
|
+
executorMode: "context_steps",
|
|
300
|
+
resolvedModelProvider: "",
|
|
301
|
+
resolvedModelId: "",
|
|
302
|
+
modelResolutionSource: "none",
|
|
303
|
+
verificationMode: "strict",
|
|
304
|
+
runId: String(runId || "").trim(),
|
|
305
|
+
attachedPid: null,
|
|
306
|
+
registryCache: null,
|
|
307
|
+
reasons: [],
|
|
308
|
+
logs: [],
|
|
309
|
+
...overrides,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function syncLegacySwarmState(controller) {
|
|
314
|
+
if (!controller || typeof controller !== "object") return;
|
|
315
|
+
swarmState.status = controller.status || swarmState.status;
|
|
316
|
+
swarmState.startedAt = controller.startedAt ?? swarmState.startedAt;
|
|
317
|
+
swarmState.stoppedAt = controller.stoppedAt ?? swarmState.stoppedAt;
|
|
318
|
+
swarmState.objective = controller.objective || swarmState.objective;
|
|
319
|
+
swarmState.workspaceRoot = controller.workspaceRoot || swarmState.workspaceRoot;
|
|
320
|
+
swarmState.maxTasks = controller.maxTasks ?? swarmState.maxTasks;
|
|
321
|
+
swarmState.maxAgents = controller.maxAgents ?? swarmState.maxAgents;
|
|
322
|
+
swarmState.workflowId = controller.workflowId || swarmState.workflowId;
|
|
323
|
+
swarmState.modelProvider = controller.modelProvider || swarmState.modelProvider;
|
|
324
|
+
swarmState.modelId = controller.modelId || swarmState.modelId;
|
|
325
|
+
swarmState.mcpServers = Array.isArray(controller.mcpServers)
|
|
326
|
+
? controller.mcpServers
|
|
327
|
+
: swarmState.mcpServers;
|
|
328
|
+
swarmState.repoRoot = controller.repoRoot || swarmState.repoRoot;
|
|
329
|
+
swarmState.lastError = controller.lastError || "";
|
|
330
|
+
swarmState.executorState = controller.executorState || swarmState.executorState;
|
|
331
|
+
swarmState.executorReason = controller.executorReason || "";
|
|
332
|
+
swarmState.executorMode = controller.executorMode || swarmState.executorMode;
|
|
333
|
+
swarmState.resolvedModelProvider =
|
|
334
|
+
controller.resolvedModelProvider || swarmState.resolvedModelProvider;
|
|
335
|
+
swarmState.resolvedModelId = controller.resolvedModelId || swarmState.resolvedModelId;
|
|
336
|
+
swarmState.modelResolutionSource =
|
|
337
|
+
controller.modelResolutionSource || swarmState.modelResolutionSource;
|
|
338
|
+
swarmState.verificationMode = controller.verificationMode || swarmState.verificationMode;
|
|
339
|
+
swarmState.runId = controller.runId || swarmState.runId;
|
|
340
|
+
swarmState.attachedPid = controller.attachedPid || null;
|
|
341
|
+
swarmState.registryCache = controller.registryCache || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getSwarmRunController(runId = "") {
|
|
345
|
+
const key = String(runId || "").trim();
|
|
346
|
+
if (!key) return null;
|
|
347
|
+
return swarmRunControllers.get(key) || null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function upsertSwarmRunController(runId = "", patch = {}) {
|
|
351
|
+
const key = String(runId || "").trim();
|
|
352
|
+
if (!key) return null;
|
|
353
|
+
const current = getSwarmRunController(key) || createSwarmRunController(key);
|
|
354
|
+
const next = {
|
|
355
|
+
...current,
|
|
356
|
+
...patch,
|
|
357
|
+
runId: key,
|
|
358
|
+
};
|
|
359
|
+
swarmRunControllers.set(key, next);
|
|
360
|
+
if (String(swarmState.runId || "").trim() === key) {
|
|
361
|
+
syncLegacySwarmState(next);
|
|
362
|
+
}
|
|
363
|
+
return next;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function setActiveSwarmRunId(runId = "") {
|
|
367
|
+
const key = String(runId || "").trim();
|
|
368
|
+
swarmState.runId = key;
|
|
369
|
+
if (!key) {
|
|
370
|
+
swarmState.status = "idle";
|
|
371
|
+
swarmState.executorState = "idle";
|
|
372
|
+
swarmState.executorReason = "";
|
|
373
|
+
swarmState.lastError = "";
|
|
374
|
+
swarmState.attachedPid = null;
|
|
375
|
+
swarmState.registryCache = null;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const controller = getSwarmRunController(key);
|
|
379
|
+
if (controller) syncLegacySwarmState(controller);
|
|
380
|
+
}
|
|
381
|
+
|
|
197
382
|
const sleep = (ms) => new Promise((resolveFn) => setTimeout(resolveFn, ms));
|
|
198
383
|
|
|
199
384
|
function shellEscape(token) {
|
|
@@ -207,6 +392,7 @@ function runCmd(bin, args = [], options = {}) {
|
|
|
207
392
|
const child = spawn(bin, args, {
|
|
208
393
|
stdio: options.stdio || "pipe",
|
|
209
394
|
env: options.env || process.env,
|
|
395
|
+
cwd: options.cwd || undefined,
|
|
210
396
|
});
|
|
211
397
|
let stdout = "";
|
|
212
398
|
let stderr = "";
|
|
@@ -231,6 +417,279 @@ function runCmd(bin, args = [], options = {}) {
|
|
|
231
417
|
});
|
|
232
418
|
}
|
|
233
419
|
|
|
420
|
+
async function loadSwarmRunsHistory() {
|
|
421
|
+
try {
|
|
422
|
+
const raw = await readFile(SWARM_RUNS_PATH, "utf8");
|
|
423
|
+
const parsed = JSON.parse(raw);
|
|
424
|
+
if (!Array.isArray(parsed?.runs)) return [];
|
|
425
|
+
return parsed.runs
|
|
426
|
+
.filter((row) => row && typeof row === "object")
|
|
427
|
+
.map((row) => ({
|
|
428
|
+
runId: String(row.runId || "").trim(),
|
|
429
|
+
objective: String(row.objective || "").trim(),
|
|
430
|
+
workspaceRoot: String(row.workspaceRoot || "").trim(),
|
|
431
|
+
status: String(row.status || "unknown").trim(),
|
|
432
|
+
startedAt: Number(row.startedAt || 0) || 0,
|
|
433
|
+
stoppedAt: Number(row.stoppedAt || 0) || 0,
|
|
434
|
+
pid: Number(row.pid || 0) || null,
|
|
435
|
+
attached: row.attached === true,
|
|
436
|
+
}))
|
|
437
|
+
.filter((row) => row.runId || row.workspaceRoot || row.pid);
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function saveSwarmRunsHistory(runs = []) {
|
|
444
|
+
const payload = JSON.stringify(
|
|
445
|
+
{ version: 1, updatedAtMs: Date.now(), runs: runs.slice(-100) },
|
|
446
|
+
null,
|
|
447
|
+
2
|
|
448
|
+
);
|
|
449
|
+
await mkdir(dirname(SWARM_RUNS_PATH), { recursive: true });
|
|
450
|
+
await writeFile(SWARM_RUNS_PATH, payload, "utf8");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function loadHiddenSwarmRunIds() {
|
|
454
|
+
try {
|
|
455
|
+
const raw = await readFile(SWARM_HIDDEN_RUNS_PATH, "utf8");
|
|
456
|
+
const parsed = JSON.parse(raw);
|
|
457
|
+
const ids = Array.isArray(parsed?.runIds) ? parsed.runIds : [];
|
|
458
|
+
return new Set(
|
|
459
|
+
ids
|
|
460
|
+
.map((id) => String(id || "").trim())
|
|
461
|
+
.filter(Boolean)
|
|
462
|
+
.slice(0, 5000)
|
|
463
|
+
);
|
|
464
|
+
} catch {
|
|
465
|
+
return new Set();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function saveHiddenSwarmRunIds(runIdSet) {
|
|
470
|
+
const runIds = Array.from(runIdSet)
|
|
471
|
+
.map((id) => String(id || "").trim())
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.sort((a, b) => a.localeCompare(b));
|
|
474
|
+
await mkdir(dirname(SWARM_HIDDEN_RUNS_PATH), { recursive: true });
|
|
475
|
+
await writeFile(
|
|
476
|
+
SWARM_HIDDEN_RUNS_PATH,
|
|
477
|
+
JSON.stringify({ updatedAt: Date.now(), runIds }, null, 2),
|
|
478
|
+
"utf8"
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function recordSwarmRun(update = {}) {
|
|
483
|
+
try {
|
|
484
|
+
const runId = String(update.runId || "").trim() || randomBytes(8).toString("hex");
|
|
485
|
+
const runs = await loadSwarmRunsHistory();
|
|
486
|
+
const idx = runs.findIndex((row) => row.runId === runId);
|
|
487
|
+
const next = {
|
|
488
|
+
runId,
|
|
489
|
+
objective: String(update.objective || "").trim(),
|
|
490
|
+
workspaceRoot: String(update.workspaceRoot || "").trim(),
|
|
491
|
+
status: String(update.status || "unknown").trim(),
|
|
492
|
+
startedAt: Number(update.startedAt || 0) || Date.now(),
|
|
493
|
+
stoppedAt: Number(update.stoppedAt || 0) || 0,
|
|
494
|
+
pid: Number(update.pid || 0) || null,
|
|
495
|
+
attached: update.attached === true,
|
|
496
|
+
};
|
|
497
|
+
if (idx >= 0) runs[idx] = { ...runs[idx], ...next };
|
|
498
|
+
else runs.push(next);
|
|
499
|
+
await saveSwarmRunsHistory(runs);
|
|
500
|
+
return runId;
|
|
501
|
+
} catch {
|
|
502
|
+
return String(update.runId || "").trim();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function buildGitSafeEnv(baseEnv, safeDirectory) {
|
|
507
|
+
const env = { ...(baseEnv || process.env) };
|
|
508
|
+
const value = String(safeDirectory || "*").trim() || "*";
|
|
509
|
+
env.GIT_CONFIG_COUNT = "1";
|
|
510
|
+
env.GIT_CONFIG_KEY_0 = "safe.directory";
|
|
511
|
+
env.GIT_CONFIG_VALUE_0 = value;
|
|
512
|
+
return env;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function runGit(args = [], options = {}) {
|
|
516
|
+
return runCmd("git", args, {
|
|
517
|
+
...options,
|
|
518
|
+
env: buildGitSafeEnv(options.env || process.env, options.safeDirectory),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function guidanceForGitInstall() {
|
|
523
|
+
if (process.platform === "darwin") {
|
|
524
|
+
return "Install Git: `xcode-select --install` or `brew install git`.";
|
|
525
|
+
}
|
|
526
|
+
if (process.platform === "win32") {
|
|
527
|
+
return "Install Git for Windows from https://git-scm.com/download/win and restart the app.";
|
|
528
|
+
}
|
|
529
|
+
return "Install Git using your package manager (for example `sudo apt install git`) and restart the app.";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function setSwarmPreflight(patch = {}) {
|
|
533
|
+
swarmState.preflight = {
|
|
534
|
+
gitAvailable: swarmState.preflight?.gitAvailable ?? null,
|
|
535
|
+
repoReady: swarmState.preflight?.repoReady ?? false,
|
|
536
|
+
autoInitialized: swarmState.preflight?.autoInitialized ?? false,
|
|
537
|
+
code: swarmState.preflight?.code || "",
|
|
538
|
+
reason: swarmState.preflight?.reason || "",
|
|
539
|
+
guidance: swarmState.preflight?.guidance || "",
|
|
540
|
+
...patch,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function detectGitAvailable() {
|
|
545
|
+
try {
|
|
546
|
+
await runGit(["--version"], { stdio: "pipe" });
|
|
547
|
+
return true;
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function isGitRepo(cwd) {
|
|
554
|
+
try {
|
|
555
|
+
const out = await runGit(["-C", String(cwd || ""), "rev-parse", "--show-toplevel"], {
|
|
556
|
+
stdio: "pipe",
|
|
557
|
+
safeDirectory: "*",
|
|
558
|
+
});
|
|
559
|
+
const root = String(out.stdout || "").trim();
|
|
560
|
+
return root ? { ok: true, root, error: "" } : { ok: false, root: "", error: "" };
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return { ok: false, root: "", error: String(error?.message || error || "") };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function bootstrapEmptyGitRepo(workspaceRoot) {
|
|
567
|
+
await runGit(["init"], { cwd: workspaceRoot, stdio: "pipe", safeDirectory: workspaceRoot });
|
|
568
|
+
const readmePath = join(workspaceRoot, "README.md");
|
|
569
|
+
const ignorePath = join(workspaceRoot, ".gitignore");
|
|
570
|
+
if (!existsSync(readmePath)) {
|
|
571
|
+
await writeFile(
|
|
572
|
+
readmePath,
|
|
573
|
+
"# Swarm Workspace\n\nInitialized automatically for Tandem Swarm.\n",
|
|
574
|
+
"utf8"
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (!existsSync(ignorePath)) {
|
|
578
|
+
await writeFile(ignorePath, "node_modules/\n.DS_Store\n.swarm/worktrees/\n", "utf8");
|
|
579
|
+
}
|
|
580
|
+
await runGit(["add", "."], { cwd: workspaceRoot, stdio: "pipe", safeDirectory: workspaceRoot });
|
|
581
|
+
try {
|
|
582
|
+
await runGit(["commit", "-m", "Initialize swarm workspace"], {
|
|
583
|
+
cwd: workspaceRoot,
|
|
584
|
+
stdio: "pipe",
|
|
585
|
+
safeDirectory: workspaceRoot,
|
|
586
|
+
});
|
|
587
|
+
} catch (commitError) {
|
|
588
|
+
const message = String(commitError?.message || "");
|
|
589
|
+
if (!message.includes("Author identity unknown")) throw commitError;
|
|
590
|
+
await runGit(["config", "user.name", "Swarm Bootstrap"], {
|
|
591
|
+
cwd: workspaceRoot,
|
|
592
|
+
stdio: "pipe",
|
|
593
|
+
safeDirectory: workspaceRoot,
|
|
594
|
+
});
|
|
595
|
+
await runGit(["config", "user.email", "swarm@local"], {
|
|
596
|
+
cwd: workspaceRoot,
|
|
597
|
+
stdio: "pipe",
|
|
598
|
+
safeDirectory: workspaceRoot,
|
|
599
|
+
});
|
|
600
|
+
await runGit(["commit", "-m", "Initialize swarm workspace"], {
|
|
601
|
+
cwd: workspaceRoot,
|
|
602
|
+
stdio: "pipe",
|
|
603
|
+
safeDirectory: workspaceRoot,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function preflightSwarmWorkspace(workspaceRoot, options = {}) {
|
|
609
|
+
const allowInitNonEmpty = options.allowInitNonEmpty === true;
|
|
610
|
+
const guidance = guidanceForGitInstall();
|
|
611
|
+
const gitAvailable = await detectGitAvailable();
|
|
612
|
+
if (!gitAvailable) {
|
|
613
|
+
return {
|
|
614
|
+
gitAvailable,
|
|
615
|
+
repoReady: false,
|
|
616
|
+
autoInitialized: false,
|
|
617
|
+
code: "git_missing",
|
|
618
|
+
repoRoot: "",
|
|
619
|
+
reason: "Git executable not found",
|
|
620
|
+
guidance,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const normalized = resolve(workspaceRoot);
|
|
625
|
+
if (!existsSync(normalized)) {
|
|
626
|
+
throw new Error(`Workspace root does not exist: ${normalized}`);
|
|
627
|
+
}
|
|
628
|
+
const details = await stat(normalized);
|
|
629
|
+
if (!details.isDirectory()) {
|
|
630
|
+
throw new Error(`Workspace root is not a directory: ${normalized}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const repo = await isGitRepo(normalized);
|
|
634
|
+
if (repo.ok) {
|
|
635
|
+
return {
|
|
636
|
+
gitAvailable: true,
|
|
637
|
+
repoReady: true,
|
|
638
|
+
autoInitialized: false,
|
|
639
|
+
code: "ok",
|
|
640
|
+
repoRoot: repo.root,
|
|
641
|
+
reason: "",
|
|
642
|
+
guidance,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const repoErrorText = String(repo.error || "");
|
|
646
|
+
const repoErrorLower = repoErrorText.toLowerCase();
|
|
647
|
+
if (
|
|
648
|
+
repoErrorText &&
|
|
649
|
+
!repoErrorLower.includes("not a git repository") &&
|
|
650
|
+
!repoErrorLower.includes("needed a single revision")
|
|
651
|
+
) {
|
|
652
|
+
return {
|
|
653
|
+
gitAvailable: true,
|
|
654
|
+
repoReady: false,
|
|
655
|
+
autoInitialized: false,
|
|
656
|
+
code: "git_probe_failed",
|
|
657
|
+
repoRoot: "",
|
|
658
|
+
reason: `Git could not inspect the selected directory: ${repoErrorText}`,
|
|
659
|
+
guidance,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const entries = await readdir(normalized);
|
|
664
|
+
if (entries.length > 0 && !allowInitNonEmpty) {
|
|
665
|
+
const detail = repoErrorText ? ` Git probe output: ${repoErrorText}` : "";
|
|
666
|
+
return {
|
|
667
|
+
gitAvailable: true,
|
|
668
|
+
repoReady: false,
|
|
669
|
+
autoInitialized: false,
|
|
670
|
+
code: "not_repo_non_empty",
|
|
671
|
+
repoRoot: "",
|
|
672
|
+
reason: `Selected directory is not a Git repository and is not empty: ${normalized}. Choose an existing repo or an empty directory.${detail}`,
|
|
673
|
+
guidance,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await bootstrapEmptyGitRepo(normalized);
|
|
678
|
+
const initializedRepo = await isGitRepo(normalized);
|
|
679
|
+
if (!initializedRepo.ok) {
|
|
680
|
+
throw new Error(`Git repository initialization failed for ${normalized}`);
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
gitAvailable: true,
|
|
684
|
+
repoReady: true,
|
|
685
|
+
autoInitialized: true,
|
|
686
|
+
code: allowInitNonEmpty ? "auto_initialized_non_empty" : "auto_initialized_empty",
|
|
687
|
+
repoRoot: initializedRepo.root,
|
|
688
|
+
reason: "",
|
|
689
|
+
guidance,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
234
693
|
async function installServices() {
|
|
235
694
|
if (process.platform !== "linux") {
|
|
236
695
|
throw new Error("--install-services currently supports Linux/systemd only.");
|
|
@@ -239,7 +698,8 @@ async function installServices() {
|
|
|
239
698
|
throw new Error("Service installation needs root privileges. Re-run with sudo.");
|
|
240
699
|
}
|
|
241
700
|
|
|
242
|
-
const serviceUser =
|
|
701
|
+
const serviceUser =
|
|
702
|
+
serviceUserArg || String(process.env.SUDO_USER || process.env.USER || "root").trim();
|
|
243
703
|
if (!serviceUser) throw new Error("Could not determine service user.");
|
|
244
704
|
const serviceGroup = serviceUser;
|
|
245
705
|
const installEngine = serviceMode === "both" || serviceMode === "engine";
|
|
@@ -252,7 +712,9 @@ async function installServices() {
|
|
|
252
712
|
const engineBin = String(process.env.TANDEM_ENGINE_BIN || "tandem-engine").trim();
|
|
253
713
|
const token =
|
|
254
714
|
CONFIGURED_ENGINE_TOKEN ||
|
|
255
|
-
(existsSync(engineEnvPath)
|
|
715
|
+
(existsSync(engineEnvPath)
|
|
716
|
+
? parseDotEnv(readFileSync(engineEnvPath, "utf8")).TANDEM_API_TOKEN || ""
|
|
717
|
+
: "") ||
|
|
256
718
|
`tk_${randomBytes(16).toString("hex")}`;
|
|
257
719
|
|
|
258
720
|
await mkdir("/etc/tandem", { recursive: true });
|
|
@@ -263,17 +725,17 @@ async function installServices() {
|
|
|
263
725
|
log(`Warning: could not chown ${stateDir} to ${serviceUser}:${serviceGroup}: ${e.message}`);
|
|
264
726
|
}
|
|
265
727
|
|
|
266
|
-
const existingEngineEnv = existsSync(engineEnvPath)
|
|
728
|
+
const existingEngineEnv = existsSync(engineEnvPath)
|
|
729
|
+
? parseDotEnv(readFileSync(engineEnvPath, "utf8"))
|
|
730
|
+
: {};
|
|
267
731
|
const engineEnv = {
|
|
268
732
|
...existingEngineEnv,
|
|
269
733
|
TANDEM_API_TOKEN: token,
|
|
270
734
|
TANDEM_STATE_DIR: stateDir,
|
|
271
735
|
TANDEM_MEMORY_DB_PATH: existingEngineEnv.TANDEM_MEMORY_DB_PATH || `${stateDir}/memory.sqlite`,
|
|
272
736
|
TANDEM_ENABLE_GLOBAL_MEMORY: existingEngineEnv.TANDEM_ENABLE_GLOBAL_MEMORY || "1",
|
|
273
|
-
TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
|
|
274
|
-
|
|
275
|
-
TANDEM_TOOL_ROUTER_ENABLED:
|
|
276
|
-
existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
737
|
+
TANDEM_DISABLE_TOOL_GUARD_BUDGETS: existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
|
|
738
|
+
TANDEM_TOOL_ROUTER_ENABLED: existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
277
739
|
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
|
|
278
740
|
existingEngineEnv.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
|
|
279
741
|
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
|
|
@@ -282,8 +744,7 @@ async function installServices() {
|
|
|
282
744
|
existingEngineEnv.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
|
|
283
745
|
TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
|
|
284
746
|
existingEngineEnv.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
|
|
285
|
-
TANDEM_TOOL_EXEC_TIMEOUT_MS:
|
|
286
|
-
existingEngineEnv.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
747
|
+
TANDEM_TOOL_EXEC_TIMEOUT_MS: existingEngineEnv.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
287
748
|
TANDEM_BASH_TIMEOUT_MS: existingEngineEnv.TANDEM_BASH_TIMEOUT_MS || "30000",
|
|
288
749
|
};
|
|
289
750
|
const engineEnvBody = Object.entries(engineEnv)
|
|
@@ -293,7 +754,9 @@ async function installServices() {
|
|
|
293
754
|
await runCmd("chmod", ["640", engineEnvPath]);
|
|
294
755
|
|
|
295
756
|
const panelAutoStart = serviceMode === "panel" ? "1" : "0";
|
|
296
|
-
const existingPanelEnv = existsSync(panelEnvPath)
|
|
757
|
+
const existingPanelEnv = existsSync(panelEnvPath)
|
|
758
|
+
? parseDotEnv(readFileSync(panelEnvPath, "utf8"))
|
|
759
|
+
: {};
|
|
297
760
|
const panelEnv = {
|
|
298
761
|
...existingPanelEnv,
|
|
299
762
|
TANDEM_CONTROL_PANEL_PORT: String(PORTAL_PORT),
|
|
@@ -365,10 +828,14 @@ WantedBy=multi-user.target
|
|
|
365
828
|
|
|
366
829
|
await runCmd("systemctl", ["daemon-reload"], { stdio: "inherit" });
|
|
367
830
|
if (installEngine) {
|
|
368
|
-
await runCmd("systemctl", ["enable", "--now", `${engineServiceName}.service`], {
|
|
831
|
+
await runCmd("systemctl", ["enable", "--now", `${engineServiceName}.service`], {
|
|
832
|
+
stdio: "inherit",
|
|
833
|
+
});
|
|
369
834
|
}
|
|
370
835
|
if (installPanel) {
|
|
371
|
-
await runCmd("systemctl", ["enable", "--now", `${panelServiceName}.service`], {
|
|
836
|
+
await runCmd("systemctl", ["enable", "--now", `${panelServiceName}.service`], {
|
|
837
|
+
stdio: "inherit",
|
|
838
|
+
});
|
|
372
839
|
}
|
|
373
840
|
|
|
374
841
|
log("Services installed.");
|
|
@@ -381,6 +848,45 @@ WantedBy=multi-user.target
|
|
|
381
848
|
log(`Token: ${token}`);
|
|
382
849
|
}
|
|
383
850
|
|
|
851
|
+
function selectedServiceUnits(mode) {
|
|
852
|
+
const normalized = String(mode || "both")
|
|
853
|
+
.trim()
|
|
854
|
+
.toLowerCase();
|
|
855
|
+
if (normalized === "engine") return ["tandem-engine.service"];
|
|
856
|
+
if (normalized === "panel") return ["tandem-control-panel.service"];
|
|
857
|
+
return ["tandem-engine.service", "tandem-control-panel.service"];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function operateServices(operation, mode) {
|
|
861
|
+
const op = String(operation || "")
|
|
862
|
+
.trim()
|
|
863
|
+
.toLowerCase();
|
|
864
|
+
if (!op) return;
|
|
865
|
+
if (process.platform !== "linux") {
|
|
866
|
+
throw new Error("--service-op currently supports Linux/systemd only.");
|
|
867
|
+
}
|
|
868
|
+
const units = selectedServiceUnits(mode);
|
|
869
|
+
if (op === "logs") {
|
|
870
|
+
const args = units
|
|
871
|
+
.flatMap((unit) => ["-u", unit])
|
|
872
|
+
.concat(["-n", "120", "-f", "-o", "short-iso"]);
|
|
873
|
+
await runCmd("journalctl", args, { stdio: "inherit" });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (op === "status") {
|
|
877
|
+
await runCmd("systemctl", ["--no-pager", "--full", "status", ...units], { stdio: "inherit" });
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!["start", "stop", "restart", "enable", "disable"].includes(op)) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
"Invalid --service-op. Expected one of: status,start,stop,restart,enable,disable,logs"
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
for (const unit of units) {
|
|
886
|
+
await runCmd("systemctl", [op, unit], { stdio: "inherit" });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
384
890
|
function isLocalEngineUrl(url) {
|
|
385
891
|
try {
|
|
386
892
|
const u = new URL(url);
|
|
@@ -472,7 +978,9 @@ function pushSwarmEvent(kind, payload = {}) {
|
|
|
472
978
|
}
|
|
473
979
|
|
|
474
980
|
function appendSwarmLog(stream, text) {
|
|
475
|
-
const lines = String(text || "")
|
|
981
|
+
const lines = String(text || "")
|
|
982
|
+
.split(/\r?\n/)
|
|
983
|
+
.filter(Boolean);
|
|
476
984
|
for (const line of lines) {
|
|
477
985
|
swarmState.logs.push({ at: Date.now(), stream, line });
|
|
478
986
|
if (swarmState.logs.length > 800) swarmState.logs.shift();
|
|
@@ -480,78 +988,6 @@ function appendSwarmLog(stream, text) {
|
|
|
480
988
|
}
|
|
481
989
|
}
|
|
482
990
|
|
|
483
|
-
function appendSwarmReason(reason) {
|
|
484
|
-
const item = { at: Date.now(), ...reason };
|
|
485
|
-
swarmState.reasons.push(item);
|
|
486
|
-
if (swarmState.reasons.length > 500) swarmState.reasons.shift();
|
|
487
|
-
pushSwarmEvent("reason", item);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function compareRegistryTransitions(previousRegistry, nextRegistry) {
|
|
491
|
-
const prevTasks = previousRegistry?.tasks || {};
|
|
492
|
-
const nextTasks = nextRegistry?.tasks || {};
|
|
493
|
-
const transitions = [];
|
|
494
|
-
|
|
495
|
-
for (const [taskId, next] of Object.entries(nextTasks)) {
|
|
496
|
-
const prev = prevTasks[taskId];
|
|
497
|
-
if (!prev) {
|
|
498
|
-
transitions.push({
|
|
499
|
-
kind: "task_transition",
|
|
500
|
-
taskId,
|
|
501
|
-
from: "new",
|
|
502
|
-
to: next.status || "unknown",
|
|
503
|
-
role: next.ownerRole || "",
|
|
504
|
-
reason: next.statusReason || "task registered",
|
|
505
|
-
});
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
if ((prev.status || "") !== (next.status || "")) {
|
|
509
|
-
transitions.push({
|
|
510
|
-
kind: "task_transition",
|
|
511
|
-
taskId,
|
|
512
|
-
from: prev.status || "unknown",
|
|
513
|
-
to: next.status || "unknown",
|
|
514
|
-
role: next.ownerRole || "",
|
|
515
|
-
reason: next.statusReason || `${prev.status || "unknown"} -> ${next.status || "unknown"}`,
|
|
516
|
-
});
|
|
517
|
-
} else if ((prev.statusReason || "") !== (next.statusReason || "") && next.statusReason) {
|
|
518
|
-
transitions.push({
|
|
519
|
-
kind: "task_reason",
|
|
520
|
-
taskId,
|
|
521
|
-
from: next.status || "unknown",
|
|
522
|
-
to: next.status || "unknown",
|
|
523
|
-
role: next.ownerRole || "",
|
|
524
|
-
reason: next.statusReason,
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return transitions;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function monitorSwarmRegistry(token) {
|
|
533
|
-
try {
|
|
534
|
-
const latest = await readSwarmRegistry(token);
|
|
535
|
-
const latestValue = latest?.value || { tasks: {} };
|
|
536
|
-
const previous = swarmState.registryCache?.value || { tasks: {} };
|
|
537
|
-
const transitions = compareRegistryTransitions(previous, latestValue);
|
|
538
|
-
if (transitions.length > 0) {
|
|
539
|
-
for (const t of transitions) appendSwarmReason(t);
|
|
540
|
-
pushSwarmEvent("registry_update", { count: transitions.length });
|
|
541
|
-
}
|
|
542
|
-
swarmState.registryCache = latest;
|
|
543
|
-
} catch (e) {
|
|
544
|
-
appendSwarmLog("stderr", `[swarm-monitor] ${e instanceof Error ? e.message : String(e)}`);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function clearSwarmMonitor() {
|
|
549
|
-
if (swarmState.monitorTimer) {
|
|
550
|
-
clearInterval(swarmState.monitorTimer);
|
|
551
|
-
swarmState.monitorTimer = null;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
991
|
async function engineHealth(token = "") {
|
|
556
992
|
try {
|
|
557
993
|
const response = await fetch(`${ENGINE_URL}/global/health`, {
|
|
@@ -595,7 +1031,9 @@ async function ensureEngineRunning() {
|
|
|
595
1031
|
log(
|
|
596
1032
|
"Note: TANDEM_CONTROL_PANEL_ENGINE_TOKEN is only applied when control panel starts a new engine process."
|
|
597
1033
|
);
|
|
598
|
-
log(
|
|
1034
|
+
log(
|
|
1035
|
+
"Use the existing engine's token, or stop that engine to let control panel start one with your configured token."
|
|
1036
|
+
);
|
|
599
1037
|
}
|
|
600
1038
|
return;
|
|
601
1039
|
}
|
|
@@ -615,25 +1053,28 @@ async function ensureEngineRunning() {
|
|
|
615
1053
|
log(`Starting Tandem Engine at ${ENGINE_URL}...`);
|
|
616
1054
|
engineProcess = spawn(
|
|
617
1055
|
process.execPath,
|
|
618
|
-
[
|
|
1056
|
+
[
|
|
1057
|
+
engineEntrypoint,
|
|
1058
|
+
"serve",
|
|
1059
|
+
"--hostname",
|
|
1060
|
+
url.hostname,
|
|
1061
|
+
"--port",
|
|
1062
|
+
String(url.port || ENGINE_PORT),
|
|
1063
|
+
],
|
|
619
1064
|
{
|
|
620
1065
|
env: {
|
|
621
1066
|
...process.env,
|
|
622
1067
|
TANDEM_API_TOKEN: managedEngineToken,
|
|
623
|
-
TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
|
|
624
|
-
|
|
625
|
-
TANDEM_TOOL_ROUTER_ENABLED:
|
|
626
|
-
process.env.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
1068
|
+
TANDEM_DISABLE_TOOL_GUARD_BUDGETS: process.env.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
|
|
1069
|
+
TANDEM_TOOL_ROUTER_ENABLED: process.env.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
627
1070
|
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
|
|
628
1071
|
process.env.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
|
|
629
1072
|
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
|
|
630
1073
|
process.env.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
|
|
631
1074
|
TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
|
|
632
1075
|
process.env.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
|
|
633
|
-
TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
|
|
634
|
-
|
|
635
|
-
TANDEM_TOOL_EXEC_TIMEOUT_MS:
|
|
636
|
-
process.env.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
1076
|
+
TANDEM_PERMISSION_WAIT_TIMEOUT_MS: process.env.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
|
|
1077
|
+
TANDEM_TOOL_EXEC_TIMEOUT_MS: process.env.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
637
1078
|
TANDEM_BASH_TIMEOUT_MS: process.env.TANDEM_BASH_TIMEOUT_MS || "30000",
|
|
638
1079
|
},
|
|
639
1080
|
stdio: "inherit",
|
|
@@ -641,7 +1082,9 @@ async function ensureEngineRunning() {
|
|
|
641
1082
|
);
|
|
642
1083
|
log(`Engine API token for this process: ${managedEngineToken}`);
|
|
643
1084
|
if (!CONFIGURED_ENGINE_TOKEN) {
|
|
644
|
-
log(
|
|
1085
|
+
log(
|
|
1086
|
+
"Token was auto-generated. Set TANDEM_CONTROL_PANEL_ENGINE_TOKEN (or TANDEM_API_TOKEN) to keep it stable."
|
|
1087
|
+
);
|
|
645
1088
|
}
|
|
646
1089
|
|
|
647
1090
|
engineProcess.on("error", (e) => err(`Failed to start engine: ${e.message}`));
|
|
@@ -676,7 +1119,8 @@ function toSafeRelPath(raw) {
|
|
|
676
1119
|
if (normalized.includes("\0")) return null;
|
|
677
1120
|
const full = resolve(FILES_ROOT, normalized);
|
|
678
1121
|
if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
|
|
679
|
-
if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`))
|
|
1122
|
+
if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`))
|
|
1123
|
+
return null;
|
|
680
1124
|
return normalized;
|
|
681
1125
|
}
|
|
682
1126
|
|
|
@@ -931,7 +1375,11 @@ async function handleAuthLogin(req, res) {
|
|
|
931
1375
|
sendJson(res, 200, {
|
|
932
1376
|
ok: true,
|
|
933
1377
|
requiresToken: !!health.apiTokenRequired,
|
|
934
|
-
engine: {
|
|
1378
|
+
engine: {
|
|
1379
|
+
url: ENGINE_URL,
|
|
1380
|
+
version: health.version || "unknown",
|
|
1381
|
+
local: isLocalEngineUrl(ENGINE_URL),
|
|
1382
|
+
},
|
|
935
1383
|
});
|
|
936
1384
|
} catch (e) {
|
|
937
1385
|
sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
@@ -976,7 +1424,10 @@ async function proxyEngineRequest(req, res, session) {
|
|
|
976
1424
|
duplex: hasBody ? "half" : undefined,
|
|
977
1425
|
});
|
|
978
1426
|
} catch (e) {
|
|
979
|
-
sendJson(res, 502, {
|
|
1427
|
+
sendJson(res, 502, {
|
|
1428
|
+
ok: false,
|
|
1429
|
+
error: `Engine unreachable: ${e instanceof Error ? e.message : String(e)}`,
|
|
1430
|
+
});
|
|
980
1431
|
return;
|
|
981
1432
|
}
|
|
982
1433
|
|
|
@@ -1023,183 +1474,2870 @@ async function proxyEngineRequest(req, res, session) {
|
|
|
1023
1474
|
}
|
|
1024
1475
|
}
|
|
1025
1476
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1477
|
+
function contextStepStatusToLegacyTaskStatus(status) {
|
|
1478
|
+
switch (
|
|
1479
|
+
String(status || "")
|
|
1480
|
+
.trim()
|
|
1481
|
+
.toLowerCase()
|
|
1482
|
+
) {
|
|
1483
|
+
case "in_progress":
|
|
1484
|
+
return "running";
|
|
1485
|
+
case "runnable":
|
|
1486
|
+
case "pending":
|
|
1487
|
+
return "pending";
|
|
1488
|
+
case "blocked":
|
|
1489
|
+
return "blocked";
|
|
1490
|
+
case "done":
|
|
1491
|
+
return "complete";
|
|
1492
|
+
case "failed":
|
|
1493
|
+
return "failed";
|
|
1494
|
+
default:
|
|
1495
|
+
return "pending";
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function contextRunStatusToSwarmStatus(status) {
|
|
1500
|
+
const normalized = String(status || "")
|
|
1501
|
+
.trim()
|
|
1502
|
+
.toLowerCase();
|
|
1503
|
+
if (
|
|
1504
|
+
[
|
|
1505
|
+
"queued",
|
|
1506
|
+
"planning",
|
|
1507
|
+
"awaiting_approval",
|
|
1508
|
+
"running",
|
|
1509
|
+
"paused",
|
|
1510
|
+
"blocked",
|
|
1511
|
+
"completed",
|
|
1512
|
+
"failed",
|
|
1513
|
+
"cancelled",
|
|
1514
|
+
].includes(normalized)
|
|
1515
|
+
) {
|
|
1516
|
+
return normalized;
|
|
1517
|
+
}
|
|
1518
|
+
return "idle";
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function buildWorkspaceId(workspaceRoot) {
|
|
1522
|
+
const digest = createHash("sha1")
|
|
1523
|
+
.update(String(workspaceRoot || ""))
|
|
1524
|
+
.digest("hex");
|
|
1525
|
+
return `ws-${digest.slice(0, 16)}`;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function workspaceExistsAsDirectory(workspaceRoot) {
|
|
1529
|
+
const normalized = resolve(String(workspaceRoot || "").trim());
|
|
1530
|
+
if (!existsSync(normalized)) return null;
|
|
1531
|
+
return stat(normalized)
|
|
1532
|
+
.then((info) => (info.isDirectory() ? normalized : null))
|
|
1533
|
+
.catch(() => null);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function engineRequestJson(session, path, options = {}) {
|
|
1537
|
+
const method = String(options.method || "GET").toUpperCase();
|
|
1538
|
+
const body = options.body;
|
|
1539
|
+
const maxNetworkRetries = options.maxNetworkRetries ?? 2;
|
|
1540
|
+
const isEngineStarting = (value) =>
|
|
1541
|
+
typeof value === "string" &&
|
|
1542
|
+
(value.includes("ENGINE_STARTING") ||
|
|
1543
|
+
value.includes("Engine starting") ||
|
|
1544
|
+
value.includes("Service Unavailable"));
|
|
1545
|
+
let response = null;
|
|
1546
|
+
let lastError = null;
|
|
1547
|
+
for (let attempt = 0; attempt <= maxNetworkRetries; attempt += 1) {
|
|
1548
|
+
if (attempt > 0) await sleep(1000 * attempt);
|
|
1029
1549
|
try {
|
|
1030
|
-
|
|
1550
|
+
response = await fetch(`${ENGINE_URL}${path}`, {
|
|
1551
|
+
method,
|
|
1031
1552
|
headers: {
|
|
1032
|
-
authorization: `Bearer ${token}`,
|
|
1033
|
-
"x-tandem-token": token,
|
|
1553
|
+
authorization: `Bearer ${session.token}`,
|
|
1554
|
+
"x-tandem-token": session.token,
|
|
1555
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
1556
|
+
...(options.headers || {}),
|
|
1034
1557
|
},
|
|
1035
|
-
|
|
1558
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1559
|
+
signal: AbortSignal.timeout(options.timeoutMs || 8000),
|
|
1036
1560
|
});
|
|
1037
|
-
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
const name = String(error?.name || "");
|
|
1563
|
+
const isTimeout = name === "AbortError" || name === "TimeoutError";
|
|
1564
|
+
if (isTimeout || attempt >= maxNetworkRetries) throw error;
|
|
1565
|
+
lastError = error;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (!response.ok) {
|
|
1569
|
+
const rawText = await response.text().catch(() => "");
|
|
1570
|
+
if (
|
|
1571
|
+
(response.status === 503 || isEngineStarting(rawText)) &&
|
|
1572
|
+
attempt < maxNetworkRetries
|
|
1573
|
+
) {
|
|
1574
|
+
lastError = new Error(rawText || `${method} ${path} failed: ${response.status}`);
|
|
1575
|
+
response = null;
|
|
1576
|
+
continue;
|
|
1041
1577
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1578
|
+
let detail = `${method} ${path} failed: ${response.status}`;
|
|
1579
|
+
if (rawText.trim()) {
|
|
1580
|
+
try {
|
|
1581
|
+
const payload = JSON.parse(rawText);
|
|
1582
|
+
detail = String(payload?.error || payload?.message || detail);
|
|
1583
|
+
} catch {
|
|
1584
|
+
detail = rawText || detail;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
throw new Error(detail);
|
|
1044
1588
|
}
|
|
1589
|
+
break;
|
|
1045
1590
|
}
|
|
1046
|
-
|
|
1591
|
+
if (!response) throw lastError || new Error(`${method} ${path} failed`);
|
|
1592
|
+
if (response.status === 204) return {};
|
|
1593
|
+
const text = await response.text();
|
|
1594
|
+
if (!text.trim()) return {};
|
|
1595
|
+
return JSON.parse(text);
|
|
1047
1596
|
}
|
|
1048
1597
|
|
|
1049
|
-
function
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1598
|
+
function contextRunToTasks(run) {
|
|
1599
|
+
const steps = Array.isArray(run?.steps) ? run.steps : [];
|
|
1600
|
+
return steps.map((step) => ({
|
|
1601
|
+
taskId: String(step.step_id || ""),
|
|
1602
|
+
title: String(step.title || step.step_id || "Untitled step"),
|
|
1603
|
+
ownerRole: "context_driver",
|
|
1604
|
+
status: contextStepStatusToLegacyTaskStatus(step.status),
|
|
1605
|
+
stepStatus: String(step.status || "pending"),
|
|
1606
|
+
statusReason: String(run?.why_next_step || ""),
|
|
1607
|
+
lastUpdateMs: Number(run?.updated_at_ms || Date.now()),
|
|
1608
|
+
runId: String(run?.run_id || ""),
|
|
1609
|
+
sessionId: `context-${String(run?.run_id || "")}`,
|
|
1610
|
+
branch: "",
|
|
1611
|
+
worktreePath: "",
|
|
1612
|
+
}));
|
|
1055
1613
|
}
|
|
1056
1614
|
|
|
1057
|
-
function
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1615
|
+
function eventsToReasons(events = []) {
|
|
1616
|
+
return events
|
|
1617
|
+
.map((evt) => {
|
|
1618
|
+
const payload = evt?.payload && typeof evt.payload === "object" ? evt.payload : {};
|
|
1619
|
+
return {
|
|
1620
|
+
at: Number(evt?.ts_ms || Date.now()),
|
|
1621
|
+
kind: "task_transition",
|
|
1622
|
+
taskId: String(evt?.step_id || payload?.step_id || "run"),
|
|
1623
|
+
role: "context_driver",
|
|
1624
|
+
from: "",
|
|
1625
|
+
to: String(evt?.status || ""),
|
|
1626
|
+
reason: String(payload?.why_next_step || payload?.error || evt?.type || "updated"),
|
|
1627
|
+
};
|
|
1628
|
+
})
|
|
1629
|
+
.slice(-250);
|
|
1630
|
+
}
|
|
1064
1631
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1632
|
+
function eventsToLogs(events = []) {
|
|
1633
|
+
return events
|
|
1634
|
+
.map((evt) => {
|
|
1635
|
+
const payload = evt?.payload && typeof evt.payload === "object" ? evt.payload : {};
|
|
1636
|
+
const details = payload?.error || payload?.why_next_step || "";
|
|
1637
|
+
const line = details
|
|
1638
|
+
? `${evt?.type || "event"} ${evt?.status || ""}: ${details}`
|
|
1639
|
+
: `${evt?.type || "event"} ${evt?.status || ""}`.trim();
|
|
1640
|
+
return {
|
|
1641
|
+
at: Number(evt?.ts_ms || Date.now()),
|
|
1642
|
+
stream: "event",
|
|
1643
|
+
line,
|
|
1644
|
+
};
|
|
1645
|
+
})
|
|
1646
|
+
.slice(-300);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function contextRunSnapshot(session, runId) {
|
|
1650
|
+
const [runPayload, eventsPayload, blackboardPayload, replayPayload, patchesPayload] =
|
|
1651
|
+
await Promise.all([
|
|
1652
|
+
engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}`),
|
|
1653
|
+
engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/events?tail=300`),
|
|
1654
|
+
engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/blackboard`).catch(
|
|
1655
|
+
() => ({})
|
|
1656
|
+
),
|
|
1657
|
+
engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/replay`).catch(
|
|
1658
|
+
() => ({})
|
|
1659
|
+
),
|
|
1660
|
+
engineRequestJson(
|
|
1661
|
+
session,
|
|
1662
|
+
`/context/runs/${encodeURIComponent(runId)}/blackboard/patches?tail=300`
|
|
1663
|
+
).catch(() => ({})),
|
|
1664
|
+
]);
|
|
1665
|
+
const run = runPayload?.run || {};
|
|
1666
|
+
const events = Array.isArray(eventsPayload?.events) ? eventsPayload.events : [];
|
|
1667
|
+
const blackboardPatches = Array.isArray(patchesPayload?.patches) ? patchesPayload.patches : [];
|
|
1668
|
+
const tasks = contextRunToTasks(run);
|
|
1669
|
+
const taskMap = Object.fromEntries(tasks.map((task) => [task.taskId, task]));
|
|
1670
|
+
return {
|
|
1671
|
+
run,
|
|
1672
|
+
events,
|
|
1673
|
+
blackboard: blackboardPayload?.blackboard || null,
|
|
1674
|
+
blackboardPatches,
|
|
1675
|
+
replay: replayPayload || null,
|
|
1676
|
+
registry: {
|
|
1677
|
+
key: "context.run.steps",
|
|
1678
|
+
value: {
|
|
1679
|
+
version: 1,
|
|
1680
|
+
updatedAtMs: Number(run?.updated_at_ms || Date.now()),
|
|
1681
|
+
tasks: taskMap,
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
1684
|
+
reasons: eventsToReasons(events),
|
|
1685
|
+
logs: eventsToLogs(events),
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1068
1688
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
pushSwarmEvent("status", { status: swarmState.status, objective, workspaceRoot, maxTasks });
|
|
1086
|
-
|
|
1087
|
-
const child = spawn(process.execPath, [managerPath, objective], {
|
|
1088
|
-
cwd: workspaceRoot,
|
|
1089
|
-
env: {
|
|
1090
|
-
...process.env,
|
|
1091
|
-
TANDEM_BASE_URL: ENGINE_URL,
|
|
1092
|
-
TANDEM_API_TOKEN: session.token,
|
|
1093
|
-
SWARM_MAX_TASKS: String(maxTasks),
|
|
1094
|
-
SWARM_OBJECTIVE: objective,
|
|
1689
|
+
async function appendContextRunEvent(
|
|
1690
|
+
session,
|
|
1691
|
+
runId,
|
|
1692
|
+
eventType,
|
|
1693
|
+
status,
|
|
1694
|
+
payload = {},
|
|
1695
|
+
stepId = null
|
|
1696
|
+
) {
|
|
1697
|
+
return engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/events`, {
|
|
1698
|
+
method: "POST",
|
|
1699
|
+
body: {
|
|
1700
|
+
type: eventType,
|
|
1701
|
+
status,
|
|
1702
|
+
step_id: stepId || undefined,
|
|
1703
|
+
payload,
|
|
1095
1704
|
},
|
|
1096
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1097
1705
|
});
|
|
1706
|
+
}
|
|
1098
1707
|
|
|
1099
|
-
|
|
1708
|
+
function parseObjectiveTodos(objective, max = 6) {
|
|
1709
|
+
const compact = String(objective || "")
|
|
1710
|
+
.split(/\r?\n/)
|
|
1711
|
+
.map((line) => line.replace(/^[-*#\d\.\)\[\]\s]+/, "").trim())
|
|
1712
|
+
.filter(Boolean)
|
|
1713
|
+
.join(" ");
|
|
1714
|
+
const normalized = compact.replace(/\s+/g, " ").trim();
|
|
1715
|
+
if (!normalized) return ["Execute requested objective"];
|
|
1716
|
+
const maxChars = Math.max(80, Number(max || 6) * 80);
|
|
1717
|
+
const content =
|
|
1718
|
+
normalized.length > maxChars ? `${normalized.slice(0, maxChars).trimEnd()}...` : normalized;
|
|
1719
|
+
return [content];
|
|
1720
|
+
}
|
|
1100
1721
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1722
|
+
function textFromMessageParts(parts) {
|
|
1723
|
+
if (!Array.isArray(parts)) return "";
|
|
1724
|
+
return parts
|
|
1725
|
+
.map((part) => {
|
|
1726
|
+
if (!part) return "";
|
|
1727
|
+
if (typeof part === "string") return part;
|
|
1728
|
+
if (typeof part.text === "string") return part.text;
|
|
1729
|
+
if (typeof part.delta === "string") return part.delta;
|
|
1730
|
+
if (typeof part.content === "string") return part.content;
|
|
1731
|
+
return "";
|
|
1732
|
+
})
|
|
1733
|
+
.filter(Boolean)
|
|
1734
|
+
.join("\n")
|
|
1735
|
+
.trim();
|
|
1736
|
+
}
|
|
1103
1737
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
});
|
|
1738
|
+
function roleOfMessage(row) {
|
|
1739
|
+
return String(
|
|
1740
|
+
row?.info?.role || row?.role || row?.message_role || row?.type || row?.author || "assistant"
|
|
1741
|
+
)
|
|
1742
|
+
.trim()
|
|
1743
|
+
.toLowerCase();
|
|
1744
|
+
}
|
|
1112
1745
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1746
|
+
function textOfMessage(row) {
|
|
1747
|
+
const fromParts = textFromMessageParts(row?.parts);
|
|
1748
|
+
if (fromParts) return fromParts;
|
|
1749
|
+
const direct = [row?.content, row?.text, row?.message, row?.delta, row?.body].find(
|
|
1750
|
+
(value) => typeof value === "string" && value.trim().length > 0
|
|
1751
|
+
);
|
|
1752
|
+
if (typeof direct === "string") return direct.trim();
|
|
1753
|
+
if (Array.isArray(row?.content)) {
|
|
1754
|
+
return row.content
|
|
1755
|
+
.map((chunk) => {
|
|
1756
|
+
if (!chunk) return "";
|
|
1757
|
+
if (typeof chunk === "string") return chunk;
|
|
1758
|
+
if (typeof chunk?.text === "string") return chunk.text;
|
|
1759
|
+
if (typeof chunk?.content === "string") return chunk.content;
|
|
1760
|
+
return "";
|
|
1761
|
+
})
|
|
1762
|
+
.filter(Boolean)
|
|
1763
|
+
.join("\n")
|
|
1764
|
+
.trim();
|
|
1765
|
+
}
|
|
1766
|
+
return "";
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function extractAssistantText(rows) {
|
|
1770
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
1771
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
1772
|
+
if (roleOfMessage(list[i]) !== "assistant") continue;
|
|
1773
|
+
const text = textOfMessage(list[i]);
|
|
1774
|
+
if (text) return text;
|
|
1775
|
+
}
|
|
1776
|
+
return "";
|
|
1777
|
+
}
|
|
1120
1778
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1779
|
+
function normalizePlannerTasks(rawTasks, maxTasks = 8, options = {}) {
|
|
1780
|
+
const normalizedMax = Math.max(1, Number(maxTasks) || 8);
|
|
1781
|
+
const linearFallback = options?.linearFallback === true;
|
|
1782
|
+
const candidates = Array.isArray(rawTasks) ? rawTasks : [];
|
|
1783
|
+
const normalizeTaskKind = (value, outputTarget) => {
|
|
1784
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
1785
|
+
if (["implementation", "inspection", "research", "validation"].includes(raw)) return raw;
|
|
1786
|
+
return outputTarget?.path ? "implementation" : "inspection";
|
|
1787
|
+
};
|
|
1788
|
+
const normalizeOutputTarget = (value) => {
|
|
1789
|
+
if (!value || typeof value !== "object") return null;
|
|
1790
|
+
const path = String(value?.path || value?.file || value?.file_path || value?.target || "").trim();
|
|
1791
|
+
if (!path) return null;
|
|
1792
|
+
const kind = String(value?.kind || value?.type || "artifact").trim().toLowerCase() || "artifact";
|
|
1793
|
+
const operation = String(value?.operation || value?.mode || "").trim().toLowerCase() || "create_or_update";
|
|
1794
|
+
return { path, kind, operation };
|
|
1795
|
+
};
|
|
1796
|
+
const provisional = candidates
|
|
1797
|
+
.map((row, index) => {
|
|
1798
|
+
if (typeof row === "string") {
|
|
1799
|
+
const title = String(row || "").trim();
|
|
1800
|
+
if (title.length < 6) return null;
|
|
1801
|
+
return {
|
|
1802
|
+
id: `task-${index + 1}`,
|
|
1803
|
+
title,
|
|
1804
|
+
dependsOnTaskIds: [],
|
|
1805
|
+
outputTarget: null,
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
if (!row || typeof row !== "object") return null;
|
|
1809
|
+
const title = String(row?.title || row?.task || row?.content || "").trim();
|
|
1810
|
+
if (title.length < 6) return null;
|
|
1811
|
+
const rawId = String(row?.id || row?.task_id || row?.taskId || `task-${index + 1}`).trim();
|
|
1812
|
+
const id = rawId || `task-${index + 1}`;
|
|
1813
|
+
const dependencySource = Array.isArray(row?.depends_on_task_ids)
|
|
1814
|
+
? row.depends_on_task_ids
|
|
1815
|
+
: Array.isArray(row?.dependsOnTaskIds)
|
|
1816
|
+
? row.dependsOnTaskIds
|
|
1817
|
+
: Array.isArray(row?.dependsOn)
|
|
1818
|
+
? row.dependsOn
|
|
1819
|
+
: Array.isArray(row?.dependencies)
|
|
1820
|
+
? row.dependencies
|
|
1821
|
+
: [];
|
|
1822
|
+
const dependsOnTaskIds = dependencySource
|
|
1823
|
+
.map((dep) => String(dep || "").trim())
|
|
1824
|
+
.filter(Boolean);
|
|
1825
|
+
const outputTarget = normalizeOutputTarget(
|
|
1826
|
+
row?.output_target || row?.outputTarget || row?.artifact || row?.target_file || null
|
|
1827
|
+
);
|
|
1828
|
+
return {
|
|
1829
|
+
id,
|
|
1830
|
+
title,
|
|
1831
|
+
dependsOnTaskIds,
|
|
1832
|
+
outputTarget,
|
|
1833
|
+
taskKind: normalizeTaskKind(row?.task_kind || row?.taskKind || row?.kind, outputTarget),
|
|
1834
|
+
};
|
|
1835
|
+
})
|
|
1836
|
+
.filter(Boolean)
|
|
1837
|
+
.slice(0, normalizedMax);
|
|
1838
|
+
const withUniqueIds = [];
|
|
1839
|
+
const idCounts = new Map();
|
|
1840
|
+
for (const row of provisional) {
|
|
1841
|
+
const base = String(row?.id || "task").trim() || "task";
|
|
1842
|
+
const count = Number(idCounts.get(base) || 0) + 1;
|
|
1843
|
+
idCounts.set(base, count);
|
|
1844
|
+
const uniqueId = count > 1 ? `${base}-${count}` : base;
|
|
1845
|
+
withUniqueIds.push({
|
|
1846
|
+
id: uniqueId,
|
|
1847
|
+
title: String(row?.title || "").trim(),
|
|
1848
|
+
dependsOnTaskIds: Array.isArray(row?.dependsOnTaskIds) ? row.dependsOnTaskIds : [],
|
|
1849
|
+
outputTarget: row?.outputTarget || null,
|
|
1850
|
+
taskKind: String(row?.taskKind || "").trim() || "inspection",
|
|
1133
1851
|
});
|
|
1852
|
+
}
|
|
1853
|
+
const knownIds = new Set(withUniqueIds.map((row) => row.id));
|
|
1854
|
+
return withUniqueIds.map((row, index) => {
|
|
1855
|
+
let dependsOnTaskIds = (Array.isArray(row?.dependsOnTaskIds) ? row.dependsOnTaskIds : [])
|
|
1856
|
+
.map((dep) => String(dep || "").trim())
|
|
1857
|
+
.filter((dep) => dep && dep !== row.id && knownIds.has(dep));
|
|
1858
|
+
if (!dependsOnTaskIds.length && linearFallback && index > 0) {
|
|
1859
|
+
dependsOnTaskIds = [withUniqueIds[index - 1].id];
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
id: row.id,
|
|
1863
|
+
title: row.title,
|
|
1864
|
+
dependsOnTaskIds,
|
|
1865
|
+
outputTarget: row?.outputTarget || null,
|
|
1866
|
+
taskKind: String(row?.taskKind || "").trim() || "inspection",
|
|
1867
|
+
};
|
|
1134
1868
|
});
|
|
1135
1869
|
}
|
|
1136
1870
|
|
|
1137
|
-
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
startedAt: swarmState.startedAt,
|
|
1148
|
-
stoppedAt: swarmState.stoppedAt,
|
|
1149
|
-
localEngine: isLocalEngineUrl(ENGINE_URL),
|
|
1150
|
-
lastError: swarmState.lastError || null,
|
|
1151
|
-
});
|
|
1152
|
-
return true;
|
|
1871
|
+
function inferOutputTargetFromText(text) {
|
|
1872
|
+
const source = String(text || "").trim();
|
|
1873
|
+
if (!source) return null;
|
|
1874
|
+
const backtickMatch = source.match(/`([^`\n]+?\.[A-Za-z0-9_-]{1,12})`/);
|
|
1875
|
+
if (backtickMatch?.[1]) {
|
|
1876
|
+
return {
|
|
1877
|
+
path: backtickMatch[1].trim(),
|
|
1878
|
+
kind: "artifact",
|
|
1879
|
+
operation: "create_or_update",
|
|
1880
|
+
};
|
|
1153
1881
|
}
|
|
1882
|
+
const saveAsMatch = source.match(/\bsave as\s+([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]{1,12})\b/i);
|
|
1883
|
+
if (saveAsMatch?.[1]) {
|
|
1884
|
+
return {
|
|
1885
|
+
path: saveAsMatch[1].trim(),
|
|
1886
|
+
kind: "artifact",
|
|
1887
|
+
operation: "create_or_update",
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1154
1892
|
|
|
1155
|
-
|
|
1893
|
+
function ensurePlannerTaskOutputTargets(tasks, objective) {
|
|
1894
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
1895
|
+
return list.map((task) => {
|
|
1896
|
+
const taskKind = String(task?.taskKind || "").trim().toLowerCase() || "inspection";
|
|
1897
|
+
const existing = task?.outputTarget && typeof task.outputTarget === "object" ? task.outputTarget : null;
|
|
1898
|
+
return {
|
|
1899
|
+
...task,
|
|
1900
|
+
taskKind,
|
|
1901
|
+
outputTarget: existing || null,
|
|
1902
|
+
};
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function validateStrictPlannerTasks(tasks) {
|
|
1907
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
1908
|
+
const invalidTaskKinds = list
|
|
1909
|
+
.filter((task) => !["implementation", "inspection", "research", "validation"].includes(String(task?.taskKind || "").trim().toLowerCase()))
|
|
1910
|
+
.map((task) => String(task?.id || task?.title || "task").trim())
|
|
1911
|
+
.filter(Boolean);
|
|
1912
|
+
const missing = list
|
|
1913
|
+
.filter((task) => {
|
|
1914
|
+
const taskKind = String(task?.taskKind || "").trim().toLowerCase();
|
|
1915
|
+
return taskKind === "implementation" && !String(task?.outputTarget?.path || "").trim();
|
|
1916
|
+
})
|
|
1917
|
+
.map((task) => String(task?.id || task?.title || "task").trim())
|
|
1918
|
+
.filter(Boolean);
|
|
1919
|
+
return {
|
|
1920
|
+
ok: missing.length === 0 && invalidTaskKinds.length === 0,
|
|
1921
|
+
missing,
|
|
1922
|
+
invalidTaskKinds,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function parsePlanTasksFromAssistant(assistantText, maxTasks = 8, options = {}) {
|
|
1927
|
+
const normalizedMax = Math.max(1, Number(maxTasks) || 8);
|
|
1928
|
+
const allowTextFallback = options?.allowTextFallback !== false;
|
|
1929
|
+
const fencedJson = String(assistantText || "").match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1930
|
+
const candidateJson = (fencedJson?.[1] || String(assistantText || "")).trim();
|
|
1931
|
+
const parsedTodos = (() => {
|
|
1156
1932
|
try {
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1933
|
+
const payload = JSON.parse(candidateJson);
|
|
1934
|
+
if (Array.isArray(payload)) return payload;
|
|
1935
|
+
if (Array.isArray(payload?.tasks)) return payload.tasks;
|
|
1936
|
+
if (Array.isArray(payload?.plan)) return payload.plan;
|
|
1937
|
+
if (Array.isArray(payload?.steps)) return payload.steps;
|
|
1938
|
+
if (Array.isArray(payload?.items)) return payload.items;
|
|
1939
|
+
return [];
|
|
1940
|
+
} catch {
|
|
1941
|
+
return [];
|
|
1162
1942
|
}
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1943
|
+
})();
|
|
1944
|
+
const fromJson = normalizePlannerTasks(parsedTodos, normalizedMax, { linearFallback: false });
|
|
1945
|
+
if (fromJson.length) return fromJson;
|
|
1946
|
+
if (!allowTextFallback) return [];
|
|
1947
|
+
const fromText = String(assistantText || "")
|
|
1948
|
+
.split(/\r?\n/)
|
|
1949
|
+
.map((line) => line.replace(/^[-*#\d\.\)\[\]\s]+/, "").trim())
|
|
1950
|
+
.filter((line) => line.length >= 6)
|
|
1951
|
+
.slice(0, normalizedMax);
|
|
1952
|
+
return normalizePlannerTasks(fromText, normalizedMax, { linearFallback: true });
|
|
1953
|
+
}
|
|
1165
1954
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1955
|
+
function extractPlannerFailureText(text) {
|
|
1956
|
+
const value = String(text || "").trim();
|
|
1957
|
+
if (!value) return "";
|
|
1958
|
+
const firstLine = value.split(/\r?\n/, 1)[0] || "";
|
|
1959
|
+
const normalized = firstLine.toUpperCase();
|
|
1960
|
+
if (normalized.startsWith("ENGINE_ERROR:")) return value;
|
|
1961
|
+
if (
|
|
1962
|
+
normalized.includes("AUTHENTICATION_ERROR") ||
|
|
1963
|
+
normalized.includes("RATE_LIMIT_EXCEEDED") ||
|
|
1964
|
+
normalized.includes("CONTEXT_LENGTH_EXCEEDED") ||
|
|
1965
|
+
normalized.includes("PROVIDER_SERVER_ERROR") ||
|
|
1966
|
+
normalized.includes("PROVIDER_REQUEST_FAILED")
|
|
1967
|
+
) {
|
|
1968
|
+
return value;
|
|
1170
1969
|
}
|
|
1970
|
+
if (/key limit exceeded|403 forbidden|monthly limit|rate limit/i.test(value)) {
|
|
1971
|
+
return value;
|
|
1972
|
+
}
|
|
1973
|
+
return "";
|
|
1974
|
+
}
|
|
1171
1975
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1976
|
+
function fallbackPlannerTasks(objective, maxTasks = 8, assistantText = "") {
|
|
1977
|
+
const fromAssistantText = parsePlanTasksFromAssistant(assistantText, maxTasks, {
|
|
1978
|
+
allowTextFallback: true,
|
|
1979
|
+
});
|
|
1980
|
+
if (fromAssistantText.length) {
|
|
1981
|
+
return {
|
|
1982
|
+
source: "llm_text_recovery",
|
|
1983
|
+
note: "Recovered planner tasks from non-JSON assistant text.",
|
|
1984
|
+
tasks: fromAssistantText,
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
const fromObjective = normalizePlannerTasks(parseObjectiveTodos(objective, maxTasks), maxTasks, {
|
|
1988
|
+
linearFallback: true,
|
|
1989
|
+
});
|
|
1990
|
+
if (fromObjective.length) {
|
|
1991
|
+
return {
|
|
1992
|
+
source: "local_objective_parser",
|
|
1993
|
+
note: "Synthesized planner tasks from the objective after planner failure.",
|
|
1994
|
+
tasks: fromObjective,
|
|
1995
|
+
};
|
|
1186
1996
|
}
|
|
1997
|
+
return {
|
|
1998
|
+
source: "local_single_task_fallback",
|
|
1999
|
+
note: "Synthesized a single fallback task after planner failure.",
|
|
2000
|
+
tasks: normalizePlannerTasks(["Execute requested objective"], 1, {
|
|
2001
|
+
linearFallback: false,
|
|
2002
|
+
}),
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
1187
2005
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
2006
|
+
async function generatePlanTodosWithLLM(session, run, maxTasks) {
|
|
2007
|
+
const runId = String(run?.run_id || "").trim();
|
|
2008
|
+
if (!runId) throw new Error("Missing run id for plan generation.");
|
|
2009
|
+
const sessionId = await createExecutionSession(session, run);
|
|
2010
|
+
const prompt = [
|
|
2011
|
+
"You are planning a swarm run.",
|
|
2012
|
+
"",
|
|
2013
|
+
`Objective: ${String(run?.objective || "").trim()}`,
|
|
2014
|
+
`Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
|
|
2015
|
+
"",
|
|
2016
|
+
`Generate ${Math.max(1, Number(maxTasks) || 3)} concise, execution-ready tasks.`,
|
|
2017
|
+
"If the objective requires creating or updating a single file, prefer ONE implementation task that creates the complete file.",
|
|
2018
|
+
"Do not split creation and refinement of the same artifact into separate dependent tasks.",
|
|
2019
|
+
"Inspection tasks should only exist when the workspace has existing files that must be understood first.",
|
|
2020
|
+
"For greenfield or nearly empty workspaces, skip inspection and go straight to implementation.",
|
|
2021
|
+
"Return strict JSON only in this shape:",
|
|
2022
|
+
'{"tasks":[{"id":"task-1","title":"...","task_kind":"inspection","depends_on_task_ids":[]},{"id":"task-2","title":"...","task_kind":"implementation","depends_on_task_ids":["task-1"],"output_target":{"path":"relative/path.ext","kind":"source|spec|config|document|test|asset","operation":"create|update|create_or_update"}}]}',
|
|
2023
|
+
"Use depends_on_task_ids only when a task requires outputs from another task.",
|
|
2024
|
+
"Independent tasks must have an empty depends_on_task_ids array.",
|
|
2025
|
+
"Every task must include task_kind: implementation, inspection, research, or validation.",
|
|
2026
|
+
"Only implementation tasks must include output_target.path naming the concrete workspace file or artifact to create or update.",
|
|
2027
|
+
"Inspection/research tasks are read-only and should not require file writes.",
|
|
2028
|
+
"Keep output_target generic and file-type agnostic: Python, JS, HTML, Markdown, TOML, YAML, text, tests, and assets are all valid.",
|
|
2029
|
+
"Do not include explanations.",
|
|
2030
|
+
].join("\n");
|
|
2031
|
+
const promptResponse = await engineRequestJson(
|
|
2032
|
+
session,
|
|
2033
|
+
`/session/${encodeURIComponent(sessionId)}/prompt_sync`,
|
|
2034
|
+
{
|
|
2035
|
+
method: "POST",
|
|
2036
|
+
timeoutMs: 3 * 60 * 1000,
|
|
2037
|
+
body: {
|
|
2038
|
+
parts: [{ type: "text", text: prompt }],
|
|
2039
|
+
},
|
|
2040
|
+
}
|
|
2041
|
+
);
|
|
2042
|
+
const syncRows = Array.isArray(promptResponse) ? promptResponse : [];
|
|
2043
|
+
const fromSync = extractAssistantText(syncRows);
|
|
2044
|
+
const syncFailure = extractPlannerFailureText(fromSync);
|
|
2045
|
+
if (syncFailure) {
|
|
2046
|
+
const error = new Error(syncFailure);
|
|
2047
|
+
error.sessionId = sessionId;
|
|
2048
|
+
throw error;
|
|
2049
|
+
}
|
|
2050
|
+
if (fromSync) {
|
|
2051
|
+
return {
|
|
2052
|
+
sessionId,
|
|
2053
|
+
tasks: parsePlanTasksFromAssistant(fromSync, maxTasks, { allowTextFallback: false }),
|
|
2054
|
+
assistantText: fromSync,
|
|
2055
|
+
};
|
|
1198
2056
|
}
|
|
2057
|
+
const sessionSnapshot = await engineRequestJson(
|
|
2058
|
+
session,
|
|
2059
|
+
`/session/${encodeURIComponent(sessionId)}`
|
|
2060
|
+
).catch(() => null);
|
|
2061
|
+
const messages = Array.isArray(sessionSnapshot?.messages) ? sessionSnapshot.messages : [];
|
|
2062
|
+
const fromSnapshot = extractAssistantText(messages);
|
|
2063
|
+
const snapshotFailure = extractPlannerFailureText(fromSnapshot);
|
|
2064
|
+
if (snapshotFailure) {
|
|
2065
|
+
const error = new Error(snapshotFailure);
|
|
2066
|
+
error.sessionId = sessionId;
|
|
2067
|
+
throw error;
|
|
2068
|
+
}
|
|
2069
|
+
return {
|
|
2070
|
+
sessionId,
|
|
2071
|
+
tasks: parsePlanTasksFromAssistant(fromSnapshot, maxTasks, { allowTextFallback: false }),
|
|
2072
|
+
assistantText: fromSnapshot,
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
1199
2075
|
|
|
1200
|
-
|
|
2076
|
+
function isRunTerminal(status) {
|
|
2077
|
+
const normalized = String(status || "")
|
|
2078
|
+
.trim()
|
|
2079
|
+
.toLowerCase();
|
|
2080
|
+
return ["completed", "failed", "cancelled"].includes(normalized);
|
|
1201
2081
|
}
|
|
1202
2082
|
|
|
2083
|
+
async function seedContextRunSteps(session, runId, objective) {
|
|
2084
|
+
const todoRows = parseObjectiveTodos(objective, 8).map((content, idx) => ({
|
|
2085
|
+
id: `step-${idx + 1}`,
|
|
2086
|
+
content,
|
|
2087
|
+
status: "pending",
|
|
2088
|
+
}));
|
|
2089
|
+
await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
|
|
2090
|
+
method: "POST",
|
|
2091
|
+
body: {
|
|
2092
|
+
replace: true,
|
|
2093
|
+
todos: todoRows,
|
|
2094
|
+
source_session_id: null,
|
|
2095
|
+
source_run_id: runId,
|
|
2096
|
+
},
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
async function seedContextRunStepsFromTitles(session, runId, titles = []) {
|
|
2101
|
+
const todoRows = (Array.isArray(titles) ? titles : [])
|
|
2102
|
+
.map((row) => String(row || "").trim())
|
|
2103
|
+
.filter((row) => row.length >= 3)
|
|
2104
|
+
.map((content, idx) => ({
|
|
2105
|
+
id: `step-${idx + 1}`,
|
|
2106
|
+
content,
|
|
2107
|
+
status: "pending",
|
|
2108
|
+
}));
|
|
2109
|
+
if (!todoRows.length) {
|
|
2110
|
+
throw new Error("No valid todo rows for context step seeding.");
|
|
2111
|
+
}
|
|
2112
|
+
await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
|
|
2113
|
+
method: "POST",
|
|
2114
|
+
body: {
|
|
2115
|
+
replace: true,
|
|
2116
|
+
todos: todoRows,
|
|
2117
|
+
source_session_id: null,
|
|
2118
|
+
source_run_id: runId,
|
|
2119
|
+
},
|
|
2120
|
+
});
|
|
2121
|
+
return todoRows.length;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
async function createExecutionSession(session, run) {
|
|
2125
|
+
const runId = String(run?.run_id || "").trim();
|
|
2126
|
+
const controller = getSwarmRunController(runId);
|
|
2127
|
+
const workspaceCandidates = [
|
|
2128
|
+
run?.workspace?.canonical_path,
|
|
2129
|
+
run?.workspace?.workspace_root,
|
|
2130
|
+
run?.workspace_root,
|
|
2131
|
+
controller?.workspaceRoot,
|
|
2132
|
+
controller?.repoRoot,
|
|
2133
|
+
swarmState.workspaceRoot,
|
|
2134
|
+
swarmState.repoRoot,
|
|
2135
|
+
REPO_ROOT,
|
|
2136
|
+
];
|
|
2137
|
+
const workspaceRootRaw = workspaceCandidates
|
|
2138
|
+
.map((value) => String(value || "").trim())
|
|
2139
|
+
.find((value) => value.length > 0);
|
|
2140
|
+
const workspaceRoot = await workspaceExistsAsDirectory(workspaceRootRaw || REPO_ROOT);
|
|
2141
|
+
if (!workspaceRoot) {
|
|
2142
|
+
throw new Error(
|
|
2143
|
+
`Workspace root does not exist or is not a directory: ${resolve(String(workspaceRootRaw || REPO_ROOT))}`
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
const resolved = await resolveExecutionModel(session, run);
|
|
2147
|
+
const modelProvider = resolved.provider;
|
|
2148
|
+
const modelId = resolved.model;
|
|
2149
|
+
upsertSwarmRunController(runId, {
|
|
2150
|
+
resolvedModelProvider: modelProvider,
|
|
2151
|
+
resolvedModelId: modelId,
|
|
2152
|
+
modelResolutionSource: resolved.source,
|
|
2153
|
+
modelProvider,
|
|
2154
|
+
modelId,
|
|
2155
|
+
});
|
|
2156
|
+
const payload = await engineRequestJson(session, "/session", {
|
|
2157
|
+
method: "POST",
|
|
2158
|
+
body: {
|
|
2159
|
+
title: `Swarm ${String(run?.run_id || "").trim()}`,
|
|
2160
|
+
directory: workspaceRoot,
|
|
2161
|
+
workspace_root: workspaceRoot,
|
|
2162
|
+
permission: [
|
|
2163
|
+
{ permission: "write", pattern: "*", action: "allow" },
|
|
2164
|
+
{ permission: "edit", pattern: "*", action: "allow" },
|
|
2165
|
+
{ permission: "apply_patch", pattern: "*", action: "allow" },
|
|
2166
|
+
{ permission: "read", pattern: "*", action: "allow" },
|
|
2167
|
+
{ permission: "glob", pattern: "*", action: "allow" },
|
|
2168
|
+
{ permission: "search", pattern: "*", action: "allow" },
|
|
2169
|
+
{ permission: "grep", pattern: "*", action: "allow" },
|
|
2170
|
+
{ permission: "codesearch", pattern: "*", action: "allow" },
|
|
2171
|
+
{ permission: "ls", pattern: "*", action: "allow" },
|
|
2172
|
+
{ permission: "list", pattern: "*", action: "allow" },
|
|
2173
|
+
{ permission: "todowrite", pattern: "*", action: "allow" },
|
|
2174
|
+
{ permission: "todo_write", pattern: "*", action: "allow" },
|
|
2175
|
+
{ permission: "update_todo_list", pattern: "*", action: "allow" },
|
|
2176
|
+
{ permission: "websearch", pattern: "*", action: "allow" },
|
|
2177
|
+
{ permission: "webfetch", pattern: "*", action: "allow" },
|
|
2178
|
+
{ permission: "webfetch_html", pattern: "*", action: "allow" },
|
|
2179
|
+
{ permission: "bash", pattern: "*", action: "deny" },
|
|
2180
|
+
{ permission: "task", pattern: "*", action: "deny" },
|
|
2181
|
+
{ permission: "spawn_agent", pattern: "*", action: "deny" },
|
|
2182
|
+
{ permission: "batch", pattern: "*", action: "deny" },
|
|
2183
|
+
],
|
|
2184
|
+
provider: modelProvider || undefined,
|
|
2185
|
+
model:
|
|
2186
|
+
modelProvider && modelId
|
|
2187
|
+
? {
|
|
2188
|
+
providerID: modelProvider,
|
|
2189
|
+
modelID: modelId,
|
|
2190
|
+
}
|
|
2191
|
+
: undefined,
|
|
2192
|
+
},
|
|
2193
|
+
});
|
|
2194
|
+
return String(payload?.id || "").trim();
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function workerExecutionToolAllowlist() {
|
|
2198
|
+
return [
|
|
2199
|
+
"ls",
|
|
2200
|
+
"list",
|
|
2201
|
+
"glob",
|
|
2202
|
+
"search",
|
|
2203
|
+
"grep",
|
|
2204
|
+
"codesearch",
|
|
2205
|
+
"read",
|
|
2206
|
+
"write",
|
|
2207
|
+
"edit",
|
|
2208
|
+
"apply_patch",
|
|
2209
|
+
];
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
function workerWriteRetryToolAllowlist() {
|
|
2213
|
+
return ["write", "edit", "apply_patch"];
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function strictWriteRetryEnabled() {
|
|
2217
|
+
const raw = String(process.env.TANDEM_STRICT_WRITE_RETRY_ENABLED || "").trim().toLowerCase();
|
|
2218
|
+
if (!raw) return true;
|
|
2219
|
+
return !["0", "false", "no", "off"].includes(raw);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function strictWriteRetryMaxAttempts() {
|
|
2223
|
+
const parsed = Number.parseInt(
|
|
2224
|
+
String(process.env.TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS || "3"),
|
|
2225
|
+
10
|
|
2226
|
+
);
|
|
2227
|
+
return Number.isFinite(parsed) ? Math.max(1, parsed) : 3;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function nonWritingRetryMaxAttempts() {
|
|
2231
|
+
const parsed = Number.parseInt(
|
|
2232
|
+
String(process.env.TANDEM_NON_WRITING_RETRY_MAX_ATTEMPTS || "2"),
|
|
2233
|
+
10
|
|
2234
|
+
);
|
|
2235
|
+
return Number.isFinite(parsed) ? Math.max(1, parsed) : 2;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function classifyStrictWriteFailureReason(verification) {
|
|
2239
|
+
const reason = String(verification?.reason || "").trim().toUpperCase();
|
|
2240
|
+
if (!reason || reason === "VERIFIED") return "";
|
|
2241
|
+
if (
|
|
2242
|
+
reason === "WRITE_ARGS_EMPTY_FROM_PROVIDER" ||
|
|
2243
|
+
reason === "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER" ||
|
|
2244
|
+
reason === "FILE_PATH_MISSING" ||
|
|
2245
|
+
reason === "WRITE_CONTENT_MISSING"
|
|
2246
|
+
) {
|
|
2247
|
+
return "malformed_write_args";
|
|
2248
|
+
}
|
|
2249
|
+
if (
|
|
2250
|
+
reason === "NO_WRITE_ACTIVITY_NO_WORKSPACE_CHANGE" ||
|
|
2251
|
+
reason === "WRITE_REQUIRED_NOT_SATISFIED" ||
|
|
2252
|
+
reason === "WRITE_TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE"
|
|
2253
|
+
) {
|
|
2254
|
+
return "write_required_unsatisfied";
|
|
2255
|
+
}
|
|
2256
|
+
if (
|
|
2257
|
+
reason === "TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE" ||
|
|
2258
|
+
reason === "NO_TOOL_ACTIVITY_NO_WORKSPACE_CHANGE"
|
|
2259
|
+
) {
|
|
2260
|
+
return "no_workspace_change";
|
|
2261
|
+
}
|
|
2262
|
+
return "";
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function buildStrictWriteRetryRequest(prompt, verification, attemptIndex, maxAttempts) {
|
|
2266
|
+
const failureClass = classifyStrictWriteFailureReason(verification);
|
|
2267
|
+
const needsInspection =
|
|
2268
|
+
Number(verification?.total_tool_calls || 0) === 0 &&
|
|
2269
|
+
Number(verification?.rejected_tool_calls || 0) === 0;
|
|
2270
|
+
const recoveryLines = [
|
|
2271
|
+
prompt,
|
|
2272
|
+
"",
|
|
2273
|
+
"[Strict Write Recovery]",
|
|
2274
|
+
`Attempt ${attemptIndex}/${maxAttempts} failed with ${failureClass || "strict_write_failure"}.`,
|
|
2275
|
+
"- You must satisfy strict write mode on this retry.",
|
|
2276
|
+
'- Valid write shape: {"path":"target-file","content":"full file contents"}',
|
|
2277
|
+
"- For file tools, include a non-empty `path` string.",
|
|
2278
|
+
"- For `write`, include a non-empty `content` string.",
|
|
2279
|
+
"- Do not use bash.",
|
|
2280
|
+
];
|
|
2281
|
+
if (needsInspection) {
|
|
2282
|
+
recoveryLines.push("- Use tools on this retry.");
|
|
2283
|
+
recoveryLines.push("- Inspect minimally with ls/list/glob/search/read if needed.");
|
|
2284
|
+
recoveryLines.push("- Then create or modify the required file in the same turn.");
|
|
2285
|
+
} else {
|
|
2286
|
+
recoveryLines.push("- Do not inspect further with read/search/glob/ls/list.");
|
|
2287
|
+
recoveryLines.push("- Create or modify the required target directly with write/edit/apply_patch.");
|
|
2288
|
+
}
|
|
2289
|
+
return {
|
|
2290
|
+
parts: [{ type: "text", text: recoveryLines.join("\n") }],
|
|
2291
|
+
tool_mode: "required",
|
|
2292
|
+
tool_allowlist: needsInspection
|
|
2293
|
+
? workerExecutionToolAllowlist()
|
|
2294
|
+
: workerWriteRetryToolAllowlist(),
|
|
2295
|
+
write_required: true,
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function classifyNonWritingFailureReason(verification) {
|
|
2300
|
+
const reason = String(verification?.reason || "").trim().toUpperCase();
|
|
2301
|
+
if (!reason || reason === "VERIFIED") return "";
|
|
2302
|
+
if (reason === "NO_TOOL_ACTIVITY_NO_DECISION" || reason === "NO_TOOL_ACTIVITY") {
|
|
2303
|
+
return "no_tool_activity";
|
|
2304
|
+
}
|
|
2305
|
+
if (reason === "DECISION_PAYLOAD_MISSING") return "decision_payload_missing";
|
|
2306
|
+
return "non_writing_verification_failed";
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function buildNonWritingRetryRequest(prompt, verification, attemptIndex, maxAttempts) {
|
|
2310
|
+
const failureClass = classifyNonWritingFailureReason(verification);
|
|
2311
|
+
const hasToolCalls =
|
|
2312
|
+
Number(verification?.total_tool_calls || 0) > 0 ||
|
|
2313
|
+
Number(verification?.rejected_tool_calls || 0) > 0;
|
|
2314
|
+
const recoveryLines = [
|
|
2315
|
+
prompt,
|
|
2316
|
+
"",
|
|
2317
|
+
"[Non-Writing Recovery]",
|
|
2318
|
+
`Attempt ${attemptIndex}/${maxAttempts} failed with ${failureClass || "verification_failed"}.`,
|
|
2319
|
+
"- This task is read-only and must use tools.",
|
|
2320
|
+
"- Allowed tools: ls, list, glob, search, grep, codesearch, read.",
|
|
2321
|
+
"- Do not call write/edit/apply_patch for this task.",
|
|
2322
|
+
'- Return strict JSON only: {"decision":{"summary":"...","evidence":["..."],"output_target":{"path":"...","kind":"artifact","operation":"create_or_update"}}}.',
|
|
2323
|
+
];
|
|
2324
|
+
if (!hasToolCalls) {
|
|
2325
|
+
recoveryLines.push("- Execute at least one read-only tool call before finalizing.");
|
|
2326
|
+
recoveryLines.push("- Keep tool usage minimal and directly relevant to the task.");
|
|
2327
|
+
} else {
|
|
2328
|
+
recoveryLines.push("- You already used tools; now return the required JSON decision payload.");
|
|
2329
|
+
recoveryLines.push("- Ensure the response is valid JSON and includes decision.summary.");
|
|
2330
|
+
}
|
|
2331
|
+
return {
|
|
2332
|
+
parts: [{ type: "text", text: recoveryLines.join("\n") }],
|
|
2333
|
+
tool_mode: "required",
|
|
2334
|
+
tool_allowlist: ["ls", "list", "glob", "search", "grep", "codesearch", "read"],
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
const WRITE_TOOL_NAMES = new Set(["write", "edit", "apply_patch"]);
|
|
2339
|
+
const REQUIRED_TOOL_MODE_REASON = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
|
|
2340
|
+
const REQUIRED_TOOL_REASON_PATTERN = new RegExp(
|
|
2341
|
+
`${REQUIRED_TOOL_MODE_REASON}:\\s*([A-Z_]+)\\b`
|
|
2342
|
+
);
|
|
2343
|
+
|
|
2344
|
+
function normalizeVerificationMode(mode) {
|
|
2345
|
+
return String(mode || "")
|
|
2346
|
+
.trim()
|
|
2347
|
+
.toLowerCase() === "lenient"
|
|
2348
|
+
? "lenient"
|
|
2349
|
+
: "strict";
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function normalizeToolName(tool) {
|
|
2353
|
+
const raw = String(tool || "")
|
|
2354
|
+
.trim()
|
|
2355
|
+
.toLowerCase();
|
|
2356
|
+
if (!raw) return "";
|
|
2357
|
+
const parts = raw.split(":").filter(Boolean);
|
|
2358
|
+
return parts.length ? parts[parts.length - 1] : raw;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function isWriteToolName(tool) {
|
|
2362
|
+
return WRITE_TOOL_NAMES.has(normalizeToolName(tool));
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function createExecutionError(message, details = {}) {
|
|
2366
|
+
const error = new Error(message);
|
|
2367
|
+
if (details?.sessionId) error.sessionId = String(details.sessionId || "").trim();
|
|
2368
|
+
if (details?.verification && typeof details.verification === "object") {
|
|
2369
|
+
error.verification = details.verification;
|
|
2370
|
+
}
|
|
2371
|
+
return error;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function normalizeWorkspaceRelativePath(pathname) {
|
|
2375
|
+
return String(pathname || "")
|
|
2376
|
+
.replace(/\\/g, "/")
|
|
2377
|
+
.replace(/^\.?\//, "")
|
|
2378
|
+
.replace(/^\/+/, "")
|
|
2379
|
+
.trim();
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function extractRequiredToolFailureReason(text) {
|
|
2383
|
+
const raw = String(text || "").trim();
|
|
2384
|
+
if (!raw) return "";
|
|
2385
|
+
const match = raw.match(REQUIRED_TOOL_REASON_PATTERN);
|
|
2386
|
+
return String(match?.[1] || "").trim().toUpperCase();
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function shouldTrackWorkspacePath(pathname) {
|
|
2390
|
+
const normalized = normalizeWorkspaceRelativePath(pathname);
|
|
2391
|
+
return (
|
|
2392
|
+
!!normalized &&
|
|
2393
|
+
normalized !== ".git" &&
|
|
2394
|
+
normalized !== ".tandem" &&
|
|
2395
|
+
!normalized.startsWith(".git/") &&
|
|
2396
|
+
!normalized.startsWith(".tandem/") &&
|
|
2397
|
+
!normalized.startsWith(".swarm/") &&
|
|
2398
|
+
!normalized.startsWith("node_modules/") &&
|
|
2399
|
+
!normalized.startsWith("dist/") &&
|
|
2400
|
+
!normalized.startsWith("target/")
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
async function fingerprintWorkspaceFile(pathname) {
|
|
2405
|
+
const info = await stat(pathname).catch(() => null);
|
|
2406
|
+
if (!info?.isFile()) return null;
|
|
2407
|
+
let contentHash = "";
|
|
2408
|
+
if (Number(info.size || 0) <= 1024 * 1024) {
|
|
2409
|
+
const bytes = await readFile(pathname).catch(() => null);
|
|
2410
|
+
if (bytes) {
|
|
2411
|
+
contentHash = createHash("sha1").update(bytes).digest("hex");
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return {
|
|
2415
|
+
size: Number(info.size || 0),
|
|
2416
|
+
modifiedMs: Number(info.mtimeMs || 0),
|
|
2417
|
+
contentHash,
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
function parseGitStatusEntries(output) {
|
|
2422
|
+
const chunks = String(output || "").split("\0");
|
|
2423
|
+
const entries = [];
|
|
2424
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
2425
|
+
const chunk = String(chunks[index] || "");
|
|
2426
|
+
if (!chunk) continue;
|
|
2427
|
+
const status = chunk.slice(0, 2);
|
|
2428
|
+
const primaryPath = normalizeWorkspaceRelativePath(chunk.slice(3));
|
|
2429
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
2430
|
+
const renamedPath = normalizeWorkspaceRelativePath(chunks[index + 1] || "");
|
|
2431
|
+
if (!renamedPath && !primaryPath) continue;
|
|
2432
|
+
entries.push({
|
|
2433
|
+
status,
|
|
2434
|
+
path: renamedPath || primaryPath,
|
|
2435
|
+
originalPath: primaryPath || "",
|
|
2436
|
+
});
|
|
2437
|
+
index += 1;
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
if (!primaryPath) continue;
|
|
2441
|
+
entries.push({
|
|
2442
|
+
status,
|
|
2443
|
+
path: primaryPath,
|
|
2444
|
+
originalPath: "",
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
return entries;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function buildGitStatusIndex(entries) {
|
|
2451
|
+
const statusByPath = Object.create(null);
|
|
2452
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
2453
|
+
const rawStatus = String(entry?.status || "")
|
|
2454
|
+
.trim()
|
|
2455
|
+
.toUpperCase();
|
|
2456
|
+
let normalized = "M";
|
|
2457
|
+
if (rawStatus === "??") normalized = "A";
|
|
2458
|
+
else if (rawStatus.startsWith("R") || rawStatus.startsWith("C"))
|
|
2459
|
+
normalized = rawStatus.slice(0, 1);
|
|
2460
|
+
else if (rawStatus.includes("D")) normalized = "D";
|
|
2461
|
+
else if (rawStatus.includes("A")) normalized = "A";
|
|
2462
|
+
if (entry?.originalPath && shouldTrackWorkspacePath(entry.originalPath)) {
|
|
2463
|
+
statusByPath[normalizeWorkspaceRelativePath(entry.originalPath)] = "D";
|
|
2464
|
+
}
|
|
2465
|
+
if (entry?.path && shouldTrackWorkspacePath(entry.path)) {
|
|
2466
|
+
statusByPath[normalizeWorkspaceRelativePath(entry.path)] = normalized;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
return statusByPath;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function collectWorkspaceSnapshotSeedPaths(includePaths = []) {
|
|
2473
|
+
return Array.from(
|
|
2474
|
+
new Set(
|
|
2475
|
+
(Array.isArray(includePaths) ? includePaths : [])
|
|
2476
|
+
.map((pathname) => normalizeWorkspaceRelativePath(pathname))
|
|
2477
|
+
.filter((pathname) => shouldTrackWorkspacePath(pathname))
|
|
2478
|
+
)
|
|
2479
|
+
).sort((a, b) => a.localeCompare(b));
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
async function captureGitWorkspaceSnapshot(workspaceRoot, includePaths = []) {
|
|
2483
|
+
const repo = await isGitRepo(workspaceRoot);
|
|
2484
|
+
if (!repo?.ok || !repo.root) return null;
|
|
2485
|
+
const gitStatus = await runGit(
|
|
2486
|
+
["-C", repo.root, "status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
2487
|
+
{
|
|
2488
|
+
stdio: "pipe",
|
|
2489
|
+
safeDirectory: repo.root,
|
|
2490
|
+
}
|
|
2491
|
+
).catch(() => null);
|
|
2492
|
+
if (!gitStatus) return null;
|
|
2493
|
+
const entries = parseGitStatusEntries(gitStatus.stdout || "");
|
|
2494
|
+
const files = Object.create(null);
|
|
2495
|
+
const candidatePaths = collectWorkspaceSnapshotSeedPaths([
|
|
2496
|
+
...entries.flatMap((entry) => [entry?.path || "", entry?.originalPath || ""]),
|
|
2497
|
+
...includePaths,
|
|
2498
|
+
]);
|
|
2499
|
+
for (const relativePath of candidatePaths) {
|
|
2500
|
+
const fingerprint = await fingerprintWorkspaceFile(join(repo.root, relativePath));
|
|
2501
|
+
if (fingerprint) files[relativePath] = fingerprint;
|
|
2502
|
+
}
|
|
2503
|
+
return {
|
|
2504
|
+
mode: "git_status",
|
|
2505
|
+
root: repo.root,
|
|
2506
|
+
files,
|
|
2507
|
+
statusByPath: buildGitStatusIndex(entries),
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
async function captureWorkspaceSnapshot(workspaceRoot, options = {}) {
|
|
2512
|
+
const root = await workspaceExistsAsDirectory(workspaceRoot);
|
|
2513
|
+
if (!root) {
|
|
2514
|
+
throw new Error(
|
|
2515
|
+
`Workspace snapshot root not found: ${resolve(String(workspaceRoot || REPO_ROOT))}`
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
const includePaths = collectWorkspaceSnapshotSeedPaths(options?.includePaths);
|
|
2519
|
+
const gitSnapshot = await captureGitWorkspaceSnapshot(root, includePaths);
|
|
2520
|
+
if (gitSnapshot) return gitSnapshot;
|
|
2521
|
+
const files = Object.create(null);
|
|
2522
|
+
async function walk(dirPath) {
|
|
2523
|
+
const entries = await readdir(dirPath, { withFileTypes: true }).catch(() => []);
|
|
2524
|
+
for (const entry of entries) {
|
|
2525
|
+
const absolutePath = join(dirPath, entry.name);
|
|
2526
|
+
const relativePath = normalizeWorkspaceRelativePath(relative(root, absolutePath));
|
|
2527
|
+
if (!shouldTrackWorkspacePath(relativePath)) continue;
|
|
2528
|
+
if (entry.isDirectory()) {
|
|
2529
|
+
await walk(absolutePath);
|
|
2530
|
+
continue;
|
|
2531
|
+
}
|
|
2532
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
2533
|
+
const fingerprint = await fingerprintWorkspaceFile(absolutePath);
|
|
2534
|
+
if (fingerprint) files[relativePath] = fingerprint;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
await walk(root);
|
|
2538
|
+
return {
|
|
2539
|
+
mode: "filesystem_fingerprint",
|
|
2540
|
+
root,
|
|
2541
|
+
files,
|
|
2542
|
+
statusByPath: Object.create(null),
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function summarizeWorkspaceChanges(beforeSnapshot, afterSnapshot) {
|
|
2547
|
+
const beforeFiles =
|
|
2548
|
+
beforeSnapshot?.files && typeof beforeSnapshot.files === "object" ? beforeSnapshot.files : {};
|
|
2549
|
+
const afterFiles =
|
|
2550
|
+
afterSnapshot?.files && typeof afterSnapshot.files === "object" ? afterSnapshot.files : {};
|
|
2551
|
+
const beforeStatusByPath =
|
|
2552
|
+
beforeSnapshot?.statusByPath && typeof beforeSnapshot.statusByPath === "object"
|
|
2553
|
+
? beforeSnapshot.statusByPath
|
|
2554
|
+
: {};
|
|
2555
|
+
const afterStatusByPath =
|
|
2556
|
+
afterSnapshot?.statusByPath && typeof afterSnapshot.statusByPath === "object"
|
|
2557
|
+
? afterSnapshot.statusByPath
|
|
2558
|
+
: {};
|
|
2559
|
+
const created = [];
|
|
2560
|
+
const updated = [];
|
|
2561
|
+
const deleted = [];
|
|
2562
|
+
const allPaths = new Set([
|
|
2563
|
+
...Object.keys(beforeFiles),
|
|
2564
|
+
...Object.keys(afterFiles),
|
|
2565
|
+
...Object.keys(beforeStatusByPath),
|
|
2566
|
+
...Object.keys(afterStatusByPath),
|
|
2567
|
+
]);
|
|
2568
|
+
for (const pathname of Array.from(allPaths)) {
|
|
2569
|
+
const beforeFingerprint = beforeFiles[pathname];
|
|
2570
|
+
const afterFingerprint = afterFiles[pathname];
|
|
2571
|
+
const afterStatus = String(afterStatusByPath[pathname] || "")
|
|
2572
|
+
.trim()
|
|
2573
|
+
.toUpperCase();
|
|
2574
|
+
const beforeStatus = String(beforeStatusByPath[pathname] || "")
|
|
2575
|
+
.trim()
|
|
2576
|
+
.toUpperCase();
|
|
2577
|
+
if (!beforeFingerprint && !afterFingerprint) {
|
|
2578
|
+
if (afterStatus === "D") deleted.push(pathname);
|
|
2579
|
+
else if (afterStatus || beforeStatus) updated.push(pathname);
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
if (!beforeFingerprint && afterFingerprint) {
|
|
2583
|
+
if (afterStatus === "A" || afterStatus === "C" || afterStatus === "R") created.push(pathname);
|
|
2584
|
+
else updated.push(pathname);
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (beforeFingerprint && !afterFingerprint) {
|
|
2588
|
+
deleted.push(pathname);
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
if (
|
|
2592
|
+
Number(beforeFingerprint?.size || 0) !== Number(afterFingerprint?.size || 0) ||
|
|
2593
|
+
Number(beforeFingerprint?.modifiedMs || 0) !== Number(afterFingerprint?.modifiedMs || 0) ||
|
|
2594
|
+
String(beforeFingerprint?.contentHash || "") !== String(afterFingerprint?.contentHash || "")
|
|
2595
|
+
) {
|
|
2596
|
+
updated.push(pathname);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
created.sort((a, b) => a.localeCompare(b));
|
|
2600
|
+
updated.sort((a, b) => a.localeCompare(b));
|
|
2601
|
+
deleted.sort((a, b) => a.localeCompare(b));
|
|
2602
|
+
const lines = [
|
|
2603
|
+
`Workspace changes: ${created.length} created, ${updated.length} updated, ${deleted.length} deleted`,
|
|
2604
|
+
...created.slice(0, 20).map((pathname) => `+ ${pathname}`),
|
|
2605
|
+
...updated.slice(0, 40).map((pathname) => `~ ${pathname}`),
|
|
2606
|
+
...deleted.slice(0, 20).map((pathname) => `- ${pathname}`),
|
|
2607
|
+
];
|
|
2608
|
+
return {
|
|
2609
|
+
mode: String(afterSnapshot?.mode || beforeSnapshot?.mode || "unknown"),
|
|
2610
|
+
hasChanges: created.length > 0 || updated.length > 0 || deleted.length > 0,
|
|
2611
|
+
created,
|
|
2612
|
+
updated,
|
|
2613
|
+
deleted,
|
|
2614
|
+
paths: [...created, ...updated, ...deleted],
|
|
2615
|
+
summary: lines.join("\n"),
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function emptyToolActivityAudit(source = "") {
|
|
2620
|
+
return {
|
|
2621
|
+
source: source || "",
|
|
2622
|
+
totalToolCalls: 0,
|
|
2623
|
+
writeToolCalls: 0,
|
|
2624
|
+
rejectedToolCalls: 0,
|
|
2625
|
+
rejectedWriteToolCalls: 0,
|
|
2626
|
+
toolNames: [],
|
|
2627
|
+
rejectedToolNames: [],
|
|
2628
|
+
rejectionReasons: [],
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function extractToolFailureSignalsFromText(text) {
|
|
2633
|
+
const raw = String(text || "").trim();
|
|
2634
|
+
if (!raw) return [];
|
|
2635
|
+
const failures = [];
|
|
2636
|
+
const knownReasons = [
|
|
2637
|
+
"FILE_PATH_MISSING",
|
|
2638
|
+
"WRITE_CONTENT_MISSING",
|
|
2639
|
+
"WRITE_ARGS_EMPTY_FROM_PROVIDER",
|
|
2640
|
+
"WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
|
|
2641
|
+
"WEBFETCH_URL_MISSING",
|
|
2642
|
+
"WEBSEARCH_QUERY_MISSING",
|
|
2643
|
+
"BASH_COMMAND_MISSING",
|
|
2644
|
+
"PACK_BUILDER_PLAN_ID_MISSING",
|
|
2645
|
+
"PACK_BUILDER_GOAL_MISSING",
|
|
2646
|
+
"TOOL_ARGUMENTS_MISSING",
|
|
2647
|
+
];
|
|
2648
|
+
for (const reason of knownReasons) {
|
|
2649
|
+
if (raw.includes(reason)) failures.push({ tool: "", reason });
|
|
2650
|
+
}
|
|
2651
|
+
for (const match of raw.matchAll(/Permission denied for tool `([^`]+)`/g)) {
|
|
2652
|
+
failures.push({ tool: match[1], reason: "PERMISSION_DENIED" });
|
|
2653
|
+
}
|
|
2654
|
+
for (const match of raw.matchAll(/Tool `([^`]+)` is not allowed/g)) {
|
|
2655
|
+
failures.push({ tool: match[1], reason: "TOOL_NOT_ALLOWED" });
|
|
2656
|
+
}
|
|
2657
|
+
for (const tool of ["read", "write", "edit", "apply_patch", "glob", "list", "ls"]) {
|
|
2658
|
+
const failedPattern = new RegExp(`(?:\`|\\b)${tool}(?:\`|\\b)[^\\n.]{0,120}\\bfailed\\b`, "i");
|
|
2659
|
+
if (failedPattern.test(raw)) {
|
|
2660
|
+
failures.push({ tool, reason: "TOOL_CALL_FAILED" });
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
return failures;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
function collectToolActivity(rows, source = "") {
|
|
2667
|
+
if (!Array.isArray(rows)) return emptyToolActivityAudit(source);
|
|
2668
|
+
const toolNames = new Set();
|
|
2669
|
+
const rejectedToolNames = new Set();
|
|
2670
|
+
const rejectionReasons = new Set();
|
|
2671
|
+
let totalToolCalls = 0;
|
|
2672
|
+
let writeToolCalls = 0;
|
|
2673
|
+
let rejectedToolCalls = 0;
|
|
2674
|
+
let rejectedWriteToolCalls = 0;
|
|
2675
|
+
const recordTool = (tool, options = {}) => {
|
|
2676
|
+
const normalized = normalizeToolName(tool) || "tool";
|
|
2677
|
+
const rejected = options?.rejected === true;
|
|
2678
|
+
const reason = String(options?.reason || "").trim();
|
|
2679
|
+
if (rejected) {
|
|
2680
|
+
rejectedToolCalls += 1;
|
|
2681
|
+
rejectedToolNames.add(normalized);
|
|
2682
|
+
if (isWriteToolName(normalized)) rejectedWriteToolCalls += 1;
|
|
2683
|
+
if (reason) rejectionReasons.add(reason);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
totalToolCalls += 1;
|
|
2687
|
+
toolNames.add(normalized);
|
|
2688
|
+
if (isWriteToolName(normalized)) writeToolCalls += 1;
|
|
2689
|
+
};
|
|
2690
|
+
for (const row of rows) {
|
|
2691
|
+
const parts = Array.isArray(row?.parts) ? row.parts : [];
|
|
2692
|
+
let rowRecorded = false;
|
|
2693
|
+
for (const part of parts) {
|
|
2694
|
+
const partType = String(part?.type || part?.part_type || "")
|
|
2695
|
+
.trim()
|
|
2696
|
+
.toLowerCase();
|
|
2697
|
+
const partTool = String(part?.tool || part?.name || "").trim();
|
|
2698
|
+
if (!partType.includes("tool") && !partTool) continue;
|
|
2699
|
+
const rejected =
|
|
2700
|
+
String(part?.state || "")
|
|
2701
|
+
.trim()
|
|
2702
|
+
.toLowerCase() === "failed" || !!String(part?.error || "").trim();
|
|
2703
|
+
recordTool(partTool, {
|
|
2704
|
+
rejected,
|
|
2705
|
+
reason: String(part?.error || "").trim(),
|
|
2706
|
+
});
|
|
2707
|
+
rowRecorded = true;
|
|
2708
|
+
}
|
|
2709
|
+
if (!rowRecorded) {
|
|
2710
|
+
const rowType = String(row?.type || "")
|
|
2711
|
+
.trim()
|
|
2712
|
+
.toLowerCase();
|
|
2713
|
+
const rowTool = String(row?.tool || row?.name || "").trim();
|
|
2714
|
+
if (rowType.includes("tool") || rowTool) {
|
|
2715
|
+
const rejected =
|
|
2716
|
+
String(row?.state || "")
|
|
2717
|
+
.trim()
|
|
2718
|
+
.toLowerCase() === "failed" || !!String(row?.error || "").trim();
|
|
2719
|
+
recordTool(rowTool, {
|
|
2720
|
+
rejected,
|
|
2721
|
+
reason: String(row?.error || "").trim(),
|
|
2722
|
+
});
|
|
2723
|
+
rowRecorded = true;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
if (rowRecorded) continue;
|
|
2727
|
+
for (const failure of extractToolFailureSignalsFromText(textOfMessage(row))) {
|
|
2728
|
+
recordTool(failure.tool, {
|
|
2729
|
+
rejected: true,
|
|
2730
|
+
reason: failure.reason,
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return {
|
|
2735
|
+
source: source || "",
|
|
2736
|
+
totalToolCalls,
|
|
2737
|
+
writeToolCalls,
|
|
2738
|
+
rejectedToolCalls,
|
|
2739
|
+
rejectedWriteToolCalls,
|
|
2740
|
+
toolNames: Array.from(toolNames).sort((a, b) => a.localeCompare(b)),
|
|
2741
|
+
rejectedToolNames: Array.from(rejectedToolNames).sort((a, b) => a.localeCompare(b)),
|
|
2742
|
+
rejectionReasons: Array.from(rejectionReasons).sort((a, b) => a.localeCompare(b)),
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function mergeToolActivityAudits(...audits) {
|
|
2747
|
+
const valid = audits.filter((audit) => audit && typeof audit === "object");
|
|
2748
|
+
const toolNames = new Set();
|
|
2749
|
+
const rejectedToolNames = new Set();
|
|
2750
|
+
const rejectionReasons = new Set();
|
|
2751
|
+
const sources = [];
|
|
2752
|
+
let totalToolCalls = 0;
|
|
2753
|
+
let writeToolCalls = 0;
|
|
2754
|
+
let rejectedToolCalls = 0;
|
|
2755
|
+
let rejectedWriteToolCalls = 0;
|
|
2756
|
+
for (const audit of valid) {
|
|
2757
|
+
totalToolCalls = Math.max(totalToolCalls, Number(audit?.totalToolCalls || 0));
|
|
2758
|
+
writeToolCalls = Math.max(writeToolCalls, Number(audit?.writeToolCalls || 0));
|
|
2759
|
+
rejectedToolCalls = Math.max(rejectedToolCalls, Number(audit?.rejectedToolCalls || 0));
|
|
2760
|
+
rejectedWriteToolCalls = Math.max(
|
|
2761
|
+
rejectedWriteToolCalls,
|
|
2762
|
+
Number(audit?.rejectedWriteToolCalls || 0)
|
|
2763
|
+
);
|
|
2764
|
+
if (
|
|
2765
|
+
audit?.source &&
|
|
2766
|
+
(audit.totalToolCalls ||
|
|
2767
|
+
audit.writeToolCalls ||
|
|
2768
|
+
audit.rejectedToolCalls ||
|
|
2769
|
+
audit.rejectedWriteToolCalls)
|
|
2770
|
+
) {
|
|
2771
|
+
sources.push(String(audit.source));
|
|
2772
|
+
}
|
|
2773
|
+
for (const tool of Array.isArray(audit?.toolNames) ? audit.toolNames : []) {
|
|
2774
|
+
const normalized = normalizeToolName(tool);
|
|
2775
|
+
if (normalized) toolNames.add(normalized);
|
|
2776
|
+
}
|
|
2777
|
+
for (const tool of Array.isArray(audit?.rejectedToolNames) ? audit.rejectedToolNames : []) {
|
|
2778
|
+
const normalized = normalizeToolName(tool);
|
|
2779
|
+
if (normalized) rejectedToolNames.add(normalized);
|
|
2780
|
+
}
|
|
2781
|
+
for (const reason of Array.isArray(audit?.rejectionReasons) ? audit.rejectionReasons : []) {
|
|
2782
|
+
const normalized = String(reason || "").trim();
|
|
2783
|
+
if (normalized) rejectionReasons.add(normalized);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
return {
|
|
2787
|
+
totalToolCalls,
|
|
2788
|
+
writeToolCalls,
|
|
2789
|
+
rejectedToolCalls,
|
|
2790
|
+
rejectedWriteToolCalls,
|
|
2791
|
+
toolNames: Array.from(toolNames).sort((a, b) => a.localeCompare(b)),
|
|
2792
|
+
rejectedToolNames: Array.from(rejectedToolNames).sort((a, b) => a.localeCompare(b)),
|
|
2793
|
+
rejectionReasons: Array.from(rejectionReasons).sort((a, b) => a.localeCompare(b)),
|
|
2794
|
+
sources: Array.from(new Set(sources)),
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
function selectProviderWriteFailureReason(reasons) {
|
|
2799
|
+
const list = Array.isArray(reasons) ? reasons : [];
|
|
2800
|
+
if (list.includes("WRITE_ARGS_EMPTY_FROM_PROVIDER")) return "WRITE_ARGS_EMPTY_FROM_PROVIDER";
|
|
2801
|
+
if (list.includes("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER")) {
|
|
2802
|
+
return "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER";
|
|
2803
|
+
}
|
|
2804
|
+
return "";
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function summarizeExecutionRows(rows, limit = 12) {
|
|
2808
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
2809
|
+
const out = [];
|
|
2810
|
+
for (const row of list) {
|
|
2811
|
+
if (out.length >= limit) break;
|
|
2812
|
+
const role = roleOfMessage(row);
|
|
2813
|
+
const type = String(row?.type || "").trim().toLowerCase();
|
|
2814
|
+
const text = textOfMessage(row).trim();
|
|
2815
|
+
const parts = Array.isArray(row?.parts) ? row.parts : [];
|
|
2816
|
+
const tools = [];
|
|
2817
|
+
for (const part of parts) {
|
|
2818
|
+
const tool = String(part?.tool || part?.name || "").trim();
|
|
2819
|
+
if (tool) tools.push(normalizeToolName(tool) || tool);
|
|
2820
|
+
}
|
|
2821
|
+
const rowTool = String(row?.tool || row?.name || "").trim();
|
|
2822
|
+
if (rowTool) tools.push(normalizeToolName(rowTool) || rowTool);
|
|
2823
|
+
out.push({
|
|
2824
|
+
role,
|
|
2825
|
+
type: type || null,
|
|
2826
|
+
tools: Array.from(new Set(tools)).slice(0, 8),
|
|
2827
|
+
error: String(row?.error || "").trim() || null,
|
|
2828
|
+
excerpt: text.slice(0, 240),
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
return out;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
function buildAttemptTelemetry(name, request, rows, messages, startedAtMs, error = null, meta = {}) {
|
|
2835
|
+
const syncAudit = collectToolActivity(rows, `${name}_prompt_sync`);
|
|
2836
|
+
const sessionAudit = collectToolActivity(messages, `${name}_session_snapshot`);
|
|
2837
|
+
const merged = mergeToolActivityAudits(syncAudit, sessionAudit);
|
|
2838
|
+
const assistantText = extractAssistantText(rows) || extractAssistantText(messages);
|
|
2839
|
+
return {
|
|
2840
|
+
name,
|
|
2841
|
+
attempt_index: Number(meta?.attemptIndex || 0),
|
|
2842
|
+
strict_write_failure_class: String(meta?.failureClass || "").trim() || null,
|
|
2843
|
+
strict_write_retry_remaining: Number(meta?.retryRemaining || 0),
|
|
2844
|
+
started_at_ms: Number(startedAtMs || Date.now()),
|
|
2845
|
+
tool_mode: String(request?.tool_mode || "").trim() || null,
|
|
2846
|
+
tool_allowlist: Array.isArray(request?.tool_allowlist) ? request.tool_allowlist.slice() : [],
|
|
2847
|
+
prompt_excerpt: String(request?.parts?.[0]?.text || "")
|
|
2848
|
+
.trim()
|
|
2849
|
+
.slice(0, 600),
|
|
2850
|
+
assistant_present: !!assistantText.trim(),
|
|
2851
|
+
assistant_excerpt: assistantText.trim().slice(0, 400),
|
|
2852
|
+
any_tool_attempts: merged.totalToolCalls > 0 || merged.rejectedToolCalls > 0,
|
|
2853
|
+
total_tool_calls: merged.totalToolCalls,
|
|
2854
|
+
write_tool_calls: merged.writeToolCalls,
|
|
2855
|
+
rejected_tool_calls: merged.rejectedToolCalls,
|
|
2856
|
+
rejected_write_tool_calls: merged.rejectedWriteToolCalls,
|
|
2857
|
+
tool_names: merged.toolNames,
|
|
2858
|
+
rejected_tool_names: merged.rejectedToolNames,
|
|
2859
|
+
rejection_reasons: merged.rejectionReasons,
|
|
2860
|
+
detection_sources: merged.sources,
|
|
2861
|
+
sync_row_count: Array.isArray(rows) ? rows.length : 0,
|
|
2862
|
+
session_message_count: Array.isArray(messages) ? messages.length : 0,
|
|
2863
|
+
sync_rows: summarizeExecutionRows(rows),
|
|
2864
|
+
session_rows: summarizeExecutionRows(messages),
|
|
2865
|
+
error: error ? String(error?.message || error || "").trim() : "",
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function buildVerificationSummary(syncRows, messages, workspaceChanges, sessionId, options = {}) {
|
|
2870
|
+
const syncAudit = collectToolActivity(syncRows, "prompt_sync");
|
|
2871
|
+
const sessionAudit = collectToolActivity(messages, "session_snapshot");
|
|
2872
|
+
const toolAudit = mergeToolActivityAudits(syncAudit, sessionAudit);
|
|
2873
|
+
const assistantText = extractAssistantText(syncRows) || extractAssistantText(messages);
|
|
2874
|
+
const mode = normalizeVerificationMode(options.verificationMode || swarmState.verificationMode);
|
|
2875
|
+
const requiredToolModeUnsatisfied = assistantText.includes(REQUIRED_TOOL_MODE_REASON);
|
|
2876
|
+
const requiredToolFailureReason = extractRequiredToolFailureReason(assistantText);
|
|
2877
|
+
const providerWriteFailureReason = selectProviderWriteFailureReason(toolAudit.rejectionReasons);
|
|
2878
|
+
const workspaceChanged = workspaceChanges?.hasChanges === true;
|
|
2879
|
+
const strictMode = mode === "strict";
|
|
2880
|
+
const passed = strictMode
|
|
2881
|
+
? workspaceChanged || toolAudit.writeToolCalls > 0
|
|
2882
|
+
: workspaceChanged || toolAudit.totalToolCalls > 0;
|
|
2883
|
+
let reason = "VERIFIED";
|
|
2884
|
+
if (!passed) {
|
|
2885
|
+
if (providerWriteFailureReason) reason = providerWriteFailureReason;
|
|
2886
|
+
else if (requiredToolFailureReason) reason = requiredToolFailureReason;
|
|
2887
|
+
else if (requiredToolModeUnsatisfied) reason = REQUIRED_TOOL_MODE_REASON;
|
|
2888
|
+
else if (toolAudit.rejectedWriteToolCalls > 0)
|
|
2889
|
+
reason = "WRITE_TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE";
|
|
2890
|
+
else if (toolAudit.rejectedToolCalls > 0)
|
|
2891
|
+
reason = "TOOL_ATTEMPT_REJECTED_NO_WORKSPACE_CHANGE";
|
|
2892
|
+
else if (strictMode && toolAudit.totalToolCalls > 0)
|
|
2893
|
+
reason = "NO_WRITE_ACTIVITY_NO_WORKSPACE_CHANGE";
|
|
2894
|
+
else reason = "NO_TOOL_ACTIVITY_NO_WORKSPACE_CHANGE";
|
|
2895
|
+
}
|
|
2896
|
+
return {
|
|
2897
|
+
mode,
|
|
2898
|
+
reason,
|
|
2899
|
+
passed,
|
|
2900
|
+
session_id: String(sessionId || "").trim() || null,
|
|
2901
|
+
assistant_present: !!assistantText.trim(),
|
|
2902
|
+
assistant_excerpt: assistantText.trim().slice(0, 280),
|
|
2903
|
+
any_tool_attempts: toolAudit.totalToolCalls > 0 || toolAudit.rejectedToolCalls > 0,
|
|
2904
|
+
any_tool_calls: toolAudit.totalToolCalls > 0,
|
|
2905
|
+
total_tool_calls: toolAudit.totalToolCalls,
|
|
2906
|
+
write_tool_calls: toolAudit.writeToolCalls,
|
|
2907
|
+
tool_names: toolAudit.toolNames,
|
|
2908
|
+
rejected_tool_calls: toolAudit.rejectedToolCalls,
|
|
2909
|
+
rejected_write_tool_calls: toolAudit.rejectedWriteToolCalls,
|
|
2910
|
+
rejected_tool_names: toolAudit.rejectedToolNames,
|
|
2911
|
+
rejection_reasons: toolAudit.rejectionReasons,
|
|
2912
|
+
detection_sources: toolAudit.sources,
|
|
2913
|
+
workspace_changed: workspaceChanged,
|
|
2914
|
+
workspace_change_mode: String(workspaceChanges?.mode || "unknown"),
|
|
2915
|
+
workspace_change_paths: Array.isArray(workspaceChanges?.paths)
|
|
2916
|
+
? workspaceChanges.paths.slice(0, 80)
|
|
2917
|
+
: [],
|
|
2918
|
+
workspace_change_summary: String(workspaceChanges?.summary || "").trim(),
|
|
2919
|
+
execution_trace:
|
|
2920
|
+
options?.executionTrace && typeof options.executionTrace === "object"
|
|
2921
|
+
? options.executionTrace
|
|
2922
|
+
: undefined,
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function hasToolActivity(rows) {
|
|
2927
|
+
if (!Array.isArray(rows)) return false;
|
|
2928
|
+
return rows.some((row) => {
|
|
2929
|
+
const rowType = String(row?.type || "")
|
|
2930
|
+
.trim()
|
|
2931
|
+
.toLowerCase();
|
|
2932
|
+
if (rowType.includes("tool")) return true;
|
|
2933
|
+
const rowTool = String(row?.tool || "")
|
|
2934
|
+
.trim()
|
|
2935
|
+
.toLowerCase();
|
|
2936
|
+
if (rowTool) return true;
|
|
2937
|
+
const parts = Array.isArray(row?.parts) ? row.parts : [];
|
|
2938
|
+
return parts.some((part) => {
|
|
2939
|
+
const partType = String(part?.type || part?.part_type || "")
|
|
2940
|
+
.trim()
|
|
2941
|
+
.toLowerCase();
|
|
2942
|
+
if (partType.includes("tool")) return true;
|
|
2943
|
+
return String(part?.tool || "").trim().length > 0;
|
|
2944
|
+
});
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
let cachedEngineDefaultModel = {
|
|
2949
|
+
provider: "",
|
|
2950
|
+
model: "",
|
|
2951
|
+
fetchedAtMs: 0,
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
async function fetchEngineDefaultModel(session) {
|
|
2955
|
+
const now = Date.now();
|
|
2956
|
+
if (cachedEngineDefaultModel.fetchedAtMs && now - cachedEngineDefaultModel.fetchedAtMs < 15000) {
|
|
2957
|
+
return { provider: cachedEngineDefaultModel.provider, model: cachedEngineDefaultModel.model };
|
|
2958
|
+
}
|
|
2959
|
+
const payload = await engineRequestJson(session, "/config/providers").catch(() => ({}));
|
|
2960
|
+
const defaultProvider = String(payload?.default || "").trim();
|
|
2961
|
+
const providers =
|
|
2962
|
+
payload?.providers && typeof payload.providers === "object" ? payload.providers : {};
|
|
2963
|
+
const defaultModel = String(providers?.[defaultProvider]?.default_model || "").trim();
|
|
2964
|
+
cachedEngineDefaultModel = {
|
|
2965
|
+
provider: defaultProvider,
|
|
2966
|
+
model: defaultModel,
|
|
2967
|
+
fetchedAtMs: now,
|
|
2968
|
+
};
|
|
2969
|
+
return { provider: defaultProvider, model: defaultModel };
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
async function resolveExecutionModel(session, run) {
|
|
2973
|
+
const runProvider = String(run?.model_provider || "").trim();
|
|
2974
|
+
const runModel = String(run?.model_id || "").trim();
|
|
2975
|
+
if (runProvider && runModel) {
|
|
2976
|
+
return { provider: runProvider, model: runModel, source: "run" };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
const controller = getSwarmRunController(String(run?.run_id || "").trim());
|
|
2980
|
+
const swarmProvider = String(controller?.modelProvider || swarmState.modelProvider || "").trim();
|
|
2981
|
+
const swarmModel = String(controller?.modelId || swarmState.modelId || "").trim();
|
|
2982
|
+
if (swarmProvider && swarmModel) {
|
|
2983
|
+
return { provider: swarmProvider, model: swarmModel, source: "swarm_state" };
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
const defaults = await fetchEngineDefaultModel(session);
|
|
2987
|
+
if (defaults.provider && defaults.model) {
|
|
2988
|
+
return { provider: defaults.provider, model: defaults.model, source: "engine_default" };
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
throw new Error("MODEL_SELECTION_REQUIRED: no provider/model configured for swarm execution.");
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function normalizeSessionModelRef(value) {
|
|
2995
|
+
if (typeof value === "string") return value.trim();
|
|
2996
|
+
if (value && typeof value === "object") {
|
|
2997
|
+
const model =
|
|
2998
|
+
String(
|
|
2999
|
+
value?.model_id || value?.id || value?.name || value?.slug || value?.model || ""
|
|
3000
|
+
).trim();
|
|
3001
|
+
if (model) return model;
|
|
3002
|
+
}
|
|
3003
|
+
return "";
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function summarizeRunStepsForPrompt(run, currentStepId, limit = 12) {
|
|
3007
|
+
const steps = Array.isArray(run?.steps) ? run.steps : [];
|
|
3008
|
+
return steps
|
|
3009
|
+
.slice(0, Math.max(1, Number(limit) || 12))
|
|
3010
|
+
.map((row, index) => {
|
|
3011
|
+
const stepId = String(row?.step_id || `step-${index + 1}`).trim();
|
|
3012
|
+
const title = String(row?.title || stepId).trim();
|
|
3013
|
+
const status = String(row?.status || "unknown").trim().toLowerCase();
|
|
3014
|
+
const marker = stepId === currentStepId ? "*" : "-";
|
|
3015
|
+
return `${marker} ${stepId} [${status}]: ${title}`;
|
|
3016
|
+
})
|
|
3017
|
+
.join("\n")
|
|
3018
|
+
.trim();
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function stepPromptText(run, step, stepIndex, totalSteps) {
|
|
3022
|
+
const stepId = String(step?.step_id || "").trim() || `step-${stepIndex + 1}`;
|
|
3023
|
+
const stepTitle = String(step?.title || stepId).trim();
|
|
3024
|
+
const stepDetails =
|
|
3025
|
+
step && typeof step === "object" ? JSON.stringify(step, null, 2).trim() : "";
|
|
3026
|
+
const stepList = summarizeRunStepsForPrompt(run, stepId);
|
|
3027
|
+
return [
|
|
3028
|
+
"Execute this swarm step.",
|
|
3029
|
+
"",
|
|
3030
|
+
`Step (${stepIndex + 1}/${totalSteps}): ${stepTitle}`,
|
|
3031
|
+
`Step ID: ${stepId}`,
|
|
3032
|
+
`Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
|
|
3033
|
+
"",
|
|
3034
|
+
"This step is already planned and assigned.",
|
|
3035
|
+
"Treat the current assigned step as the authority for what to implement.",
|
|
3036
|
+
"Use the original objective and step list only to clarify the assigned step, not to re-plan the run.",
|
|
3037
|
+
"Do not create a new plan, do not restate the task graph, and do not describe future work.",
|
|
3038
|
+
"Use workspace tools to implement this step now.",
|
|
3039
|
+
"",
|
|
3040
|
+
"Current assigned step payload:",
|
|
3041
|
+
stepDetails || "{}",
|
|
3042
|
+
"",
|
|
3043
|
+
"Run step list:",
|
|
3044
|
+
stepList || "(no step list available)",
|
|
3045
|
+
"",
|
|
3046
|
+
`Original objective: ${String(run?.objective || "").trim()}`,
|
|
3047
|
+
"",
|
|
3048
|
+
"Requirements:",
|
|
3049
|
+
"- First inspect the relevant workspace files with read/glob/list/search if needed.",
|
|
3050
|
+
"- Use list/ls/glob for directories. Use read only for concrete file paths.",
|
|
3051
|
+
"- Make the required code/project changes for this step right now.",
|
|
3052
|
+
"- Create or edit files as needed for this step only.",
|
|
3053
|
+
"- If no relevant file exists yet, create the correct new file directly.",
|
|
3054
|
+
"- A write call must include both a path and the full content to write.",
|
|
3055
|
+
"- Use write/edit/apply_patch instead of a prose-only response.",
|
|
3056
|
+
"- Keep scope limited to this step.",
|
|
3057
|
+
"- Return a concise summary of the concrete file changes and blockers.",
|
|
3058
|
+
].join("\n");
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function rowsSinceAttemptStart(rows, startIndex = 0) {
|
|
3062
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
3063
|
+
const offset = Math.max(0, Number(startIndex) || 0);
|
|
3064
|
+
if (!offset) return list;
|
|
3065
|
+
return list.length > offset ? list.slice(offset) : list;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
function parseDecisionPayload(text) {
|
|
3069
|
+
const candidate = String(text || "").trim();
|
|
3070
|
+
if (!candidate) return null;
|
|
3071
|
+
const fencedJson = candidate.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3072
|
+
const raw = (fencedJson?.[1] || candidate).trim();
|
|
3073
|
+
try {
|
|
3074
|
+
const parsed = JSON.parse(raw);
|
|
3075
|
+
return parsed && typeof parsed === "object" && parsed.decision ? parsed : null;
|
|
3076
|
+
} catch {
|
|
3077
|
+
return null;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
function buildNonWritingVerificationSummary(syncRows, messages, sessionId, options = {}) {
|
|
3082
|
+
const syncAudit = collectToolActivity(syncRows, "prompt_sync");
|
|
3083
|
+
const sessionAudit = collectToolActivity(messages, "session_snapshot");
|
|
3084
|
+
const toolAudit = mergeToolActivityAudits(syncAudit, sessionAudit);
|
|
3085
|
+
const assistantText = extractAssistantText(syncRows) || extractAssistantText(messages);
|
|
3086
|
+
const decisionPayload = parseDecisionPayload(assistantText);
|
|
3087
|
+
const passed = toolAudit.totalToolCalls > 0 && !!decisionPayload;
|
|
3088
|
+
const hasToolCalls = toolAudit.totalToolCalls > 0;
|
|
3089
|
+
const hasDecisionPayload = !!decisionPayload;
|
|
3090
|
+
let reason = "VERIFIED";
|
|
3091
|
+
if (!passed) {
|
|
3092
|
+
if (!hasToolCalls && !hasDecisionPayload) reason = "NO_TOOL_ACTIVITY_NO_DECISION";
|
|
3093
|
+
else if (!hasToolCalls) reason = "NO_TOOL_ACTIVITY";
|
|
3094
|
+
else reason = "DECISION_PAYLOAD_MISSING";
|
|
3095
|
+
}
|
|
3096
|
+
return {
|
|
3097
|
+
mode: normalizeVerificationMode(options.verificationMode || swarmState.verificationMode),
|
|
3098
|
+
reason,
|
|
3099
|
+
passed,
|
|
3100
|
+
session_id: String(sessionId || "").trim() || null,
|
|
3101
|
+
assistant_present: !!assistantText.trim(),
|
|
3102
|
+
assistant_excerpt: assistantText.trim().slice(0, 280),
|
|
3103
|
+
any_tool_attempts: toolAudit.totalToolCalls > 0 || toolAudit.rejectedToolCalls > 0,
|
|
3104
|
+
any_tool_calls: toolAudit.totalToolCalls > 0,
|
|
3105
|
+
total_tool_calls: toolAudit.totalToolCalls,
|
|
3106
|
+
write_tool_calls: toolAudit.writeToolCalls,
|
|
3107
|
+
tool_names: toolAudit.toolNames,
|
|
3108
|
+
rejected_tool_calls: toolAudit.rejectedToolCalls,
|
|
3109
|
+
rejected_write_tool_calls: toolAudit.rejectedWriteToolCalls,
|
|
3110
|
+
rejected_tool_names: toolAudit.rejectedToolNames,
|
|
3111
|
+
rejection_reasons: toolAudit.rejectionReasons,
|
|
3112
|
+
detection_sources: toolAudit.sources,
|
|
3113
|
+
workspace_changed: false,
|
|
3114
|
+
workspace_change_mode: "not_required",
|
|
3115
|
+
workspace_change_paths: [],
|
|
3116
|
+
workspace_change_summary: "Workspace changes not required for this task kind.",
|
|
3117
|
+
decision_payload: decisionPayload,
|
|
3118
|
+
execution_trace:
|
|
3119
|
+
options?.executionTrace && typeof options.executionTrace === "object"
|
|
3120
|
+
? options.executionTrace
|
|
3121
|
+
: undefined,
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
async function runExecutionPromptWithVerification(session, run, prompt, sessionId = "", options = {}) {
|
|
3126
|
+
const activeSessionId =
|
|
3127
|
+
String(sessionId || "").trim() || (await createExecutionSession(session, run));
|
|
3128
|
+
if (!activeSessionId) throw new Error("Failed to create execution session.");
|
|
3129
|
+
const runId = String(run?.run_id || "").trim();
|
|
3130
|
+
const controller = getSwarmRunController(runId);
|
|
3131
|
+
const workspaceRoot = await workspaceExistsAsDirectory(
|
|
3132
|
+
String(
|
|
3133
|
+
run?.workspace?.canonical_path ||
|
|
3134
|
+
run?.workspace_root ||
|
|
3135
|
+
controller?.workspaceRoot ||
|
|
3136
|
+
swarmState.workspaceRoot ||
|
|
3137
|
+
REPO_ROOT
|
|
3138
|
+
).trim()
|
|
3139
|
+
);
|
|
3140
|
+
let workspaceBefore = null;
|
|
3141
|
+
let workspaceAfter = null;
|
|
3142
|
+
let workspaceChanges = {
|
|
3143
|
+
mode: "unavailable",
|
|
3144
|
+
hasChanges: false,
|
|
3145
|
+
paths: [],
|
|
3146
|
+
summary: "",
|
|
3147
|
+
};
|
|
3148
|
+
const resolvedModel = await resolveExecutionModel(session, run);
|
|
3149
|
+
const writeRequired = options.writeRequired !== false;
|
|
3150
|
+
const maxAttempts = writeRequired
|
|
3151
|
+
? strictWriteRetryEnabled()
|
|
3152
|
+
? strictWriteRetryMaxAttempts()
|
|
3153
|
+
: 1
|
|
3154
|
+
: nonWritingRetryMaxAttempts();
|
|
3155
|
+
const attempts = [];
|
|
3156
|
+
const workspaceSeedPaths = () => [
|
|
3157
|
+
...Object.keys(workspaceBefore?.files || {}),
|
|
3158
|
+
...Object.keys(workspaceBefore?.statusByPath || {}),
|
|
3159
|
+
];
|
|
3160
|
+
if (workspaceRoot) {
|
|
3161
|
+
workspaceBefore = await captureWorkspaceSnapshot(workspaceRoot).catch((error) => ({
|
|
3162
|
+
mode: "capture_failed",
|
|
3163
|
+
root: workspaceRoot,
|
|
3164
|
+
files: {},
|
|
3165
|
+
statusByPath: {},
|
|
3166
|
+
error: String(error?.message || error || "workspace snapshot failed"),
|
|
3167
|
+
}));
|
|
3168
|
+
}
|
|
3169
|
+
let previousSyncCount = 0;
|
|
3170
|
+
let previousMessageCount = 0;
|
|
3171
|
+
let syncRows = [];
|
|
3172
|
+
let messages = [];
|
|
3173
|
+
let verification = null;
|
|
3174
|
+
let attemptError = null;
|
|
3175
|
+
let hasAssistant = false;
|
|
3176
|
+
let persistedAssistant = false;
|
|
3177
|
+
let lastSessionSnapshot = null;
|
|
3178
|
+
for (let attemptIndex = 1; attemptIndex <= maxAttempts; attemptIndex += 1) {
|
|
3179
|
+
attemptError = null;
|
|
3180
|
+
const requestBody =
|
|
3181
|
+
attemptIndex === 1
|
|
3182
|
+
? {
|
|
3183
|
+
parts: [{ type: "text", text: prompt }],
|
|
3184
|
+
tool_mode: "required",
|
|
3185
|
+
tool_allowlist:
|
|
3186
|
+
options.toolAllowlist ||
|
|
3187
|
+
(writeRequired
|
|
3188
|
+
? workerExecutionToolAllowlist()
|
|
3189
|
+
: ["ls", "list", "glob", "search", "grep", "codesearch", "read"]),
|
|
3190
|
+
write_required: writeRequired ? true : undefined,
|
|
3191
|
+
}
|
|
3192
|
+
: writeRequired
|
|
3193
|
+
? buildStrictWriteRetryRequest(prompt, verification, attemptIndex, maxAttempts)
|
|
3194
|
+
: buildNonWritingRetryRequest(prompt, verification, attemptIndex, maxAttempts);
|
|
3195
|
+
const promptResponse = await engineRequestJson(
|
|
3196
|
+
session,
|
|
3197
|
+
`/session/${encodeURIComponent(activeSessionId)}/prompt_sync`,
|
|
3198
|
+
{
|
|
3199
|
+
method: "POST",
|
|
3200
|
+
timeoutMs: 10 * 60 * 1000,
|
|
3201
|
+
body: requestBody,
|
|
3202
|
+
}
|
|
3203
|
+
).catch((error) => {
|
|
3204
|
+
attemptError = error;
|
|
3205
|
+
return null;
|
|
3206
|
+
});
|
|
3207
|
+
const allSyncRows = Array.isArray(promptResponse) ? promptResponse : [];
|
|
3208
|
+
syncRows = rowsSinceAttemptStart(allSyncRows, previousSyncCount);
|
|
3209
|
+
previousSyncCount = allSyncRows.length;
|
|
3210
|
+
const sessionSnapshot = await engineRequestJson(
|
|
3211
|
+
session,
|
|
3212
|
+
`/session/${encodeURIComponent(activeSessionId)}`
|
|
3213
|
+
).catch(() => null);
|
|
3214
|
+
lastSessionSnapshot = sessionSnapshot;
|
|
3215
|
+
const sessionMessages = Array.isArray(sessionSnapshot?.messages) ? sessionSnapshot.messages : [];
|
|
3216
|
+
messages = rowsSinceAttemptStart(sessionMessages, previousMessageCount);
|
|
3217
|
+
previousMessageCount = sessionMessages.length;
|
|
3218
|
+
hasAssistant = syncRows.some((row) => roleOfMessage(row) === "assistant");
|
|
3219
|
+
persistedAssistant = messages.some((message) => roleOfMessage(message) === "assistant");
|
|
3220
|
+
|
|
3221
|
+
if (workspaceRoot) {
|
|
3222
|
+
workspaceAfter = await captureWorkspaceSnapshot(workspaceRoot, {
|
|
3223
|
+
includePaths: workspaceSeedPaths(),
|
|
3224
|
+
}).catch((error) => ({
|
|
3225
|
+
mode: "capture_failed",
|
|
3226
|
+
root: workspaceRoot,
|
|
3227
|
+
files: {},
|
|
3228
|
+
statusByPath: {},
|
|
3229
|
+
error: String(error?.message || error || "workspace snapshot failed"),
|
|
3230
|
+
}));
|
|
3231
|
+
}
|
|
3232
|
+
if (workspaceBefore && workspaceAfter) {
|
|
3233
|
+
workspaceChanges = summarizeWorkspaceChanges(workspaceBefore, workspaceAfter);
|
|
3234
|
+
if (workspaceBefore?.error || workspaceAfter?.error) {
|
|
3235
|
+
const detail = [workspaceBefore?.error, workspaceAfter?.error].filter(Boolean).join(" | ");
|
|
3236
|
+
workspaceChanges.summary = [
|
|
3237
|
+
workspaceChanges.summary,
|
|
3238
|
+
detail ? `Workspace snapshot warnings: ${detail}` : "",
|
|
3239
|
+
]
|
|
3240
|
+
.filter(Boolean)
|
|
3241
|
+
.join("\n");
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
verification = writeRequired
|
|
3245
|
+
? buildVerificationSummary(syncRows, messages, workspaceChanges, activeSessionId, {
|
|
3246
|
+
verificationMode: controller?.verificationMode || swarmState.verificationMode,
|
|
3247
|
+
})
|
|
3248
|
+
: buildNonWritingVerificationSummary(syncRows, messages, activeSessionId, {
|
|
3249
|
+
verificationMode: controller?.verificationMode || swarmState.verificationMode,
|
|
3250
|
+
});
|
|
3251
|
+
const failureClass = writeRequired
|
|
3252
|
+
? classifyStrictWriteFailureReason(verification)
|
|
3253
|
+
: classifyNonWritingFailureReason(verification);
|
|
3254
|
+
attempts.push(
|
|
3255
|
+
buildAttemptTelemetry(
|
|
3256
|
+
attemptIndex === 1 ? "initial" : `retry_${attemptIndex - 1}`,
|
|
3257
|
+
requestBody,
|
|
3258
|
+
syncRows,
|
|
3259
|
+
messages,
|
|
3260
|
+
Date.now(),
|
|
3261
|
+
attemptError,
|
|
3262
|
+
{
|
|
3263
|
+
attemptIndex,
|
|
3264
|
+
failureClass,
|
|
3265
|
+
retryRemaining: maxAttempts - attemptIndex,
|
|
3266
|
+
}
|
|
3267
|
+
)
|
|
3268
|
+
);
|
|
3269
|
+
if (verification?.passed) break;
|
|
3270
|
+
if (!failureClass || attemptIndex >= maxAttempts) break;
|
|
3271
|
+
}
|
|
3272
|
+
const executionTrace = {
|
|
3273
|
+
session_id: activeSessionId,
|
|
3274
|
+
model: {
|
|
3275
|
+
provider: String(lastSessionSnapshot?.provider || resolvedModel?.provider || "").trim(),
|
|
3276
|
+
model_id:
|
|
3277
|
+
normalizeSessionModelRef(lastSessionSnapshot?.model) ||
|
|
3278
|
+
normalizeSessionModelRef(resolvedModel?.model),
|
|
3279
|
+
source: String(
|
|
3280
|
+
lastSessionSnapshot?.provider &&
|
|
3281
|
+
normalizeSessionModelRef(lastSessionSnapshot?.model)
|
|
3282
|
+
? "session_snapshot"
|
|
3283
|
+
: resolvedModel?.source || ""
|
|
3284
|
+
).trim(),
|
|
3285
|
+
},
|
|
3286
|
+
attempts,
|
|
3287
|
+
};
|
|
3288
|
+
verification = writeRequired
|
|
3289
|
+
? buildVerificationSummary(syncRows, messages, workspaceChanges, activeSessionId, {
|
|
3290
|
+
verificationMode: controller?.verificationMode || swarmState.verificationMode,
|
|
3291
|
+
executionTrace,
|
|
3292
|
+
})
|
|
3293
|
+
: buildNonWritingVerificationSummary(syncRows, messages, activeSessionId, {
|
|
3294
|
+
verificationMode: controller?.verificationMode || swarmState.verificationMode,
|
|
3295
|
+
executionTrace,
|
|
3296
|
+
});
|
|
3297
|
+
if (attemptError && !hasAssistant && !persistedAssistant) {
|
|
3298
|
+
throw createExecutionError(`PROMPT_RETRY_FAILED: ${attemptError.message}`, {
|
|
3299
|
+
sessionId: activeSessionId,
|
|
3300
|
+
verification: {
|
|
3301
|
+
...verification,
|
|
3302
|
+
reason: "PROMPT_RETRY_FAILED",
|
|
3303
|
+
passed: false,
|
|
3304
|
+
assistant_present: false,
|
|
3305
|
+
retry_error: String(attemptError?.message || attemptError || "").trim(),
|
|
3306
|
+
},
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
if (!hasAssistant && !persistedAssistant) {
|
|
3310
|
+
throw createExecutionError(
|
|
3311
|
+
"PROMPT_DISPATCH_EMPTY_RESPONSE: prompt_sync returned no assistant output. Model route may be unresolved.",
|
|
3312
|
+
{
|
|
3313
|
+
sessionId: activeSessionId,
|
|
3314
|
+
verification: {
|
|
3315
|
+
...verification,
|
|
3316
|
+
reason: "PROMPT_DISPATCH_EMPTY_RESPONSE",
|
|
3317
|
+
passed: false,
|
|
3318
|
+
assistant_present: false,
|
|
3319
|
+
},
|
|
3320
|
+
}
|
|
3321
|
+
);
|
|
3322
|
+
}
|
|
3323
|
+
if (!verification.passed) {
|
|
3324
|
+
throw createExecutionError(`TASK_NOT_VERIFIED: ${verification.reason}`, {
|
|
3325
|
+
sessionId: activeSessionId,
|
|
3326
|
+
verification,
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
return { sessionId: activeSessionId, verification };
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
async function runStepWithLLM(session, run, step, stepIndex, totalSteps, sessionId = "") {
|
|
3333
|
+
const prompt = stepPromptText(run, step, stepIndex, totalSteps);
|
|
3334
|
+
return runExecutionPromptWithVerification(session, run, prompt, sessionId);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
function extractBlackboardTasks(blackboardPayload) {
|
|
3338
|
+
const board =
|
|
3339
|
+
blackboardPayload?.blackboard && typeof blackboardPayload.blackboard === "object"
|
|
3340
|
+
? blackboardPayload.blackboard
|
|
3341
|
+
: {};
|
|
3342
|
+
return Array.isArray(board?.tasks) ? board.tasks : [];
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function isTerminalTaskStatus(status) {
|
|
3346
|
+
const normalized = String(status || "")
|
|
3347
|
+
.trim()
|
|
3348
|
+
.toLowerCase();
|
|
3349
|
+
return ["done", "completed", "failed", "cancelled", "canceled"].includes(normalized);
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
function isCompletedTaskStatus(status) {
|
|
3353
|
+
const normalized = String(status || "")
|
|
3354
|
+
.trim()
|
|
3355
|
+
.toLowerCase();
|
|
3356
|
+
return ["done", "completed"].includes(normalized);
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
function extractClaimedTask(payload) {
|
|
3360
|
+
if (payload?.task && typeof payload.task === "object") return payload.task;
|
|
3361
|
+
if (Array.isArray(payload?.tasks) && payload.tasks[0] && typeof payload.tasks[0] === "object") {
|
|
3362
|
+
return payload.tasks[0];
|
|
3363
|
+
}
|
|
3364
|
+
if (payload && typeof payload === "object" && String(payload.id || "").trim()) {
|
|
3365
|
+
return payload;
|
|
3366
|
+
}
|
|
3367
|
+
return null;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
function taskTitleFromRecord(task) {
|
|
3371
|
+
const payload = task?.payload && typeof task.payload === "object" ? task.payload : {};
|
|
3372
|
+
return String(payload?.title || task?.title || task?.task_type || task?.id || "task").trim();
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function taskKindFromRecord(task) {
|
|
3376
|
+
const payload = task?.payload && typeof task.payload === "object" ? task.payload : {};
|
|
3377
|
+
const raw = String(payload?.task_kind || task?.task_type || "inspection").trim().toLowerCase();
|
|
3378
|
+
return ["implementation", "inspection", "research", "validation"].includes(raw)
|
|
3379
|
+
? raw
|
|
3380
|
+
: "inspection";
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
function isNonWritingTaskRecord(task) {
|
|
3384
|
+
return taskKindFromRecord(task) !== "implementation";
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
function summarizeBlackboardTasksForPrompt(tasks, currentTaskId, limit = 16) {
|
|
3388
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
3389
|
+
return list
|
|
3390
|
+
.slice(0, Math.max(1, Number(limit) || 16))
|
|
3391
|
+
.map((task, index) => {
|
|
3392
|
+
const taskId = String(task?.id || `task-${index + 1}`).trim();
|
|
3393
|
+
const title = taskTitleFromRecord(task);
|
|
3394
|
+
const status = String(task?.status || "unknown").trim().toLowerCase();
|
|
3395
|
+
const marker = taskId === currentTaskId ? "*" : "-";
|
|
3396
|
+
return `${marker} ${taskId} [${status}]: ${title}`;
|
|
3397
|
+
})
|
|
3398
|
+
.join("\n")
|
|
3399
|
+
.trim();
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
function taskPromptText(run, task, workerId, workflowId) {
|
|
3403
|
+
const taskId = String(task?.id || "").trim();
|
|
3404
|
+
const taskTitle = taskTitleFromRecord(task);
|
|
3405
|
+
const taskKind = taskKindFromRecord(task);
|
|
3406
|
+
const taskDetails =
|
|
3407
|
+
task && typeof task === "object" ? JSON.stringify(task, null, 2).trim() : "";
|
|
3408
|
+
const taskList = summarizeBlackboardTasksForPrompt(run?.tasks, taskId);
|
|
3409
|
+
const outputTarget =
|
|
3410
|
+
task?.payload?.output_target && typeof task.payload.output_target === "object"
|
|
3411
|
+
? task.payload.output_target
|
|
3412
|
+
: task?.output_target && typeof task.output_target === "object"
|
|
3413
|
+
? task.output_target
|
|
3414
|
+
: null;
|
|
3415
|
+
const outputPath = String(outputTarget?.path || "").trim();
|
|
3416
|
+
const outputKind = String(outputTarget?.kind || "artifact").trim();
|
|
3417
|
+
const outputOperation = String(outputTarget?.operation || "create_or_update").trim();
|
|
3418
|
+
if (taskKind !== "implementation") {
|
|
3419
|
+
return [
|
|
3420
|
+
"Execute this swarm blackboard task.",
|
|
3421
|
+
"",
|
|
3422
|
+
`Task: ${taskTitle}`,
|
|
3423
|
+
`Task ID: ${taskId}`,
|
|
3424
|
+
`Task Kind: ${taskKind}`,
|
|
3425
|
+
`Workflow: ${String(workflowId || task?.workflow_id || "swarm.blackboard.default").trim()}`,
|
|
3426
|
+
`Agent: ${workerId}`,
|
|
3427
|
+
`Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
|
|
3428
|
+
"",
|
|
3429
|
+
"This is a non-writing research/inspection task.",
|
|
3430
|
+
"Treat the current assigned task as the authority for what to inspect or decide.",
|
|
3431
|
+
"Use the original objective and task list only to clarify the assigned task, not to re-plan the run.",
|
|
3432
|
+
"Do not create a new plan, do not restate the task graph, and do not describe future work.",
|
|
3433
|
+
"Use read-only tools to inspect the workspace now.",
|
|
3434
|
+
"",
|
|
3435
|
+
"Current assigned task payload:",
|
|
3436
|
+
taskDetails || "{}",
|
|
3437
|
+
"",
|
|
3438
|
+
"Run blackboard task list:",
|
|
3439
|
+
taskList || "(no task list available)",
|
|
3440
|
+
"",
|
|
3441
|
+
`Original objective: ${String(run?.objective || "").trim()}`,
|
|
3442
|
+
"",
|
|
3443
|
+
"Requirements:",
|
|
3444
|
+
"- You must use tools in this task.",
|
|
3445
|
+
"- Use only read-only tools such as ls/list/glob/search/grep/codesearch/read.",
|
|
3446
|
+
"- Do not call write/edit/apply_patch for this task.",
|
|
3447
|
+
"- Return a concise structured JSON decision object with your findings.",
|
|
3448
|
+
"- If you decide a future artifact path, include `output_target.path` in the JSON.",
|
|
3449
|
+
'- Output shape: {"decision":{"summary":"...","output_target":{"path":"...","kind":"artifact","operation":"create_or_update"},"evidence":["..."]}}',
|
|
3450
|
+
].join("\n");
|
|
3451
|
+
}
|
|
3452
|
+
return [
|
|
3453
|
+
"Execute this swarm blackboard task.",
|
|
3454
|
+
"",
|
|
3455
|
+
`Task: ${taskTitle}`,
|
|
3456
|
+
`Task ID: ${taskId}`,
|
|
3457
|
+
`Task Kind: ${taskKind}`,
|
|
3458
|
+
`Workflow: ${String(workflowId || task?.workflow_id || "swarm.blackboard.default").trim()}`,
|
|
3459
|
+
`Agent: ${workerId}`,
|
|
3460
|
+
`Workspace: ${String(run?.workspace?.canonical_path || "").trim()}`,
|
|
3461
|
+
"",
|
|
3462
|
+
"This task is already planned and assigned.",
|
|
3463
|
+
"Treat the current assigned task as the authority for what to implement.",
|
|
3464
|
+
"Use the original objective and task list only to clarify the assigned task, not to re-plan the run.",
|
|
3465
|
+
"Do not create a new plan, do not restate the task graph, and do not describe future work.",
|
|
3466
|
+
"Use workspace tools to implement this task now.",
|
|
3467
|
+
"",
|
|
3468
|
+
"Current assigned task payload:",
|
|
3469
|
+
taskDetails || "{}",
|
|
3470
|
+
"",
|
|
3471
|
+
"Required output target:",
|
|
3472
|
+
outputPath
|
|
3473
|
+
? JSON.stringify(
|
|
3474
|
+
{
|
|
3475
|
+
path: outputPath,
|
|
3476
|
+
kind: outputKind,
|
|
3477
|
+
operation: outputOperation,
|
|
3478
|
+
},
|
|
3479
|
+
null,
|
|
3480
|
+
2
|
|
3481
|
+
)
|
|
3482
|
+
: '{"path":"","kind":"artifact","operation":"create_or_update"}',
|
|
3483
|
+
"",
|
|
3484
|
+
"Run blackboard task list:",
|
|
3485
|
+
taskList || "(no task list available)",
|
|
3486
|
+
"",
|
|
3487
|
+
`Original objective: ${String(run?.objective || "").trim()}`,
|
|
3488
|
+
"",
|
|
3489
|
+
"Requirements:",
|
|
3490
|
+
"- First inspect the relevant workspace files with read/glob/list/search if needed.",
|
|
3491
|
+
"- If the target file does not exist, create the COMPLETE file in a single write call.",
|
|
3492
|
+
"- Do not split the implementation across multiple tool calls if the result should be one file.",
|
|
3493
|
+
"- Use list/ls/glob for directories. Use read only for concrete file paths.",
|
|
3494
|
+
"- Implement this task in the workspace right now.",
|
|
3495
|
+
"- Create or edit files as needed for this task only.",
|
|
3496
|
+
outputPath
|
|
3497
|
+
? `- The required output for this task is \`${outputPath}\` (${outputKind}, ${outputOperation}).`
|
|
3498
|
+
: "- The required output target is missing; do not guess a file path.",
|
|
3499
|
+
"- If the target file does not exist yet, create it directly.",
|
|
3500
|
+
"- A write call must include both a path and the full content to write.",
|
|
3501
|
+
"- Use write/edit/apply_patch instead of a prose-only response.",
|
|
3502
|
+
"- Keep scope limited to this task.",
|
|
3503
|
+
"- Return a concise summary of the concrete file changes and blockers.",
|
|
3504
|
+
].join("\n");
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
async function runTaskWithLLM(session, run, task, workerId, workflowId, sessionId = "") {
|
|
3508
|
+
const prompt = taskPromptText(run, task, workerId, workflowId);
|
|
3509
|
+
return runExecutionPromptWithVerification(session, run, prompt, sessionId, {
|
|
3510
|
+
writeRequired: !isNonWritingTaskRecord(task),
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
async function fetchBlackboardTasks(session, runId) {
|
|
3515
|
+
const payload = await engineRequestJson(
|
|
3516
|
+
session,
|
|
3517
|
+
`/context/runs/${encodeURIComponent(runId)}/blackboard`
|
|
3518
|
+
).catch(() => ({}));
|
|
3519
|
+
return extractBlackboardTasks(payload);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
async function seedBlackboardTasks(session, runId, objective, taskRows, workflowId) {
|
|
3523
|
+
const normalizedTasks = ensurePlannerTaskOutputTargets(
|
|
3524
|
+
normalizePlannerTasks(taskRows, 128, { linearFallback: true }),
|
|
3525
|
+
objective
|
|
3526
|
+
);
|
|
3527
|
+
const validTaskIds = new Set(normalizedTasks.map((task) => task.id));
|
|
3528
|
+
const prepared = normalizedTasks
|
|
3529
|
+
.map((task, idx, list) => ({
|
|
3530
|
+
id: String(task?.id || `task-${idx + 1}`).trim(),
|
|
3531
|
+
task_type: String(task?.taskKind || "inspection").trim(),
|
|
3532
|
+
workflow_id: workflowId,
|
|
3533
|
+
depends_on_task_ids: (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds : [])
|
|
3534
|
+
.map((dep) => String(dep || "").trim())
|
|
3535
|
+
.filter((dep) => dep && validTaskIds.has(dep)),
|
|
3536
|
+
payload: {
|
|
3537
|
+
title: String(task?.title || "").trim(),
|
|
3538
|
+
task_kind: String(task?.taskKind || "inspection").trim(),
|
|
3539
|
+
objective,
|
|
3540
|
+
step_index: idx + 1,
|
|
3541
|
+
total_steps: list.length,
|
|
3542
|
+
output_target: task?.outputTarget || null,
|
|
3543
|
+
},
|
|
3544
|
+
}))
|
|
3545
|
+
.filter((task) => String(task?.payload?.title || "").trim().length >= 6);
|
|
3546
|
+
if (!prepared.length) {
|
|
3547
|
+
throw new Error("No valid tasks to seed.");
|
|
3548
|
+
}
|
|
3549
|
+
const created = await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/tasks`, {
|
|
3550
|
+
method: "POST",
|
|
3551
|
+
body: { tasks: prepared },
|
|
3552
|
+
});
|
|
3553
|
+
if (created && created.ok === false) {
|
|
3554
|
+
throw new Error(String(created.error || created.code || "Task seeding failed."));
|
|
3555
|
+
}
|
|
3556
|
+
const seeded = await fetchBlackboardTasks(session, runId);
|
|
3557
|
+
if (!seeded.length) {
|
|
3558
|
+
throw new Error("Task seeding returned no blackboard tasks.");
|
|
3559
|
+
}
|
|
3560
|
+
return { mode: "blackboard", count: seeded.length };
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
async function transitionBlackboardTask(session, runId, task, update = {}) {
|
|
3564
|
+
const taskId = String(task?.id || update.taskId || "").trim();
|
|
3565
|
+
if (!taskId) throw new Error("Missing task id for transition.");
|
|
3566
|
+
const expectedTaskRev = task?.task_rev ?? update.expectedTaskRev;
|
|
3567
|
+
const leaseToken = task?.lease_token || task?.leaseToken || update.leaseToken;
|
|
3568
|
+
const agentId = update.agentId || task?.assigned_agent || task?.lease_owner || undefined;
|
|
3569
|
+
return engineRequestJson(
|
|
3570
|
+
session,
|
|
3571
|
+
`/context/runs/${encodeURIComponent(runId)}/tasks/${encodeURIComponent(taskId)}/transition`,
|
|
3572
|
+
{
|
|
3573
|
+
method: "POST",
|
|
3574
|
+
body: {
|
|
3575
|
+
action: String(update.action || "status").trim() || "status",
|
|
3576
|
+
status: update.status || undefined,
|
|
3577
|
+
error: update.error || undefined,
|
|
3578
|
+
command_id: update.commandId || undefined,
|
|
3579
|
+
expected_task_rev: expectedTaskRev ?? undefined,
|
|
3580
|
+
lease_token: leaseToken || undefined,
|
|
3581
|
+
agent_id: agentId || undefined,
|
|
3582
|
+
},
|
|
3583
|
+
}
|
|
3584
|
+
);
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
async function detectExecutorMode(session, runId) {
|
|
3588
|
+
const blackboardTasks = await fetchBlackboardTasks(session, runId);
|
|
3589
|
+
if (blackboardTasks.length) return "blackboard";
|
|
3590
|
+
const payload = await engineRequestJson(
|
|
3591
|
+
session,
|
|
3592
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3593
|
+
).catch(() => null);
|
|
3594
|
+
const steps = Array.isArray(payload?.run?.steps) ? payload.run.steps : [];
|
|
3595
|
+
if (steps.length) return "context_steps";
|
|
3596
|
+
return String(
|
|
3597
|
+
getSwarmRunController(runId)?.executorMode || swarmState.executorMode || "context_steps"
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
const swarmExecutors = new Map();
|
|
3602
|
+
|
|
3603
|
+
function findStepByStatus(steps, status) {
|
|
3604
|
+
return (Array.isArray(steps) ? steps : []).find(
|
|
3605
|
+
(step) =>
|
|
3606
|
+
String(step?.status || "")
|
|
3607
|
+
.trim()
|
|
3608
|
+
.toLowerCase() === status
|
|
3609
|
+
);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
async function ensureStepMarkedDone(session, runId, stepId) {
|
|
3613
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
3614
|
+
const payload = await engineRequestJson(
|
|
3615
|
+
session,
|
|
3616
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3617
|
+
).catch(() => null);
|
|
3618
|
+
const run = payload?.run;
|
|
3619
|
+
const steps = Array.isArray(run?.steps) ? run.steps : [];
|
|
3620
|
+
const idx = steps.findIndex((step) => String(step?.step_id || "") === stepId);
|
|
3621
|
+
if (idx < 0) return true;
|
|
3622
|
+
const current = String(steps[idx]?.status || "").toLowerCase();
|
|
3623
|
+
if (current === "done") return true;
|
|
3624
|
+
if (attempt < 2) {
|
|
3625
|
+
await sleep(120);
|
|
3626
|
+
continue;
|
|
3627
|
+
}
|
|
3628
|
+
const patched = {
|
|
3629
|
+
...run,
|
|
3630
|
+
status: "running",
|
|
3631
|
+
why_next_step: `reconciled completion for ${stepId}`,
|
|
3632
|
+
steps: steps.map((step, i) => (i === idx ? { ...step, status: "done" } : step)),
|
|
3633
|
+
};
|
|
3634
|
+
await engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}`, {
|
|
3635
|
+
method: "PUT",
|
|
3636
|
+
body: patched,
|
|
3637
|
+
});
|
|
3638
|
+
const verify = await engineRequestJson(
|
|
3639
|
+
session,
|
|
3640
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3641
|
+
).catch(() => null);
|
|
3642
|
+
const verifySteps = Array.isArray(verify?.run?.steps) ? verify.run.steps : [];
|
|
3643
|
+
const verifyStep = verifySteps.find((step) => String(step?.step_id || "") === stepId);
|
|
3644
|
+
return String(verifyStep?.status || "").toLowerCase() === "done";
|
|
3645
|
+
}
|
|
3646
|
+
return false;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
async function driveContextRunExecution(session, runId) {
|
|
3650
|
+
if (swarmExecutors.has(runId)) return false;
|
|
3651
|
+
upsertSwarmRunController(runId, {
|
|
3652
|
+
executorState: "running",
|
|
3653
|
+
executorReason: "",
|
|
3654
|
+
});
|
|
3655
|
+
const runner = (async () => {
|
|
3656
|
+
let completionStreak = 0;
|
|
3657
|
+
let lastCompletedStepId = "";
|
|
3658
|
+
for (let cycle = 0; cycle < 24; cycle += 1) {
|
|
3659
|
+
const runPayload = await engineRequestJson(
|
|
3660
|
+
session,
|
|
3661
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3662
|
+
);
|
|
3663
|
+
const run = runPayload?.run || {};
|
|
3664
|
+
if (isRunTerminal(run.status)) return;
|
|
3665
|
+
|
|
3666
|
+
const nextPayload = await engineRequestJson(
|
|
3667
|
+
session,
|
|
3668
|
+
`/context/runs/${encodeURIComponent(runId)}/driver/next`,
|
|
3669
|
+
{
|
|
3670
|
+
method: "POST",
|
|
3671
|
+
body: { dry_run: false },
|
|
3672
|
+
}
|
|
3673
|
+
);
|
|
3674
|
+
const selectedStepId = String(nextPayload?.selected_step_id || "").trim();
|
|
3675
|
+
const latestRun = nextPayload?.run || run;
|
|
3676
|
+
const steps = Array.isArray(latestRun?.steps) ? latestRun.steps : [];
|
|
3677
|
+
const inProgressStep = findStepByStatus(steps, "in_progress");
|
|
3678
|
+
const executionStepId = selectedStepId || String(inProgressStep?.step_id || "").trim();
|
|
3679
|
+
|
|
3680
|
+
if (!executionStepId) {
|
|
3681
|
+
if (
|
|
3682
|
+
steps.length &&
|
|
3683
|
+
steps.every((step) => String(step?.status || "").toLowerCase() === "done")
|
|
3684
|
+
) {
|
|
3685
|
+
await appendContextRunEvent(session, runId, "run_completed", "completed", {
|
|
3686
|
+
why_next_step: "all steps completed",
|
|
3687
|
+
});
|
|
3688
|
+
upsertSwarmRunController(runId, {
|
|
3689
|
+
lastError: "",
|
|
3690
|
+
status: "completed",
|
|
3691
|
+
stoppedAt: Date.now(),
|
|
3692
|
+
executorState: "idle",
|
|
3693
|
+
executorReason: "run completed",
|
|
3694
|
+
});
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
const blockedReason = String(
|
|
3698
|
+
nextPayload?.why_next_step || latestRun?.why_next_step || "No actionable step selected."
|
|
3699
|
+
);
|
|
3700
|
+
upsertSwarmRunController(runId, {
|
|
3701
|
+
lastError: blockedReason,
|
|
3702
|
+
status: "blocked",
|
|
3703
|
+
executorState: "blocked",
|
|
3704
|
+
executorReason: blockedReason,
|
|
3705
|
+
});
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
const stepIndex = steps.findIndex((step) => String(step?.step_id || "") === executionStepId);
|
|
3710
|
+
const step =
|
|
3711
|
+
stepIndex >= 0 ? steps[stepIndex] : { step_id: executionStepId, title: executionStepId };
|
|
3712
|
+
let stepSessionId = "";
|
|
3713
|
+
|
|
3714
|
+
try {
|
|
3715
|
+
stepSessionId = await createExecutionSession(session, latestRun);
|
|
3716
|
+
await appendContextRunEvent(
|
|
3717
|
+
session,
|
|
3718
|
+
runId,
|
|
3719
|
+
"step_started",
|
|
3720
|
+
"running",
|
|
3721
|
+
{
|
|
3722
|
+
step_status: "in_progress",
|
|
3723
|
+
step_title: String(step?.title || executionStepId),
|
|
3724
|
+
session_id: stepSessionId || null,
|
|
3725
|
+
why_next_step: selectedStepId
|
|
3726
|
+
? `executing ${executionStepId}`
|
|
3727
|
+
: `resuming in_progress step ${executionStepId}`,
|
|
3728
|
+
},
|
|
3729
|
+
executionStepId
|
|
3730
|
+
);
|
|
3731
|
+
const { sessionId, verification } = await runStepWithLLM(
|
|
3732
|
+
session,
|
|
3733
|
+
latestRun,
|
|
3734
|
+
step,
|
|
3735
|
+
Math.max(stepIndex, 0),
|
|
3736
|
+
Math.max(steps.length, 1),
|
|
3737
|
+
stepSessionId
|
|
3738
|
+
);
|
|
3739
|
+
await appendContextRunEvent(
|
|
3740
|
+
session,
|
|
3741
|
+
runId,
|
|
3742
|
+
"step_completed",
|
|
3743
|
+
"running",
|
|
3744
|
+
{
|
|
3745
|
+
step_status: "done",
|
|
3746
|
+
step_title: String(step?.title || executionStepId),
|
|
3747
|
+
session_id: sessionId,
|
|
3748
|
+
verification,
|
|
3749
|
+
why_next_step: `completed ${executionStepId}`,
|
|
3750
|
+
},
|
|
3751
|
+
executionStepId
|
|
3752
|
+
);
|
|
3753
|
+
const refresh = await engineRequestJson(
|
|
3754
|
+
session,
|
|
3755
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3756
|
+
).catch(() => null);
|
|
3757
|
+
const refreshedSteps = Array.isArray(refresh?.run?.steps) ? refresh.run.steps : [];
|
|
3758
|
+
const refreshedStep = refreshedSteps.find(
|
|
3759
|
+
(item) => String(item?.step_id || "") === executionStepId
|
|
3760
|
+
);
|
|
3761
|
+
const refreshedStatus = String(refreshedStep?.status || "").toLowerCase();
|
|
3762
|
+
if (refreshedStatus !== "done") {
|
|
3763
|
+
const reconciled = await ensureStepMarkedDone(session, runId, executionStepId);
|
|
3764
|
+
if (reconciled) {
|
|
3765
|
+
await appendContextRunEvent(
|
|
3766
|
+
session,
|
|
3767
|
+
runId,
|
|
3768
|
+
"step_completion_reconciled",
|
|
3769
|
+
"running",
|
|
3770
|
+
{
|
|
3771
|
+
step_status: "done",
|
|
3772
|
+
why_next_step: `reconciled stale step state for ${executionStepId}`,
|
|
3773
|
+
},
|
|
3774
|
+
executionStepId
|
|
3775
|
+
);
|
|
3776
|
+
} else {
|
|
3777
|
+
throw new Error(
|
|
3778
|
+
`STEP_STATE_NOT_ADVANCING: step \`${executionStepId}\` remained \`${refreshedStatus || "unknown"}\` after completion`
|
|
3779
|
+
);
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
if (executionStepId === lastCompletedStepId) completionStreak += 1;
|
|
3783
|
+
else {
|
|
3784
|
+
lastCompletedStepId = executionStepId;
|
|
3785
|
+
completionStreak = 1;
|
|
3786
|
+
}
|
|
3787
|
+
if (completionStreak > 2) {
|
|
3788
|
+
throw new Error(`STEP_LOOP_GUARD: repeated completion on step \`${executionStepId}\``);
|
|
3789
|
+
}
|
|
3790
|
+
upsertSwarmRunController(runId, {
|
|
3791
|
+
lastError: "",
|
|
3792
|
+
status: "running",
|
|
3793
|
+
executorState: "running",
|
|
3794
|
+
executorReason: "",
|
|
3795
|
+
});
|
|
3796
|
+
} catch (error) {
|
|
3797
|
+
const message = String(error?.message || error || "Unknown step failure");
|
|
3798
|
+
const failureSessionId = String(error?.sessionId || stepSessionId || "").trim();
|
|
3799
|
+
const verification =
|
|
3800
|
+
error?.verification && typeof error.verification === "object"
|
|
3801
|
+
? error.verification
|
|
3802
|
+
: undefined;
|
|
3803
|
+
upsertSwarmRunController(runId, {
|
|
3804
|
+
lastError: message,
|
|
3805
|
+
status: "failed",
|
|
3806
|
+
executorState: "error",
|
|
3807
|
+
executorReason: message,
|
|
3808
|
+
});
|
|
3809
|
+
await appendContextRunEvent(
|
|
3810
|
+
session,
|
|
3811
|
+
runId,
|
|
3812
|
+
"step_failed",
|
|
3813
|
+
"failed",
|
|
3814
|
+
{
|
|
3815
|
+
step_status: "failed",
|
|
3816
|
+
session_id: failureSessionId || null,
|
|
3817
|
+
verification,
|
|
3818
|
+
error: message,
|
|
3819
|
+
why_next_step: `step failed: ${executionStepId}`,
|
|
3820
|
+
},
|
|
3821
|
+
executionStepId
|
|
3822
|
+
);
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
})()
|
|
3827
|
+
.catch((error) => {
|
|
3828
|
+
const message = String(error?.message || error || "Run executor failed");
|
|
3829
|
+
upsertSwarmRunController(runId, {
|
|
3830
|
+
lastError: message,
|
|
3831
|
+
status: "failed",
|
|
3832
|
+
executorState: "error",
|
|
3833
|
+
executorReason: message,
|
|
3834
|
+
});
|
|
3835
|
+
})
|
|
3836
|
+
.finally(() => {
|
|
3837
|
+
swarmExecutors.delete(runId);
|
|
3838
|
+
const controller = getSwarmRunController(runId);
|
|
3839
|
+
if (String(controller?.executorState || "") === "running") {
|
|
3840
|
+
upsertSwarmRunController(runId, {
|
|
3841
|
+
executorState: "idle",
|
|
3842
|
+
executorReason: "",
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
});
|
|
3846
|
+
swarmExecutors.set(runId, runner);
|
|
3847
|
+
return true;
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
async function driveBlackboardRunExecution(session, runId, options = {}) {
|
|
3851
|
+
if (swarmExecutors.has(runId)) return false;
|
|
3852
|
+
const controller = getSwarmRunController(runId);
|
|
3853
|
+
const workflowId = String(
|
|
3854
|
+
options.workflowId || controller?.workflowId || swarmState.workflowId || "swarm.blackboard.default"
|
|
3855
|
+
).trim();
|
|
3856
|
+
const maxAgents = Math.max(
|
|
3857
|
+
1,
|
|
3858
|
+
Math.min(
|
|
3859
|
+
16,
|
|
3860
|
+
Number.parseInt(String(options.maxAgents || controller?.maxAgents || swarmState.maxAgents || 3), 10) || 3
|
|
3861
|
+
)
|
|
3862
|
+
);
|
|
3863
|
+
upsertSwarmRunController(runId, {
|
|
3864
|
+
executorState: "running",
|
|
3865
|
+
executorReason: "",
|
|
3866
|
+
executorMode: "blackboard",
|
|
3867
|
+
});
|
|
3868
|
+
|
|
3869
|
+
const runner = (async () => {
|
|
3870
|
+
let completionAnnounced = false;
|
|
3871
|
+
const markRunComplete = async (reason) => {
|
|
3872
|
+
if (completionAnnounced) return;
|
|
3873
|
+
completionAnnounced = true;
|
|
3874
|
+
await appendContextRunEvent(session, runId, "run_completed", "completed", {
|
|
3875
|
+
why_next_step: String(reason || "all tasks completed"),
|
|
3876
|
+
});
|
|
3877
|
+
upsertSwarmRunController(runId, {
|
|
3878
|
+
lastError: "",
|
|
3879
|
+
status: "completed",
|
|
3880
|
+
stoppedAt: Date.now(),
|
|
3881
|
+
executorState: "idle",
|
|
3882
|
+
executorReason: "run completed",
|
|
3883
|
+
});
|
|
3884
|
+
};
|
|
3885
|
+
|
|
3886
|
+
const workers = Array.from({ length: maxAgents }).map((_, index) => {
|
|
3887
|
+
const agentId = `swarm-agent-${index + 1}`;
|
|
3888
|
+
return (async () => {
|
|
3889
|
+
let idleSpins = 0;
|
|
3890
|
+
for (let cycle = 0; cycle < 96; cycle += 1) {
|
|
3891
|
+
const runPayload = await engineRequestJson(
|
|
3892
|
+
session,
|
|
3893
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3894
|
+
).catch(() => null);
|
|
3895
|
+
const run = runPayload?.run || {};
|
|
3896
|
+
if (isRunTerminal(run.status)) return;
|
|
3897
|
+
|
|
3898
|
+
const boardTasks = await fetchBlackboardTasks(session, runId);
|
|
3899
|
+
const openTasks = boardTasks.filter((task) => !isTerminalTaskStatus(task?.status));
|
|
3900
|
+
if (!openTasks.length && boardTasks.length) {
|
|
3901
|
+
await markRunComplete("all blackboard tasks completed");
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
const claimedPayload = await engineRequestJson(
|
|
3906
|
+
session,
|
|
3907
|
+
`/context/runs/${encodeURIComponent(runId)}/tasks/claim`,
|
|
3908
|
+
{
|
|
3909
|
+
method: "POST",
|
|
3910
|
+
body: {
|
|
3911
|
+
agent_id: agentId,
|
|
3912
|
+
workflow_id: workflowId || undefined,
|
|
3913
|
+
lease_ms: 45000,
|
|
3914
|
+
},
|
|
3915
|
+
}
|
|
3916
|
+
).catch(() => null);
|
|
3917
|
+
const task = extractClaimedTask(claimedPayload);
|
|
3918
|
+
if (!task) {
|
|
3919
|
+
idleSpins += 1;
|
|
3920
|
+
if (idleSpins > 6) {
|
|
3921
|
+
const refreshedBoard = await fetchBlackboardTasks(session, runId);
|
|
3922
|
+
if (
|
|
3923
|
+
refreshedBoard.length &&
|
|
3924
|
+
refreshedBoard.every((row) => isCompletedTaskStatus(row?.status))
|
|
3925
|
+
) {
|
|
3926
|
+
await markRunComplete("all blackboard tasks completed");
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
idleSpins = 0;
|
|
3930
|
+
}
|
|
3931
|
+
await sleep(500);
|
|
3932
|
+
continue;
|
|
3933
|
+
}
|
|
3934
|
+
idleSpins = 0;
|
|
3935
|
+
const taskId = String(task?.id || "").trim();
|
|
3936
|
+
const title = taskTitleFromRecord(task);
|
|
3937
|
+
let taskSessionId = "";
|
|
3938
|
+
try {
|
|
3939
|
+
taskSessionId = await createExecutionSession(session, run);
|
|
3940
|
+
await appendContextRunEvent(
|
|
3941
|
+
session,
|
|
3942
|
+
runId,
|
|
3943
|
+
"task_started",
|
|
3944
|
+
"running",
|
|
3945
|
+
{
|
|
3946
|
+
step_status: "in_progress",
|
|
3947
|
+
step_title: title,
|
|
3948
|
+
session_id: taskSessionId || null,
|
|
3949
|
+
workflow_id: String(task?.workflow_id || workflowId || "").trim(),
|
|
3950
|
+
assigned_agent: agentId,
|
|
3951
|
+
why_next_step: `executing ${taskId || title}`,
|
|
3952
|
+
},
|
|
3953
|
+
taskId || null
|
|
3954
|
+
);
|
|
3955
|
+
const runSnapshot = await engineRequestJson(
|
|
3956
|
+
session,
|
|
3957
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
3958
|
+
).catch(() => ({ run: {} }));
|
|
3959
|
+
const { sessionId, verification } = await runTaskWithLLM(
|
|
3960
|
+
session,
|
|
3961
|
+
runSnapshot?.run || run,
|
|
3962
|
+
task,
|
|
3963
|
+
agentId,
|
|
3964
|
+
workflowId,
|
|
3965
|
+
taskSessionId
|
|
3966
|
+
);
|
|
3967
|
+
await transitionBlackboardTask(session, runId, task, {
|
|
3968
|
+
status: "done",
|
|
3969
|
+
agentId,
|
|
3970
|
+
commandId: sessionId,
|
|
3971
|
+
}).catch(() => null);
|
|
3972
|
+
await appendContextRunEvent(
|
|
3973
|
+
session,
|
|
3974
|
+
runId,
|
|
3975
|
+
"task_completed",
|
|
3976
|
+
"running",
|
|
3977
|
+
{
|
|
3978
|
+
step_status: "done",
|
|
3979
|
+
step_title: title,
|
|
3980
|
+
session_id: sessionId,
|
|
3981
|
+
verification,
|
|
3982
|
+
workflow_id: String(task?.workflow_id || workflowId || "").trim(),
|
|
3983
|
+
assigned_agent: agentId,
|
|
3984
|
+
why_next_step: `completed ${taskId || title}`,
|
|
3985
|
+
},
|
|
3986
|
+
taskId || null
|
|
3987
|
+
);
|
|
3988
|
+
upsertSwarmRunController(runId, {
|
|
3989
|
+
lastError: "",
|
|
3990
|
+
status: "running",
|
|
3991
|
+
executorState: "running",
|
|
3992
|
+
executorReason: "",
|
|
3993
|
+
});
|
|
3994
|
+
} catch (error) {
|
|
3995
|
+
const message = String(error?.message || error || "Unknown task failure");
|
|
3996
|
+
const failureSessionId = String(error?.sessionId || taskSessionId || "").trim();
|
|
3997
|
+
const verification =
|
|
3998
|
+
error?.verification && typeof error.verification === "object"
|
|
3999
|
+
? error.verification
|
|
4000
|
+
: undefined;
|
|
4001
|
+
await transitionBlackboardTask(session, runId, task, {
|
|
4002
|
+
status: "failed",
|
|
4003
|
+
error: message,
|
|
4004
|
+
agentId,
|
|
4005
|
+
}).catch(() => null);
|
|
4006
|
+
upsertSwarmRunController(runId, {
|
|
4007
|
+
lastError: message,
|
|
4008
|
+
status: "failed",
|
|
4009
|
+
executorState: "error",
|
|
4010
|
+
executorReason: message,
|
|
4011
|
+
});
|
|
4012
|
+
await appendContextRunEvent(
|
|
4013
|
+
session,
|
|
4014
|
+
runId,
|
|
4015
|
+
"task_failed",
|
|
4016
|
+
"failed",
|
|
4017
|
+
{
|
|
4018
|
+
step_status: "failed",
|
|
4019
|
+
step_title: title,
|
|
4020
|
+
session_id: failureSessionId || null,
|
|
4021
|
+
verification,
|
|
4022
|
+
workflow_id: String(task?.workflow_id || workflowId || "").trim(),
|
|
4023
|
+
assigned_agent: agentId,
|
|
4024
|
+
error: message,
|
|
4025
|
+
why_next_step: `task failed: ${taskId || title}`,
|
|
4026
|
+
},
|
|
4027
|
+
taskId || null
|
|
4028
|
+
);
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
})();
|
|
4033
|
+
});
|
|
4034
|
+
await Promise.all(workers);
|
|
4035
|
+
})()
|
|
4036
|
+
.catch((error) => {
|
|
4037
|
+
const message = String(error?.message || error || "Run executor failed");
|
|
4038
|
+
upsertSwarmRunController(runId, {
|
|
4039
|
+
lastError: message,
|
|
4040
|
+
status: "failed",
|
|
4041
|
+
executorState: "error",
|
|
4042
|
+
executorReason: message,
|
|
4043
|
+
});
|
|
4044
|
+
})
|
|
4045
|
+
.finally(() => {
|
|
4046
|
+
swarmExecutors.delete(runId);
|
|
4047
|
+
const current = getSwarmRunController(runId);
|
|
4048
|
+
if (String(current?.executorState || "") === "running") {
|
|
4049
|
+
upsertSwarmRunController(runId, {
|
|
4050
|
+
executorState: "idle",
|
|
4051
|
+
executorReason: "",
|
|
4052
|
+
});
|
|
4053
|
+
}
|
|
4054
|
+
});
|
|
4055
|
+
swarmExecutors.set(runId, runner);
|
|
4056
|
+
return true;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
async function startRunExecutor(session, runId, options = {}) {
|
|
4060
|
+
const mode = String(options.mode || "").trim() || (await detectExecutorMode(session, runId));
|
|
4061
|
+
upsertSwarmRunController(runId, { executorMode: mode });
|
|
4062
|
+
if (mode === "blackboard") {
|
|
4063
|
+
return driveBlackboardRunExecution(session, runId, options);
|
|
4064
|
+
}
|
|
4065
|
+
return driveContextRunExecution(session, runId);
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
async function requeueInProgressSteps(session, runId) {
|
|
4069
|
+
const payload = await engineRequestJson(
|
|
4070
|
+
session,
|
|
4071
|
+
`/context/runs/${encodeURIComponent(runId)}`
|
|
4072
|
+
).catch(() => null);
|
|
4073
|
+
const run = payload?.run;
|
|
4074
|
+
const steps = Array.isArray(run?.steps) ? run.steps : [];
|
|
4075
|
+
const inProgress = steps.filter(
|
|
4076
|
+
(step) =>
|
|
4077
|
+
String(step?.status || "")
|
|
4078
|
+
.trim()
|
|
4079
|
+
.toLowerCase() === "in_progress"
|
|
4080
|
+
);
|
|
4081
|
+
for (const step of inProgress) {
|
|
4082
|
+
const stepId = String(step?.step_id || "").trim();
|
|
4083
|
+
if (!stepId) continue;
|
|
4084
|
+
await appendContextRunEvent(
|
|
4085
|
+
session,
|
|
4086
|
+
runId,
|
|
4087
|
+
"task_retry_requested",
|
|
4088
|
+
"running",
|
|
4089
|
+
{
|
|
4090
|
+
why_next_step: `requeued stale in_progress step \`${stepId}\` before continue`,
|
|
4091
|
+
},
|
|
4092
|
+
stepId
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
return inProgress.length;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
async function startSwarm(session, config = {}) {
|
|
4099
|
+
const objective = String(config.objective || "Ship a small feature end-to-end").trim();
|
|
4100
|
+
const workspaceCandidates = [
|
|
4101
|
+
config.workspaceRoot,
|
|
4102
|
+
config.workspace_root,
|
|
4103
|
+
config.workspace?.canonical_path,
|
|
4104
|
+
config.workspace?.workspace_root,
|
|
4105
|
+
config.repoRoot,
|
|
4106
|
+
config.repo_root,
|
|
4107
|
+
];
|
|
4108
|
+
const workspaceRootRaw = workspaceCandidates
|
|
4109
|
+
.map((value) => String(value || "").trim())
|
|
4110
|
+
.find((value) => value.length > 0);
|
|
4111
|
+
if (!workspaceRootRaw) {
|
|
4112
|
+
throw new Error(
|
|
4113
|
+
"WORKSPACE_SELECTION_REQUIRED: select a workspace folder before starting a swarm run."
|
|
4114
|
+
);
|
|
4115
|
+
}
|
|
4116
|
+
const workspaceRoot = await workspaceExistsAsDirectory(workspaceRootRaw);
|
|
4117
|
+
if (!workspaceRoot) {
|
|
4118
|
+
throw new Error(
|
|
4119
|
+
`Workspace root does not exist or is not a directory: ${resolve(String(workspaceRootRaw || REPO_ROOT))}`
|
|
4120
|
+
);
|
|
4121
|
+
}
|
|
4122
|
+
const maxTasks = Math.max(1, Number.parseInt(String(config.maxTasks || 3), 10) || 3);
|
|
4123
|
+
const maxAgents = Math.max(
|
|
4124
|
+
1,
|
|
4125
|
+
Math.min(16, Number.parseInt(String(config.maxAgents || 3), 10) || 3)
|
|
4126
|
+
);
|
|
4127
|
+
const workflowId =
|
|
4128
|
+
String(config.workflowId || "swarm.blackboard.default").trim() || "swarm.blackboard.default";
|
|
4129
|
+
const verificationMode = normalizeVerificationMode(
|
|
4130
|
+
config?.verificationMode || config?.verification_mode
|
|
4131
|
+
);
|
|
4132
|
+
const requireLlmPlan = config?.requireLlmPlan === true || config?.require_llm_plan === true;
|
|
4133
|
+
const allowLocalPlannerFallback =
|
|
4134
|
+
config?.allowLocalPlannerFallback === true || config?.allow_local_planner_fallback === true;
|
|
4135
|
+
let modelProvider = String(config.modelProvider || "").trim();
|
|
4136
|
+
let modelId = String(config.modelId || "").trim();
|
|
4137
|
+
const mcpServers = (Array.isArray(config.mcpServers) ? config.mcpServers : [])
|
|
4138
|
+
.map((entry) => String(entry || "").trim())
|
|
4139
|
+
.filter(Boolean)
|
|
4140
|
+
.slice(0, 64);
|
|
4141
|
+
|
|
4142
|
+
const created = await engineRequestJson(session, "/context/runs", {
|
|
4143
|
+
method: "POST",
|
|
4144
|
+
body: {
|
|
4145
|
+
objective,
|
|
4146
|
+
run_type: "interactive",
|
|
4147
|
+
source_client: "control_panel",
|
|
4148
|
+
model_provider: modelProvider || undefined,
|
|
4149
|
+
model_id: modelId || undefined,
|
|
4150
|
+
mcp_servers: mcpServers,
|
|
4151
|
+
workspace: {
|
|
4152
|
+
workspace_id: buildWorkspaceId(workspaceRoot),
|
|
4153
|
+
canonical_path: workspaceRoot,
|
|
4154
|
+
lease_epoch: 1,
|
|
4155
|
+
},
|
|
4156
|
+
},
|
|
4157
|
+
});
|
|
4158
|
+
const run = created?.run;
|
|
4159
|
+
const runId = String(run?.run_id || "").trim();
|
|
4160
|
+
if (!runId) throw new Error("Context run creation failed (missing run_id).");
|
|
4161
|
+
|
|
4162
|
+
await appendContextRunEvent(session, runId, "planning_started", "planning", {
|
|
4163
|
+
source_client: "control_panel",
|
|
4164
|
+
max_tasks: maxTasks,
|
|
4165
|
+
max_agents: maxAgents,
|
|
4166
|
+
workflow_id: workflowId,
|
|
4167
|
+
verification_mode: verificationMode,
|
|
4168
|
+
model_provider: modelProvider || undefined,
|
|
4169
|
+
model_id: modelId || undefined,
|
|
4170
|
+
mcp_servers: mcpServers,
|
|
4171
|
+
});
|
|
4172
|
+
const synced = (() =>
|
|
4173
|
+
engineRequestJson(session, `/context/runs/${encodeURIComponent(runId)}/todos/sync`, {
|
|
4174
|
+
method: "POST",
|
|
4175
|
+
body: {
|
|
4176
|
+
replace: true,
|
|
4177
|
+
todos: [],
|
|
4178
|
+
source_session_id: null,
|
|
4179
|
+
source_run_id: runId,
|
|
4180
|
+
},
|
|
4181
|
+
}))();
|
|
4182
|
+
await synced;
|
|
4183
|
+
let plannerTasks = [];
|
|
4184
|
+
let planSeedMode = "fallback_local";
|
|
4185
|
+
const enforceStrictTaskOutputs = String(verificationMode || "strict").trim().toLowerCase() === "strict";
|
|
4186
|
+
try {
|
|
4187
|
+
const llmPlan = await generatePlanTodosWithLLM(session, run, maxTasks);
|
|
4188
|
+
let plannerSource = "llm_objective_planner";
|
|
4189
|
+
let plannerNote = "";
|
|
4190
|
+
plannerTasks = ensurePlannerTaskOutputTargets(
|
|
4191
|
+
normalizePlannerTasks(llmPlan.tasks, maxTasks, { linearFallback: false }),
|
|
4192
|
+
objective
|
|
4193
|
+
);
|
|
4194
|
+
if (!plannerTasks.length) {
|
|
4195
|
+
const recovered = fallbackPlannerTasks(objective, maxTasks, llmPlan?.assistantText || "");
|
|
4196
|
+
plannerTasks = ensurePlannerTaskOutputTargets(recovered.tasks, objective);
|
|
4197
|
+
plannerSource = recovered.source;
|
|
4198
|
+
plannerNote = recovered.note;
|
|
4199
|
+
}
|
|
4200
|
+
if (enforceStrictTaskOutputs) {
|
|
4201
|
+
const strictCheck = validateStrictPlannerTasks(plannerTasks);
|
|
4202
|
+
if (!strictCheck.ok) {
|
|
4203
|
+
throw new Error(
|
|
4204
|
+
`STRICT_TASK_PLAN_INVALID: missing_output_target=${strictCheck.missing.join(", ")} invalid_task_kind=${strictCheck.invalidTaskKinds.join(", ")}`
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
const seeded = await seedBlackboardTasks(session, runId, objective, plannerTasks, workflowId);
|
|
4209
|
+
planSeedMode = "blackboard_llm";
|
|
4210
|
+
await appendContextRunEvent(session, runId, "plan_seeded_llm", "planning", {
|
|
4211
|
+
source: plannerSource,
|
|
4212
|
+
session_id: llmPlan.sessionId || null,
|
|
4213
|
+
task_count: Number(seeded?.count || 0),
|
|
4214
|
+
dependency_edges: plannerTasks.reduce(
|
|
4215
|
+
(sum, task) =>
|
|
4216
|
+
sum + (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds.length : 0),
|
|
4217
|
+
0
|
|
4218
|
+
),
|
|
4219
|
+
workflow_id: workflowId,
|
|
4220
|
+
planner_target: planSeedMode,
|
|
4221
|
+
note: plannerNote || undefined,
|
|
4222
|
+
});
|
|
4223
|
+
} catch (planningError) {
|
|
4224
|
+
const plannerFailureReason = String(
|
|
4225
|
+
planningError?.message || planningError || "unknown planning failure"
|
|
4226
|
+
);
|
|
4227
|
+
const recovered = fallbackPlannerTasks(objective, maxTasks, planningError?.assistantText || "");
|
|
4228
|
+
plannerTasks = ensurePlannerTaskOutputTargets(recovered.tasks, objective);
|
|
4229
|
+
const forcedFallback = requireLlmPlan && !allowLocalPlannerFallback;
|
|
4230
|
+
if (forcedFallback) {
|
|
4231
|
+
await appendContextRunEvent(session, runId, "plan_failed_llm_required", "planning", {
|
|
4232
|
+
reason: plannerFailureReason,
|
|
4233
|
+
recovered: false,
|
|
4234
|
+
recovery_source: recovered.source,
|
|
4235
|
+
}).catch(() => null);
|
|
4236
|
+
throw new Error(`LLM planning failed and fallback is disabled: ${plannerFailureReason}`);
|
|
4237
|
+
}
|
|
4238
|
+
if (enforceStrictTaskOutputs) {
|
|
4239
|
+
const strictCheck = validateStrictPlannerTasks(plannerTasks);
|
|
4240
|
+
if (!strictCheck.ok) {
|
|
4241
|
+
await appendContextRunEvent(session, runId, "plan_failed_output_target_missing", "planning", {
|
|
4242
|
+
reason: plannerFailureReason,
|
|
4243
|
+
missing_tasks: strictCheck.missing,
|
|
4244
|
+
invalid_task_kind_tasks: strictCheck.invalidTaskKinds,
|
|
4245
|
+
recovery_source: recovered.source,
|
|
4246
|
+
}).catch(() => null);
|
|
4247
|
+
throw new Error(
|
|
4248
|
+
`Strict orchestration requires valid task kinds and output targets where needed: missing_output_target=${strictCheck.missing.join(", ")} invalid_task_kind=${strictCheck.invalidTaskKinds.join(", ")}`
|
|
4249
|
+
);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
try {
|
|
4253
|
+
const seeded = await seedBlackboardTasks(session, runId, objective, plannerTasks, workflowId);
|
|
4254
|
+
planSeedMode = "blackboard_local";
|
|
4255
|
+
await appendContextRunEvent(session, runId, "plan_seeded_local", "planning", {
|
|
4256
|
+
source: recovered.source,
|
|
4257
|
+
task_count: Number(seeded?.count || 0),
|
|
4258
|
+
dependency_edges: plannerTasks.reduce(
|
|
4259
|
+
(sum, task) =>
|
|
4260
|
+
sum + (Array.isArray(task?.dependsOnTaskIds) ? task.dependsOnTaskIds.length : 0),
|
|
4261
|
+
0
|
|
4262
|
+
),
|
|
4263
|
+
workflow_id: workflowId,
|
|
4264
|
+
planner_target: planSeedMode,
|
|
4265
|
+
note: `${recovered.note} Planner fallback used: ${plannerFailureReason}`,
|
|
4266
|
+
});
|
|
4267
|
+
} catch (blackboardError) {
|
|
4268
|
+
throw blackboardError;
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
await appendContextRunEvent(session, runId, "plan_ready_for_approval", "awaiting_approval", {
|
|
4272
|
+
source_client: "control_panel",
|
|
4273
|
+
workflow_id: workflowId,
|
|
4274
|
+
max_agents: maxAgents,
|
|
4275
|
+
verification_mode: verificationMode,
|
|
4276
|
+
planner_mode: planSeedMode,
|
|
4277
|
+
});
|
|
4278
|
+
upsertSwarmRunController(runId, {
|
|
4279
|
+
status: "awaiting_approval",
|
|
4280
|
+
startedAt: Date.now(),
|
|
4281
|
+
stoppedAt: null,
|
|
4282
|
+
objective,
|
|
4283
|
+
workspaceRoot,
|
|
4284
|
+
maxTasks,
|
|
4285
|
+
maxAgents,
|
|
4286
|
+
workflowId,
|
|
4287
|
+
modelProvider,
|
|
4288
|
+
modelId,
|
|
4289
|
+
resolvedModelProvider: "",
|
|
4290
|
+
resolvedModelId: "",
|
|
4291
|
+
modelResolutionSource: "deferred",
|
|
4292
|
+
mcpServers,
|
|
4293
|
+
repoRoot: workspaceRoot,
|
|
4294
|
+
verificationMode,
|
|
4295
|
+
lastError: "",
|
|
4296
|
+
executorMode: planSeedMode.startsWith("blackboard") ? "blackboard" : "context_steps",
|
|
4297
|
+
executorState: "idle",
|
|
4298
|
+
executorReason: "",
|
|
4299
|
+
attachedPid: null,
|
|
4300
|
+
registryCache: null,
|
|
4301
|
+
});
|
|
4302
|
+
setActiveSwarmRunId(runId);
|
|
4303
|
+
setSwarmPreflight({
|
|
4304
|
+
gitAvailable: null,
|
|
4305
|
+
repoReady: true,
|
|
4306
|
+
autoInitialized: false,
|
|
4307
|
+
code: "workspace_ready",
|
|
4308
|
+
reason: "",
|
|
4309
|
+
guidance: "",
|
|
4310
|
+
});
|
|
4311
|
+
pushSwarmEvent("status", { status: swarmState.status, runId: runId });
|
|
4312
|
+
return runId;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
const handleSwarmApi = createSwarmApiHandler({
|
|
4316
|
+
PORTAL_PORT,
|
|
4317
|
+
REPO_ROOT,
|
|
4318
|
+
ENGINE_URL,
|
|
4319
|
+
swarmState,
|
|
4320
|
+
isLocalEngineUrl,
|
|
4321
|
+
sendJson,
|
|
4322
|
+
readJsonBody,
|
|
4323
|
+
workspaceExistsAsDirectory,
|
|
4324
|
+
loadHiddenSwarmRunIds,
|
|
4325
|
+
saveHiddenSwarmRunIds,
|
|
4326
|
+
engineRequestJson,
|
|
4327
|
+
appendContextRunEvent,
|
|
4328
|
+
contextRunStatusToSwarmStatus,
|
|
4329
|
+
startSwarm,
|
|
4330
|
+
detectExecutorMode,
|
|
4331
|
+
startRunExecutor,
|
|
4332
|
+
requeueInProgressSteps,
|
|
4333
|
+
transitionBlackboardTask,
|
|
4334
|
+
contextRunSnapshot,
|
|
4335
|
+
contextRunToTasks,
|
|
4336
|
+
getSwarmRunController,
|
|
4337
|
+
upsertSwarmRunController,
|
|
4338
|
+
setActiveSwarmRunId,
|
|
4339
|
+
});
|
|
4340
|
+
|
|
1203
4341
|
async function handleApi(req, res) {
|
|
1204
4342
|
const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
|
|
1205
4343
|
|
|
@@ -1235,7 +4373,10 @@ async function handleApi(req, res) {
|
|
|
1235
4373
|
if (!health) {
|
|
1236
4374
|
sessions.delete(session.sid);
|
|
1237
4375
|
clearSessionCookie(res);
|
|
1238
|
-
sendJson(res, 401, {
|
|
4376
|
+
sendJson(res, 401, {
|
|
4377
|
+
ok: false,
|
|
4378
|
+
error: "Session token is no longer valid for the configured engine.",
|
|
4379
|
+
});
|
|
1239
4380
|
return true;
|
|
1240
4381
|
}
|
|
1241
4382
|
sendJson(res, 200, {
|
|
@@ -1247,7 +4388,7 @@ async function handleApi(req, res) {
|
|
|
1247
4388
|
return true;
|
|
1248
4389
|
}
|
|
1249
4390
|
|
|
1250
|
-
if (pathname.startsWith("/api/swarm")) {
|
|
4391
|
+
if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
|
|
1251
4392
|
const session = requireSession(req, res);
|
|
1252
4393
|
if (!session) return true;
|
|
1253
4394
|
return handleSwarmApi(req, res, session);
|
|
@@ -1302,7 +4443,6 @@ function shutdown(signal) {
|
|
|
1302
4443
|
}
|
|
1303
4444
|
if (swarmState.process && !swarmState.process.killed) {
|
|
1304
4445
|
try {
|
|
1305
|
-
clearSwarmMonitor();
|
|
1306
4446
|
swarmState.process.kill("SIGTERM");
|
|
1307
4447
|
} catch {}
|
|
1308
4448
|
}
|
|
@@ -1318,6 +4458,13 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
|
|
1318
4458
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1319
4459
|
|
|
1320
4460
|
async function main() {
|
|
4461
|
+
if (serviceOp) {
|
|
4462
|
+
await operateServices(serviceOp, serviceMode);
|
|
4463
|
+
if (serviceSetupOnly && !installServicesRequested) {
|
|
4464
|
+
return;
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
|
|
1321
4468
|
if (installServicesRequested) {
|
|
1322
4469
|
await installServices();
|
|
1323
4470
|
if (serviceSetupOnly) {
|
|
@@ -1352,13 +4499,15 @@ async function main() {
|
|
|
1352
4499
|
process.exit(1);
|
|
1353
4500
|
});
|
|
1354
4501
|
|
|
1355
|
-
server.listen(PORTAL_PORT, () => {
|
|
4502
|
+
server.listen(PORTAL_PORT, PANEL_HOST, () => {
|
|
1356
4503
|
log("=========================================");
|
|
1357
|
-
log(`Control Panel: http
|
|
4504
|
+
log(`Control Panel: http://${PANEL_HOST}:${PORTAL_PORT}`);
|
|
4505
|
+
if (PANEL_PUBLIC_URL) log(`Public URL: ${PANEL_PUBLIC_URL}`);
|
|
1358
4506
|
log(`Engine URL: ${ENGINE_URL}`);
|
|
1359
4507
|
log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
|
|
1360
4508
|
log(`Files root: ${FILES_ROOT}`);
|
|
1361
4509
|
log(`Files scope: ${FILES_SCOPE || "(full root)"}`);
|
|
4510
|
+
log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
|
|
1362
4511
|
log("=========================================");
|
|
1363
4512
|
});
|
|
1364
4513
|
}
|