@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -24
- package/dist/index.js +1855 -232
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -11,8 +11,8 @@ import chalk2 from "chalk";
|
|
|
11
11
|
|
|
12
12
|
// src/lib/config.ts
|
|
13
13
|
import Conf from "conf";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { chmodSync, existsSync, statSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
16
|
var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
|
|
17
17
|
var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
|
|
18
18
|
var defaults = {
|
|
@@ -47,8 +47,35 @@ var credentials = new Conf({
|
|
|
47
47
|
projectName: "evident",
|
|
48
48
|
projectSuffix: "",
|
|
49
49
|
configName: "credentials",
|
|
50
|
-
defaults: {}
|
|
50
|
+
defaults: {},
|
|
51
|
+
configFileMode: 384
|
|
51
52
|
});
|
|
53
|
+
var CREDENTIALS_FILE_MODE = 384;
|
|
54
|
+
var CREDENTIALS_DIR_MODE = 448;
|
|
55
|
+
var permissionWarningEmitted = false;
|
|
56
|
+
function hardenCredentialsPermissions() {
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const file = credentials.path;
|
|
61
|
+
for (const [path, mode] of [
|
|
62
|
+
[file, CREDENTIALS_FILE_MODE],
|
|
63
|
+
[dirname(file), CREDENTIALS_DIR_MODE]
|
|
64
|
+
]) {
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
|
|
67
|
+
chmodSync(path, mode);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!permissionWarningEmitted) {
|
|
71
|
+
permissionWarningEmitted = true;
|
|
72
|
+
console.error(
|
|
73
|
+
`[config] could not restrict permissions on ${path}; the credentials file may be readable by other users on this machine: ${err instanceof Error ? err.message : String(err)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
52
79
|
function getApiUrlConfig() {
|
|
53
80
|
return getApiUrl();
|
|
54
81
|
}
|
|
@@ -59,6 +86,7 @@ function credentialsKey() {
|
|
|
59
86
|
return getApiUrl();
|
|
60
87
|
}
|
|
61
88
|
function getCredentials() {
|
|
89
|
+
hardenCredentialsPermissions();
|
|
62
90
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
63
91
|
return byEndpoint[credentialsKey()] ?? {};
|
|
64
92
|
}
|
|
@@ -70,14 +98,17 @@ function setCredentials(creds) {
|
|
|
70
98
|
expiresAt: creds.expiresAt
|
|
71
99
|
};
|
|
72
100
|
credentials.set("byEndpoint", byEndpoint);
|
|
101
|
+
hardenCredentialsPermissions();
|
|
73
102
|
}
|
|
74
103
|
function clearCredentials() {
|
|
75
104
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
76
105
|
delete byEndpoint[credentialsKey()];
|
|
77
106
|
credentials.set("byEndpoint", byEndpoint);
|
|
107
|
+
hardenCredentialsPermissions();
|
|
78
108
|
}
|
|
79
109
|
function clearAllCredentials() {
|
|
80
110
|
credentials.clear();
|
|
111
|
+
hardenCredentialsPermissions();
|
|
81
112
|
}
|
|
82
113
|
function getCliName() {
|
|
83
114
|
const argv1 = process.argv[1] || "";
|
|
@@ -285,14 +316,14 @@ function blank() {
|
|
|
285
316
|
console.log();
|
|
286
317
|
}
|
|
287
318
|
function waitForEnter(prompt = "Press Enter to continue...") {
|
|
288
|
-
return new Promise((
|
|
319
|
+
return new Promise((resolve3) => {
|
|
289
320
|
process.stdout.write(chalk.dim(prompt));
|
|
290
321
|
const handler = () => {
|
|
291
322
|
process.stdin.removeListener("data", handler);
|
|
292
323
|
process.stdin.setRawMode?.(false);
|
|
293
324
|
process.stdin.pause();
|
|
294
325
|
console.log();
|
|
295
|
-
|
|
326
|
+
resolve3();
|
|
296
327
|
};
|
|
297
328
|
if (process.stdin.isTTY) {
|
|
298
329
|
process.stdin.setRawMode?.(true);
|
|
@@ -302,7 +333,7 @@ function waitForEnter(prompt = "Press Enter to continue...") {
|
|
|
302
333
|
});
|
|
303
334
|
}
|
|
304
335
|
function sleep(ms) {
|
|
305
|
-
return new Promise((
|
|
336
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
306
337
|
}
|
|
307
338
|
|
|
308
339
|
// src/commands/login.ts
|
|
@@ -373,22 +404,23 @@ async function deviceFlowLogin(options) {
|
|
|
373
404
|
}
|
|
374
405
|
async function tokenLogin() {
|
|
375
406
|
console.log("Token login mode.");
|
|
376
|
-
console.log("
|
|
407
|
+
console.log("Run `evident login` on a machine with a browser to get a token.");
|
|
408
|
+
console.log("Manage or revoke existing tokens under Settings \u2192 CLI tokens.");
|
|
377
409
|
blank();
|
|
378
410
|
process.stdout.write("Paste token: ");
|
|
379
|
-
const token = await new Promise((
|
|
411
|
+
const token = await new Promise((resolve3) => {
|
|
380
412
|
let data = "";
|
|
381
413
|
process.stdin.setEncoding("utf8");
|
|
382
414
|
process.stdin.on("data", (chunk) => {
|
|
383
415
|
data += chunk;
|
|
384
416
|
});
|
|
385
417
|
process.stdin.on("end", () => {
|
|
386
|
-
|
|
418
|
+
resolve3(data.trim());
|
|
387
419
|
});
|
|
388
420
|
if (process.stdin.isTTY) {
|
|
389
421
|
process.stdin.once("data", (chunk) => {
|
|
390
422
|
process.stdin.pause();
|
|
391
|
-
|
|
423
|
+
resolve3(chunk.toString().trim());
|
|
392
424
|
});
|
|
393
425
|
process.stdin.resume();
|
|
394
426
|
}
|
|
@@ -467,9 +499,9 @@ async function whoami() {
|
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
// src/commands/run.ts
|
|
502
|
+
import { homedir as homedir2 } from "os";
|
|
503
|
+
import { isAbsolute as isAbsolute2, join as join2, parse, resolve as resolvePath } from "path";
|
|
470
504
|
import chalk6 from "chalk";
|
|
471
|
-
import ora3 from "ora";
|
|
472
|
-
import { select as select3 } from "@inquirer/prompts";
|
|
473
505
|
|
|
474
506
|
// ../../packages/types/src/telemetry/index.ts
|
|
475
507
|
var TelemetryEventTypes = {
|
|
@@ -485,6 +517,10 @@ var TelemetryEventTypes = {
|
|
|
485
517
|
var MAX_FRAME_BYTES = 256 * 1024;
|
|
486
518
|
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
487
519
|
|
|
520
|
+
// ../../packages/types/src/runner-files.ts
|
|
521
|
+
var MAX_FILE_PUSH_BYTES = 64 * 1024;
|
|
522
|
+
var MAX_FILE_SYNC_DIRECTORIES = 16;
|
|
523
|
+
|
|
488
524
|
// ../../packages/types/src/logging/index.ts
|
|
489
525
|
var CORRELATION_ID_HEADER = "x-evident-correlation-id";
|
|
490
526
|
function log(level, event, fields) {
|
|
@@ -499,6 +535,12 @@ function log(level, event, fields) {
|
|
|
499
535
|
);
|
|
500
536
|
}
|
|
501
537
|
}
|
|
538
|
+
function errorFields(err) {
|
|
539
|
+
if (err instanceof Error) {
|
|
540
|
+
return { error: err.message, error_name: err.name };
|
|
541
|
+
}
|
|
542
|
+
return { error: String(err) };
|
|
543
|
+
}
|
|
502
544
|
function stripQuery(url) {
|
|
503
545
|
try {
|
|
504
546
|
return new URL(url).pathname;
|
|
@@ -508,6 +550,10 @@ function stripQuery(url) {
|
|
|
508
550
|
}
|
|
509
551
|
}
|
|
510
552
|
|
|
553
|
+
// src/commands/run.ts
|
|
554
|
+
import ora3 from "ora";
|
|
555
|
+
import { select as select3 } from "@inquirer/prompts";
|
|
556
|
+
|
|
511
557
|
// src/lib/telemetry.ts
|
|
512
558
|
var CLI_VERSION = (true ? "3.0.0" : void 0) ?? process.env.npm_package_version ?? "unknown";
|
|
513
559
|
function getCliVersion() {
|
|
@@ -645,14 +691,27 @@ var EventTypes = {
|
|
|
645
691
|
// CLI lifecycle
|
|
646
692
|
CLI_STARTED: "cli.started",
|
|
647
693
|
CLI_COMMAND: "cli.command",
|
|
648
|
-
CLI_ERROR: "cli.error"
|
|
694
|
+
CLI_ERROR: "cli.error",
|
|
695
|
+
// Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
|
|
696
|
+
// names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
|
|
697
|
+
DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
|
|
698
|
+
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
649
699
|
};
|
|
650
700
|
|
|
651
701
|
// src/lib/auth.ts
|
|
652
702
|
async function getAuthCredentials() {
|
|
703
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
653
704
|
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
705
|
+
if (runnerKey) {
|
|
706
|
+
return {
|
|
707
|
+
token: runnerKey,
|
|
708
|
+
authType: "agent_key",
|
|
709
|
+
keySource: "runner_key",
|
|
710
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
711
|
+
};
|
|
712
|
+
}
|
|
654
713
|
if (agentKey) {
|
|
655
|
-
return { token: agentKey, authType: "agent_key" };
|
|
714
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
656
715
|
}
|
|
657
716
|
const userToken = process.env.EVIDENT_TOKEN;
|
|
658
717
|
if (userToken) {
|
|
@@ -706,7 +765,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
706
765
|
if (health.healthy) {
|
|
707
766
|
return health;
|
|
708
767
|
}
|
|
709
|
-
await new Promise((
|
|
768
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
710
769
|
}
|
|
711
770
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
712
771
|
}
|
|
@@ -721,7 +780,7 @@ function buildOpenCodeVersionWarning(version2) {
|
|
|
721
780
|
if (isQueueValidatedVersion(version2)) return null;
|
|
722
781
|
const detected = version2 ? `v${version2}` : "unknown";
|
|
723
782
|
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
724
|
-
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack
|
|
783
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
725
784
|
}
|
|
726
785
|
|
|
727
786
|
// src/lib/opencode/process.ts
|
|
@@ -1013,6 +1072,12 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
1013
1072
|
return action;
|
|
1014
1073
|
}
|
|
1015
1074
|
|
|
1075
|
+
// src/lib/opencode/provider-check.ts
|
|
1076
|
+
function buildNoProviderWarning(hasProvider) {
|
|
1077
|
+
if (hasProvider !== false) return null;
|
|
1078
|
+
return "Warning: opencode has no authenticated model provider configured, so it won't be able to answer prompts. Run `opencode auth login` to set one up (see https://opencode.ai for details).";
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1016
1081
|
// src/lib/opencode/session.ts
|
|
1017
1082
|
function opencodeBase(port) {
|
|
1018
1083
|
return `http://127.0.0.1:${port}`;
|
|
@@ -1173,17 +1238,128 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1173
1238
|
const data = await response.json();
|
|
1174
1239
|
return data.id;
|
|
1175
1240
|
}
|
|
1241
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1242
|
+
try {
|
|
1243
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1244
|
+
if (!res.ok) {
|
|
1245
|
+
console.error(
|
|
1246
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1247
|
+
);
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
const body = await res.json();
|
|
1251
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1252
|
+
if (!providers) {
|
|
1253
|
+
console.error(
|
|
1254
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1255
|
+
);
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1259
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1260
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1261
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1262
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1263
|
+
if (!provider && !providerId) {
|
|
1264
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1265
|
+
if (defaultProviderIds.length === 1) {
|
|
1266
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if (!provider || !provider.models) return null;
|
|
1270
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1271
|
+
const def = defaults2[provider.id];
|
|
1272
|
+
if (typeof def === "string") modelId = def;
|
|
1273
|
+
}
|
|
1274
|
+
if (!modelId) {
|
|
1275
|
+
if (providerId) {
|
|
1276
|
+
const keys = Object.keys(provider.models);
|
|
1277
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1278
|
+
}
|
|
1279
|
+
if (!modelId) return null;
|
|
1280
|
+
}
|
|
1281
|
+
const entry = provider.models[modelId];
|
|
1282
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1283
|
+
if (entry.capabilities && typeof entry.capabilities === "object") {
|
|
1284
|
+
if (typeof entry.capabilities.attachment === "boolean") {
|
|
1285
|
+
return entry.capabilities.attachment;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
console.error(
|
|
1291
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1292
|
+
);
|
|
1293
|
+
return null;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
async function buildFileParts(attachments, capable) {
|
|
1297
|
+
const outcomes = [];
|
|
1298
|
+
const parts = [];
|
|
1299
|
+
const capabilityUnknown = capable === null;
|
|
1300
|
+
if (capable !== true) {
|
|
1301
|
+
for (const a of attachments.inputs) {
|
|
1302
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1303
|
+
}
|
|
1304
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1305
|
+
}
|
|
1306
|
+
for (const a of attachments.inputs) {
|
|
1307
|
+
let dataUrl = null;
|
|
1308
|
+
try {
|
|
1309
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
console.error(
|
|
1312
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1313
|
+
);
|
|
1314
|
+
dataUrl = null;
|
|
1315
|
+
}
|
|
1316
|
+
if (dataUrl !== null && typeof dataUrl === "object") {
|
|
1317
|
+
outcomes.push({
|
|
1318
|
+
index: a.index,
|
|
1319
|
+
mime: a.mime,
|
|
1320
|
+
filename: a.filename,
|
|
1321
|
+
status: "failed",
|
|
1322
|
+
reason: "needs_reauth"
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (dataUrl == null) {
|
|
1327
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
parts.push({
|
|
1331
|
+
type: "file",
|
|
1332
|
+
mime: a.mime,
|
|
1333
|
+
url: dataUrl,
|
|
1334
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1335
|
+
});
|
|
1336
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1337
|
+
}
|
|
1338
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1339
|
+
}
|
|
1176
1340
|
function messageText(m) {
|
|
1177
1341
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1178
1342
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1179
1343
|
}
|
|
1180
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1344
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1181
1345
|
const before = await getSessionMessages(port, sessionId);
|
|
1182
1346
|
const knownUserIds = new Set(
|
|
1183
1347
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1184
1348
|
);
|
|
1349
|
+
const parts = [{ type: "text", text: content }];
|
|
1350
|
+
let pendingOutcomes = null;
|
|
1351
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1352
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1353
|
+
const {
|
|
1354
|
+
parts: fileParts,
|
|
1355
|
+
outcomes,
|
|
1356
|
+
capabilityUnknown
|
|
1357
|
+
} = await buildFileParts(attachments, capable);
|
|
1358
|
+
parts.push(...fileParts);
|
|
1359
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1360
|
+
}
|
|
1185
1361
|
const body = {
|
|
1186
|
-
parts
|
|
1362
|
+
parts
|
|
1187
1363
|
};
|
|
1188
1364
|
if (options?.agent) {
|
|
1189
1365
|
body.agent = options.agent;
|
|
@@ -1222,10 +1398,13 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1222
1398
|
best = { id, created };
|
|
1223
1399
|
}
|
|
1224
1400
|
}
|
|
1225
|
-
if (best)
|
|
1401
|
+
if (best) {
|
|
1402
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1403
|
+
return best.id;
|
|
1404
|
+
}
|
|
1226
1405
|
}
|
|
1227
1406
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1228
|
-
await new Promise((
|
|
1407
|
+
await new Promise((resolve3) => setTimeout(resolve3, READ_BACK_DELAY_MS));
|
|
1229
1408
|
}
|
|
1230
1409
|
}
|
|
1231
1410
|
return null;
|
|
@@ -1271,6 +1450,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
|
|
|
1271
1450
|
}
|
|
1272
1451
|
return lastOk ?? last;
|
|
1273
1452
|
}
|
|
1453
|
+
function messageUsage(messages, userMessageId) {
|
|
1454
|
+
if (!messages || messages.length === 0) return null;
|
|
1455
|
+
const byParentAll = messages.filter(
|
|
1456
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1457
|
+
);
|
|
1458
|
+
const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
|
|
1459
|
+
const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
|
|
1460
|
+
let correlated;
|
|
1461
|
+
if (byParent.length > 0) {
|
|
1462
|
+
correlated = byParent;
|
|
1463
|
+
} else {
|
|
1464
|
+
const reply = findAssistantReplyAfter(messages, userMessageId);
|
|
1465
|
+
correlated = reply ? [reply] : [];
|
|
1466
|
+
}
|
|
1467
|
+
if (correlated.length === 0) return null;
|
|
1468
|
+
let sawAnyUsage = false;
|
|
1469
|
+
let inputSum = 0;
|
|
1470
|
+
let outputSum = 0;
|
|
1471
|
+
let reasoningSum = 0;
|
|
1472
|
+
let cacheReadSum = 0;
|
|
1473
|
+
let cacheWriteSum = 0;
|
|
1474
|
+
let costSum = 0;
|
|
1475
|
+
let sawCost = false;
|
|
1476
|
+
let modelId = null;
|
|
1477
|
+
let providerId = null;
|
|
1478
|
+
for (const m of correlated) {
|
|
1479
|
+
const info = m.info;
|
|
1480
|
+
if (!info) continue;
|
|
1481
|
+
const tokens = info.tokens;
|
|
1482
|
+
if (tokens) {
|
|
1483
|
+
sawAnyUsage = true;
|
|
1484
|
+
inputSum += tokens.input ?? 0;
|
|
1485
|
+
outputSum += tokens.output ?? 0;
|
|
1486
|
+
reasoningSum += tokens.reasoning ?? 0;
|
|
1487
|
+
cacheReadSum += tokens.cache?.read ?? 0;
|
|
1488
|
+
cacheWriteSum += tokens.cache?.write ?? 0;
|
|
1489
|
+
}
|
|
1490
|
+
if (typeof info.cost === "number") {
|
|
1491
|
+
sawAnyUsage = true;
|
|
1492
|
+
sawCost = true;
|
|
1493
|
+
costSum += info.cost;
|
|
1494
|
+
}
|
|
1495
|
+
if (typeof info.modelID === "string") {
|
|
1496
|
+
sawAnyUsage = true;
|
|
1497
|
+
modelId = info.modelID;
|
|
1498
|
+
}
|
|
1499
|
+
if (typeof info.providerID === "string") {
|
|
1500
|
+
sawAnyUsage = true;
|
|
1501
|
+
providerId = info.providerID;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (!sawAnyUsage) return null;
|
|
1505
|
+
return {
|
|
1506
|
+
usage_provider_id: providerId,
|
|
1507
|
+
usage_model_id: modelId,
|
|
1508
|
+
usage_tokens_input: inputSum,
|
|
1509
|
+
usage_tokens_output: outputSum,
|
|
1510
|
+
usage_tokens_reasoning: reasoningSum,
|
|
1511
|
+
usage_tokens_cache_read: cacheReadSum,
|
|
1512
|
+
usage_tokens_cache_write: cacheWriteSum,
|
|
1513
|
+
// NULL means "OpenCode never reported a cost" (never inferred from
|
|
1514
|
+
// tokens) — distinct from a genuine 0-cost turn, which would set
|
|
1515
|
+
// `sawCost` true with `costSum === 0`.
|
|
1516
|
+
usage_cost_usd: sawCost ? costSum : null
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1274
1519
|
function messageRunState(messages, userMessageId) {
|
|
1275
1520
|
if (!messages || messages.length === 0) return "unknown";
|
|
1276
1521
|
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
@@ -1287,6 +1532,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
1287
1532
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1288
1533
|
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1289
1534
|
}
|
|
1535
|
+
function isB2AbandonmentConfirmed(params) {
|
|
1536
|
+
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
1537
|
+
}
|
|
1290
1538
|
function messageError(messages, userMessageId) {
|
|
1291
1539
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1292
1540
|
const error2 = errorOf(reply);
|
|
@@ -1306,6 +1554,37 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1306
1554
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1307
1555
|
);
|
|
1308
1556
|
}
|
|
1557
|
+
async function hasAnyConfiguredProvider(port) {
|
|
1558
|
+
try {
|
|
1559
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1560
|
+
if (!res.ok) {
|
|
1561
|
+
console.error(
|
|
1562
|
+
`[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1563
|
+
);
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
const body = await res.json();
|
|
1567
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1568
|
+
console.error(
|
|
1569
|
+
`[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
|
|
1570
|
+
);
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
const defaults2 = body.default;
|
|
1574
|
+
if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
|
|
1575
|
+
console.error(
|
|
1576
|
+
`[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
|
|
1577
|
+
);
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
return Object.keys(defaults2).length > 0;
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
console.error(
|
|
1583
|
+
`[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1584
|
+
);
|
|
1585
|
+
return null;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1309
1588
|
|
|
1310
1589
|
// src/lib/opencode/session-cleanup.ts
|
|
1311
1590
|
var DURATION_UNIT_MS = {
|
|
@@ -1464,10 +1743,11 @@ var StreamForwarder = class {
|
|
|
1464
1743
|
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1465
1744
|
*/
|
|
1466
1745
|
abortAll() {
|
|
1467
|
-
for (const stream of this.inflight.
|
|
1746
|
+
for (const [sid, stream] of this.inflight.entries()) {
|
|
1468
1747
|
try {
|
|
1469
1748
|
stream.abort();
|
|
1470
|
-
} catch {
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
|
|
1471
1751
|
}
|
|
1472
1752
|
}
|
|
1473
1753
|
this.inflight.clear();
|
|
@@ -1501,12 +1781,12 @@ var StreamForwarder = class {
|
|
|
1501
1781
|
let endBody;
|
|
1502
1782
|
if (has_body) {
|
|
1503
1783
|
const chunks = [];
|
|
1504
|
-
bodyPromise = new Promise((
|
|
1784
|
+
bodyPromise = new Promise((resolve3) => {
|
|
1505
1785
|
pushBody = (buf) => {
|
|
1506
1786
|
chunks.push(buf);
|
|
1507
1787
|
};
|
|
1508
1788
|
endBody = () => {
|
|
1509
|
-
|
|
1789
|
+
resolve3(Buffer.concat(chunks));
|
|
1510
1790
|
};
|
|
1511
1791
|
});
|
|
1512
1792
|
}
|
|
@@ -1617,31 +1897,20 @@ function connectTunnel(options) {
|
|
|
1617
1897
|
onConnected,
|
|
1618
1898
|
onDisconnected,
|
|
1619
1899
|
onError,
|
|
1620
|
-
onRequest,
|
|
1621
1900
|
onResponse,
|
|
1622
1901
|
onInfo,
|
|
1623
1902
|
onDrainPing
|
|
1624
1903
|
} = options;
|
|
1625
1904
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1626
1905
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1627
|
-
return new Promise((
|
|
1906
|
+
return new Promise((resolve3, reject) => {
|
|
1628
1907
|
const ws = new WebSocket2(url, {
|
|
1629
1908
|
headers: {
|
|
1630
1909
|
Authorization: authHeader
|
|
1631
1910
|
}
|
|
1632
1911
|
});
|
|
1633
|
-
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1634
1912
|
const forwarder = new StreamForwarder(ws, port, {
|
|
1635
|
-
|
|
1636
|
-
if (path === TUNNEL_DRAIN_PING_PATH) return;
|
|
1637
|
-
streamStartTimes.set(sid, Date.now());
|
|
1638
|
-
onRequest?.(method, path, sid);
|
|
1639
|
-
},
|
|
1640
|
-
onHead: (sid, status) => {
|
|
1641
|
-
const startedAt = streamStartTimes.get(sid);
|
|
1642
|
-
streamStartTimes.delete(sid);
|
|
1643
|
-
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1644
|
-
},
|
|
1913
|
+
onHead: () => onResponse?.(),
|
|
1645
1914
|
onDrainPing: () => onDrainPing?.()
|
|
1646
1915
|
});
|
|
1647
1916
|
const connectionTimeout = setTimeout(() => {
|
|
@@ -1689,7 +1958,7 @@ function connectTunnel(options) {
|
|
|
1689
1958
|
clearTimeout(connectionTimeout);
|
|
1690
1959
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1691
1960
|
onConnected?.(connectedAgentId);
|
|
1692
|
-
|
|
1961
|
+
resolve3({
|
|
1693
1962
|
ws,
|
|
1694
1963
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1695
1964
|
});
|
|
@@ -1717,7 +1986,6 @@ function connectTunnel(options) {
|
|
|
1717
1986
|
ws.on("close", (code, reason) => {
|
|
1718
1987
|
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1719
1988
|
forwarder.abortAll();
|
|
1720
|
-
streamStartTimes.clear();
|
|
1721
1989
|
onDisconnected?.(code, reasonStr);
|
|
1722
1990
|
});
|
|
1723
1991
|
});
|
|
@@ -1752,7 +2020,11 @@ var RunnerConnection = class {
|
|
|
1752
2020
|
if (this.connection) {
|
|
1753
2021
|
try {
|
|
1754
2022
|
this.connection.close();
|
|
1755
|
-
} catch {
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
log("error", "runner_connection_close_failed", {
|
|
2025
|
+
agent_id: this.resolvedAgentId,
|
|
2026
|
+
...errorFields(err)
|
|
2027
|
+
});
|
|
1756
2028
|
}
|
|
1757
2029
|
this.connection = null;
|
|
1758
2030
|
}
|
|
@@ -1804,6 +2076,404 @@ var RunnerConnection = class {
|
|
|
1804
2076
|
}
|
|
1805
2077
|
};
|
|
1806
2078
|
|
|
2079
|
+
// src/lib/channels/driver.ts
|
|
2080
|
+
import { homedir } from "os";
|
|
2081
|
+
|
|
2082
|
+
// src/lib/file-push.ts
|
|
2083
|
+
import { randomUUID } from "crypto";
|
|
2084
|
+
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2085
|
+
import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
|
|
2086
|
+
var FILE_MODE = 384;
|
|
2087
|
+
var DIRECTORY_MODE = 448;
|
|
2088
|
+
async function writePushedFile(request) {
|
|
2089
|
+
const { requestedPath, content, allowedDirectories, homeDir } = request;
|
|
2090
|
+
const bytes = content.byteLength;
|
|
2091
|
+
if (allowedDirectories.length === 0) {
|
|
2092
|
+
return refuse("file_sync_disabled", "File sync is not enabled on this runner.", {
|
|
2093
|
+
path: requestedPath,
|
|
2094
|
+
bytes
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
if (bytes > MAX_FILE_PUSH_BYTES) {
|
|
2098
|
+
return refuse(
|
|
2099
|
+
"file_too_large",
|
|
2100
|
+
`File is ${bytes} bytes; the limit is ${MAX_FILE_PUSH_BYTES}.`,
|
|
2101
|
+
{
|
|
2102
|
+
path: requestedPath,
|
|
2103
|
+
bytes
|
|
2104
|
+
}
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
const candidate = expandAndValidate(requestedPath, homeDir);
|
|
2108
|
+
if (candidate === null) {
|
|
2109
|
+
return refuse("invalid_path", "The requested path is not a valid absolute file path.", {
|
|
2110
|
+
path: requestedPath,
|
|
2111
|
+
bytes
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2116
|
+
dirname2(candidate)
|
|
2117
|
+
);
|
|
2118
|
+
const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
|
|
2119
|
+
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2120
|
+
if (allowedDirectory === null) {
|
|
2121
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2122
|
+
path: realTarget,
|
|
2123
|
+
bytes
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
if (missingSegments.length > 0) {
|
|
2127
|
+
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2128
|
+
const realParent = await realpath(dirname2(realTarget));
|
|
2129
|
+
if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2130
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2131
|
+
path: realTarget,
|
|
2132
|
+
bytes,
|
|
2133
|
+
reason: "parent_changed_after_create"
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
await writeAtomically(realTarget, content);
|
|
2138
|
+
log("info", "file_push_written", { path: realTarget, bytes });
|
|
2139
|
+
return { ok: true, path: realTarget };
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
const errno = err.code ?? "UNKNOWN";
|
|
2142
|
+
return refuse("write_failed", `The runner could not write the file (${errno}).`, {
|
|
2143
|
+
path: candidate,
|
|
2144
|
+
bytes,
|
|
2145
|
+
errno,
|
|
2146
|
+
...errorFields(err)
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
function expandAndValidate(requestedPath, homeDir) {
|
|
2151
|
+
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2152
|
+
return null;
|
|
2153
|
+
}
|
|
2154
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2155
|
+
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
if (!isAbsolute(expanded)) {
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
const candidate = resolve2(expanded);
|
|
2162
|
+
const name = basename(candidate);
|
|
2163
|
+
return name === "" || name === "." || name === ".." ? null : candidate;
|
|
2164
|
+
}
|
|
2165
|
+
async function resolveNearestExistingAncestor(directory) {
|
|
2166
|
+
const missingSegments = [];
|
|
2167
|
+
let current = directory;
|
|
2168
|
+
for (; ; ) {
|
|
2169
|
+
try {
|
|
2170
|
+
return { existingAncestor: await realpath(current), missingSegments };
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
const parent = dirname2(current);
|
|
2173
|
+
if (err.code !== "ENOENT" || parent === current) {
|
|
2174
|
+
throw err;
|
|
2175
|
+
}
|
|
2176
|
+
missingSegments.unshift(basename(current));
|
|
2177
|
+
current = parent;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
async function findContainingAllowedDirectory(allowedDirectories, realTarget) {
|
|
2182
|
+
for (const directory of allowedDirectories) {
|
|
2183
|
+
if (!isAbsolute(directory)) {
|
|
2184
|
+
log("warn", "file_push_allowed_directory_skipped", { directory, reason: "not_absolute" });
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
const realDirectory = await realpathCreatingIfMissing(directory);
|
|
2188
|
+
if (realDirectory !== null && contains(realDirectory, realTarget)) {
|
|
2189
|
+
return realDirectory;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
async function realpathCreatingIfMissing(directory) {
|
|
2195
|
+
try {
|
|
2196
|
+
return await realpath(directory);
|
|
2197
|
+
} catch (err) {
|
|
2198
|
+
if (err.code !== "ENOENT") {
|
|
2199
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2200
|
+
directory,
|
|
2201
|
+
reason: "unresolvable",
|
|
2202
|
+
...errorFields(err)
|
|
2203
|
+
});
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
2209
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
2210
|
+
return await realpath(directory);
|
|
2211
|
+
} catch (err) {
|
|
2212
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2213
|
+
directory,
|
|
2214
|
+
reason: "create_failed",
|
|
2215
|
+
...errorFields(err)
|
|
2216
|
+
});
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
function contains(realDirectory, realTarget) {
|
|
2221
|
+
const rel = relative(realDirectory, realTarget);
|
|
2222
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
2223
|
+
}
|
|
2224
|
+
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2225
|
+
let current = existingAncestor;
|
|
2226
|
+
for (const segment of missingSegments) {
|
|
2227
|
+
current = join(current, segment);
|
|
2228
|
+
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2229
|
+
await chmod(current, DIRECTORY_MODE);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
async function writeAtomically(realTarget, content) {
|
|
2233
|
+
const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2234
|
+
let handle;
|
|
2235
|
+
try {
|
|
2236
|
+
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
2237
|
+
await handle.writeFile(content);
|
|
2238
|
+
await handle.chmod(FILE_MODE);
|
|
2239
|
+
await handle.close();
|
|
2240
|
+
handle = void 0;
|
|
2241
|
+
await rename(temporaryPath, realTarget);
|
|
2242
|
+
} catch (err) {
|
|
2243
|
+
await discardTemporaryFile(temporaryPath, handle);
|
|
2244
|
+
throw err;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
async function discardTemporaryFile(temporaryPath, handle) {
|
|
2248
|
+
try {
|
|
2249
|
+
await handle?.close();
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
log("warn", "file_push_temp_close_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2252
|
+
}
|
|
2253
|
+
try {
|
|
2254
|
+
await unlink(temporaryPath);
|
|
2255
|
+
} catch (err) {
|
|
2256
|
+
const errno = err.code;
|
|
2257
|
+
if (errno !== "ENOENT" && errno !== "ENOTDIR") {
|
|
2258
|
+
log("warn", "file_push_temp_cleanup_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
function refuse(code, message, fields) {
|
|
2263
|
+
log(code === "write_failed" ? "error" : "warn", "file_push_refused", { code, ...fields });
|
|
2264
|
+
return { ok: false, code, message };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// src/lib/runner-file-sync.ts
|
|
2268
|
+
var MAX_ACK_ATTEMPTS = 5;
|
|
2269
|
+
async function syncPendingRunnerFiles(options) {
|
|
2270
|
+
const pending = await listPendingFiles(options);
|
|
2271
|
+
const pendingIds = new Set(pending.map((file) => file.id));
|
|
2272
|
+
for (const id of options.ackFailures.keys()) {
|
|
2273
|
+
if (!pendingIds.has(id)) options.ackFailures.delete(id);
|
|
2274
|
+
}
|
|
2275
|
+
if (pending.length === 0) return 0;
|
|
2276
|
+
options.log({
|
|
2277
|
+
level: "info",
|
|
2278
|
+
message: `Runner file sync: ${pending.length} file(s) queued for this runner`
|
|
2279
|
+
});
|
|
2280
|
+
let applied = 0;
|
|
2281
|
+
for (const file of pending) {
|
|
2282
|
+
if ((options.ackFailures.get(file.id) ?? 0) >= MAX_ACK_ATTEMPTS) continue;
|
|
2283
|
+
if (await applyOne(options, file)) applied += 1;
|
|
2284
|
+
}
|
|
2285
|
+
return applied;
|
|
2286
|
+
}
|
|
2287
|
+
async function listPendingFiles(options) {
|
|
2288
|
+
let res;
|
|
2289
|
+
try {
|
|
2290
|
+
res = await options.fetchImpl(`${options.apiUrl}/runners/${options.agentId}/files/pending`, {
|
|
2291
|
+
headers: { Authorization: options.getAuthHeader() }
|
|
2292
|
+
});
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
options.log({
|
|
2295
|
+
level: "warn",
|
|
2296
|
+
message: `Could not list pending runner files \u2014 retrying on the next drain: ${describe(err)}`
|
|
2297
|
+
});
|
|
2298
|
+
return [];
|
|
2299
|
+
}
|
|
2300
|
+
if (!res.ok) {
|
|
2301
|
+
options.log({
|
|
2302
|
+
level: res.status === 404 ? "debug" : "warn",
|
|
2303
|
+
message: `Listing pending runner files returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2304
|
+
});
|
|
2305
|
+
return [];
|
|
2306
|
+
}
|
|
2307
|
+
let body;
|
|
2308
|
+
try {
|
|
2309
|
+
body = await res.json();
|
|
2310
|
+
} catch (err) {
|
|
2311
|
+
options.log({
|
|
2312
|
+
level: "warn",
|
|
2313
|
+
message: `Pending runner file list was not readable JSON \u2014 retrying on the next drain: ${describe(err)}`
|
|
2314
|
+
});
|
|
2315
|
+
return [];
|
|
2316
|
+
}
|
|
2317
|
+
if (!Array.isArray(body)) {
|
|
2318
|
+
options.log({
|
|
2319
|
+
level: "warn",
|
|
2320
|
+
message: "Pending runner file list was not an array \u2014 ignoring it for this drain"
|
|
2321
|
+
});
|
|
2322
|
+
return [];
|
|
2323
|
+
}
|
|
2324
|
+
const files = [];
|
|
2325
|
+
for (const entry of body) {
|
|
2326
|
+
const file = asPendingFile(entry);
|
|
2327
|
+
if (file === null) {
|
|
2328
|
+
options.log({
|
|
2329
|
+
level: "warn",
|
|
2330
|
+
message: "Ignoring a malformed pending runner file entry (expected id, path and size)"
|
|
2331
|
+
});
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
files.push(file);
|
|
2335
|
+
}
|
|
2336
|
+
return files;
|
|
2337
|
+
}
|
|
2338
|
+
function asPendingFile(entry) {
|
|
2339
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
2340
|
+
const { id, path, size } = entry;
|
|
2341
|
+
if (typeof id !== "string" || id === "") return null;
|
|
2342
|
+
if (typeof path !== "string" || path === "") return null;
|
|
2343
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
|
|
2344
|
+
return { id, path, size };
|
|
2345
|
+
}
|
|
2346
|
+
async function applyOne(options, file) {
|
|
2347
|
+
const label = `${file.id.slice(0, 8)} (${file.path})`;
|
|
2348
|
+
if (options.allowedDirectories.length === 0) {
|
|
2349
|
+
options.log({
|
|
2350
|
+
level: "warn",
|
|
2351
|
+
message: `Runner file ${label} rejected: file sync is not enabled on this runner (start it with --enable-file-sync-to)`
|
|
2352
|
+
});
|
|
2353
|
+
await ack(options, file, "rejected", "file_sync_disabled");
|
|
2354
|
+
return false;
|
|
2355
|
+
}
|
|
2356
|
+
if (file.size > MAX_FILE_PUSH_BYTES) {
|
|
2357
|
+
options.log({
|
|
2358
|
+
level: "warn",
|
|
2359
|
+
message: `Runner file ${label} rejected: declared ${file.size} bytes, the limit is ${MAX_FILE_PUSH_BYTES}`
|
|
2360
|
+
});
|
|
2361
|
+
await ack(options, file, "rejected", "file_too_large");
|
|
2362
|
+
return false;
|
|
2363
|
+
}
|
|
2364
|
+
const download = await downloadContent(options, file, label);
|
|
2365
|
+
if (!download.ok) {
|
|
2366
|
+
if (download.terminal) await ack(options, file, "rejected", download.code);
|
|
2367
|
+
return false;
|
|
2368
|
+
}
|
|
2369
|
+
let outcome;
|
|
2370
|
+
try {
|
|
2371
|
+
outcome = await writePushedFile({
|
|
2372
|
+
requestedPath: file.path,
|
|
2373
|
+
content: download.content,
|
|
2374
|
+
allowedDirectories: options.allowedDirectories,
|
|
2375
|
+
homeDir: options.homeDir
|
|
2376
|
+
});
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
options.log({
|
|
2379
|
+
level: "error",
|
|
2380
|
+
message: `Runner file ${label} could not be written: ${describe(err)}`
|
|
2381
|
+
});
|
|
2382
|
+
await ack(options, file, "rejected", "write_failed");
|
|
2383
|
+
return false;
|
|
2384
|
+
}
|
|
2385
|
+
if (!outcome.ok) {
|
|
2386
|
+
options.log({
|
|
2387
|
+
level: "warn",
|
|
2388
|
+
message: `Runner file ${label} rejected (${outcome.code}): ${outcome.message}`
|
|
2389
|
+
});
|
|
2390
|
+
await ack(options, file, "rejected", outcome.code);
|
|
2391
|
+
return false;
|
|
2392
|
+
}
|
|
2393
|
+
options.log({
|
|
2394
|
+
level: "info",
|
|
2395
|
+
message: `Runner file ${label} applied (${download.content.byteLength} bytes)`
|
|
2396
|
+
});
|
|
2397
|
+
await ack(options, file, "applied");
|
|
2398
|
+
return true;
|
|
2399
|
+
}
|
|
2400
|
+
function durableDownloadCode(status) {
|
|
2401
|
+
return status === 413 ? "file_too_large" : "write_failed";
|
|
2402
|
+
}
|
|
2403
|
+
async function downloadContent(options, file, label) {
|
|
2404
|
+
try {
|
|
2405
|
+
const res = await options.fetchImpl(
|
|
2406
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/content`,
|
|
2407
|
+
{ headers: { Authorization: options.getAuthHeader() } }
|
|
2408
|
+
);
|
|
2409
|
+
if (!res.ok) {
|
|
2410
|
+
const terminal = res.status >= 400 && res.status < 500 && res.status !== 401 && res.status !== 403 && res.status !== 408 && res.status !== 429;
|
|
2411
|
+
if (!terminal) {
|
|
2412
|
+
options.log({
|
|
2413
|
+
level: "warn",
|
|
2414
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2415
|
+
});
|
|
2416
|
+
return { ok: false, terminal: false };
|
|
2417
|
+
}
|
|
2418
|
+
const code = durableDownloadCode(res.status);
|
|
2419
|
+
options.log({
|
|
2420
|
+
level: "error",
|
|
2421
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 rejecting it as ${code} (the bytes never reached the writer)`
|
|
2422
|
+
});
|
|
2423
|
+
return { ok: false, terminal: true, code };
|
|
2424
|
+
}
|
|
2425
|
+
return { ok: true, content: Buffer.from(await res.arrayBuffer()) };
|
|
2426
|
+
} catch (err) {
|
|
2427
|
+
options.log({
|
|
2428
|
+
level: "warn",
|
|
2429
|
+
message: `Downloading runner file ${label} failed \u2014 retrying on the next drain: ${describe(err)}`
|
|
2430
|
+
});
|
|
2431
|
+
return { ok: false, terminal: false };
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
async function ack(options, file, status, reason) {
|
|
2435
|
+
const outcome = `${status}${reason ? ` (${reason})` : ""}`;
|
|
2436
|
+
try {
|
|
2437
|
+
const res = await options.fetchImpl(
|
|
2438
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
2439
|
+
{
|
|
2440
|
+
method: "POST",
|
|
2441
|
+
headers: {
|
|
2442
|
+
Authorization: options.getAuthHeader(),
|
|
2443
|
+
"Content-Type": "application/json"
|
|
2444
|
+
},
|
|
2445
|
+
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
2446
|
+
}
|
|
2447
|
+
);
|
|
2448
|
+
if (!res.ok) {
|
|
2449
|
+
recordAckFailure(
|
|
2450
|
+
options,
|
|
2451
|
+
file,
|
|
2452
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} returned HTTP ${res.status}`
|
|
2453
|
+
);
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
options.ackFailures.delete(file.id);
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
recordAckFailure(
|
|
2459
|
+
options,
|
|
2460
|
+
file,
|
|
2461
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} failed: ${describe(err)}`
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
function recordAckFailure(options, file, what) {
|
|
2466
|
+
const attempts = (options.ackFailures.get(file.id) ?? 0) + 1;
|
|
2467
|
+
options.ackFailures.set(file.id, attempts);
|
|
2468
|
+
options.log({
|
|
2469
|
+
level: "error",
|
|
2470
|
+
message: attempts >= MAX_ACK_ATTEMPTS ? `${what} \u2014 giving up after ${attempts} attempts. It stays pending until the server expires it; restart the runner to retry.` : `${what} \u2014 it stays pending until a later drain re-acks it (attempt ${attempts} of ${MAX_ACK_ATTEMPTS})`
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
function describe(err) {
|
|
2474
|
+
return err instanceof Error ? err.message : String(err);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
1807
2477
|
// src/lib/channels/driver.ts
|
|
1808
2478
|
function messageIdOf(m) {
|
|
1809
2479
|
if (!m || typeof m !== "object") return void 0;
|
|
@@ -1811,6 +2481,17 @@ function messageIdOf(m) {
|
|
|
1811
2481
|
const infoId = m.info?.id;
|
|
1812
2482
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1813
2483
|
}
|
|
2484
|
+
function cleanImageMime(contentType) {
|
|
2485
|
+
if (!contentType) return null;
|
|
2486
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
2487
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
2488
|
+
}
|
|
2489
|
+
var LOG_LEVELS = {
|
|
2490
|
+
debug: 0,
|
|
2491
|
+
info: 1,
|
|
2492
|
+
warn: 2,
|
|
2493
|
+
error: 3
|
|
2494
|
+
};
|
|
1814
2495
|
var DEFAULT_RETRY_POLICY = {
|
|
1815
2496
|
maxAttempts: 6,
|
|
1816
2497
|
baseDelayMs: 500,
|
|
@@ -1821,7 +2502,10 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
|
1821
2502
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1822
2503
|
var HEARTBEAT_MS = 6e4;
|
|
1823
2504
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
2505
|
+
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
2506
|
+
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
1824
2507
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2508
|
+
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
1825
2509
|
var ChannelAuthError = class extends Error {
|
|
1826
2510
|
constructor(message) {
|
|
1827
2511
|
super(message);
|
|
@@ -1844,7 +2528,7 @@ function backoffDelay(attempt, policy) {
|
|
|
1844
2528
|
function isRetryableStatus(status) {
|
|
1845
2529
|
return status === 429 || status >= 500 && status <= 599;
|
|
1846
2530
|
}
|
|
1847
|
-
var ChannelDriver = class {
|
|
2531
|
+
var ChannelDriver = class _ChannelDriver {
|
|
1848
2532
|
agentId;
|
|
1849
2533
|
port;
|
|
1850
2534
|
apiUrl;
|
|
@@ -1858,8 +2542,38 @@ var ChannelDriver = class {
|
|
|
1858
2542
|
pausedMaxWaitMs;
|
|
1859
2543
|
stuckQueuedMs;
|
|
1860
2544
|
now;
|
|
2545
|
+
fileSyncDirectories;
|
|
2546
|
+
homeDir;
|
|
1861
2547
|
/** Cache of conversationId → opencode sessionId. */
|
|
1862
2548
|
sessions = /* @__PURE__ */ new Map();
|
|
2549
|
+
/**
|
|
2550
|
+
* conversationId → the opencode session this runner has ABANDONED as that
|
|
2551
|
+
* conversation's binding (#553), after a genuine (`sessionExists === true`)
|
|
2552
|
+
* dispatch failure: the session still exists but is wedged, so #485's self-heal
|
|
2553
|
+
* must bind a fresh one.
|
|
2554
|
+
*
|
|
2555
|
+
* Dropping the local binding + clearing the server row is not enough on its own:
|
|
2556
|
+
* a SIBLING message dispatched earlier in the same drain is still in-flight under
|
|
2557
|
+
* the same session, and its watcher's routine status writes carry
|
|
2558
|
+
* `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
|
|
2559
|
+
* and `ensureSession`'s persisted-id fallback then reuses it, defeating the
|
|
2560
|
+
* self-heal. This map makes the runner authoritative instead of racing those
|
|
2561
|
+
* writes: *`ensureSession` never reuses an abandoned id for that conversation,
|
|
2562
|
+
* whatever the server row says* — which holds even when the resurrecting write
|
|
2563
|
+
* is one we deliberately keep (see `markDone`).
|
|
2564
|
+
*
|
|
2565
|
+
* Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
|
|
2566
|
+
* one conversation hold ONE entry (the newest abandonment replaces the older), and
|
|
2567
|
+
* hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
|
|
2568
|
+
* NEWEST abandoned id per conversation is guarded: after a second abandonment a
|
|
2569
|
+
* late sibling of the FIRST session can write that id back and `ensureSession`
|
|
2570
|
+
* will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
|
|
2571
|
+
* NOT dropped when the session's watcher tears down: `markDone` still writes the
|
|
2572
|
+
* abandoned id back (it must, or the reply is lost), so the guard has to outlive
|
|
2573
|
+
* the turn that resurrects it. In-memory only — a restart forgets it, at the same
|
|
2574
|
+
* bounded cost.
|
|
2575
|
+
*/
|
|
2576
|
+
supersededSessions = /* @__PURE__ */ new Map();
|
|
1863
2577
|
/**
|
|
1864
2578
|
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1865
2579
|
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
@@ -1941,6 +2655,15 @@ var ChannelDriver = class {
|
|
|
1941
2655
|
* so the NEXT tick may retry exactly once more).
|
|
1942
2656
|
*/
|
|
1943
2657
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2658
|
+
/**
|
|
2659
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2660
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2661
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2662
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2663
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2664
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2665
|
+
*/
|
|
2666
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1944
2667
|
/**
|
|
1945
2668
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1946
2669
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1958,8 +2681,39 @@ var ChannelDriver = class {
|
|
|
1958
2681
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1959
2682
|
*/
|
|
1960
2683
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2684
|
+
/**
|
|
2685
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2686
|
+
* NON-EMPTY, non-placeholder name is stored (terminal — a real session name
|
|
2687
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
|
|
2688
|
+
* excludes OpenCode's synchronous default title (see
|
|
2689
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
|
|
2690
|
+
* as an empty title so it never latches. A missing entry = not yet resolved OR
|
|
2691
|
+
* resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
|
|
2692
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2693
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2694
|
+
* no watcher) can resolve the title.
|
|
2695
|
+
*/
|
|
2696
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1961
2697
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1962
2698
|
draining = false;
|
|
2699
|
+
/**
|
|
2700
|
+
* Serialises runner-file syncs (#559) so the ~2s poll tick and a concurrent
|
|
2701
|
+
* drain ping don't download, write and ack the same file twice.
|
|
2702
|
+
*/
|
|
2703
|
+
syncingFiles = false;
|
|
2704
|
+
/**
|
|
2705
|
+
* Consecutive failed acks per pending file (#559). Lives on the driver so it
|
|
2706
|
+
* survives across drains — without it, a file whose ack keeps failing is
|
|
2707
|
+
* re-downloaded and re-written every ~2s until the server expires it.
|
|
2708
|
+
*/
|
|
2709
|
+
fileAckFailures = /* @__PURE__ */ new Map();
|
|
2710
|
+
/**
|
|
2711
|
+
* Monotonic count of files this runner has pulled and written (#559). Only
|
|
2712
|
+
* ever increases, so `run.ts` detects work by comparing it against the value
|
|
2713
|
+
* it saw on the previous cycle — including work that landed mid-sleep, the
|
|
2714
|
+
* same trick `lastProxiedActivityAt` uses.
|
|
2715
|
+
*/
|
|
2716
|
+
appliedFileCount = 0;
|
|
1963
2717
|
/**
|
|
1964
2718
|
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
1965
2719
|
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
@@ -1990,14 +2744,13 @@ var ChannelDriver = class {
|
|
|
1990
2744
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1991
2745
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1992
2746
|
this.now = config2.now ?? (() => Date.now());
|
|
2747
|
+
this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
|
|
2748
|
+
this.homeDir = config2.homeDir ?? homedir();
|
|
1993
2749
|
}
|
|
1994
2750
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1995
2751
|
get opencodeBase() {
|
|
1996
2752
|
return `http://127.0.0.1:${this.port}`;
|
|
1997
2753
|
}
|
|
1998
|
-
// -------------------------------------------------------------------------
|
|
1999
|
-
// Public API
|
|
2000
|
-
// -------------------------------------------------------------------------
|
|
2001
2754
|
/**
|
|
2002
2755
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
2003
2756
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2020,6 +2773,47 @@ var ChannelDriver = class {
|
|
|
2020
2773
|
);
|
|
2021
2774
|
return run2;
|
|
2022
2775
|
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Pull-and-apply any files Evident has queued for this runner (#559), riding
|
|
2778
|
+
* the EXISTING drain cycle — `run.ts` calls it from the same ~2s channel poll
|
|
2779
|
+
* and drain ping that call `drainPending()`. There is deliberately no channel,
|
|
2780
|
+
* control frame or poll loop of its own: worst-case latency is one poll tick.
|
|
2781
|
+
*
|
|
2782
|
+
* NEVER throws and never surfaces a `ChannelAuthError`: a file failure must not
|
|
2783
|
+
* cost a conversation turn. Failures are logged and either acked as a terminal
|
|
2784
|
+
* outcome or left pending for the next drain (see `runner-file-sync.ts`).
|
|
2785
|
+
*
|
|
2786
|
+
* Re-entrant calls are skipped (the poll tick and a drain ping can overlap).
|
|
2787
|
+
*
|
|
2788
|
+
* @returns the number of files written to disk.
|
|
2789
|
+
*/
|
|
2790
|
+
async syncPendingFiles() {
|
|
2791
|
+
if (this.stopped) return 0;
|
|
2792
|
+
if (this.syncingFiles) return 0;
|
|
2793
|
+
this.syncingFiles = true;
|
|
2794
|
+
try {
|
|
2795
|
+
const applied = await syncPendingRunnerFiles({
|
|
2796
|
+
agentId: this.agentId,
|
|
2797
|
+
apiUrl: this.apiUrl,
|
|
2798
|
+
getAuthHeader: this.getAuthHeader,
|
|
2799
|
+
fetchImpl: this.fetchImpl,
|
|
2800
|
+
allowedDirectories: this.fileSyncDirectories,
|
|
2801
|
+
homeDir: this.homeDir,
|
|
2802
|
+
ackFailures: this.fileAckFailures,
|
|
2803
|
+
log: this.log
|
|
2804
|
+
});
|
|
2805
|
+
this.appliedFileCount += applied;
|
|
2806
|
+
return applied;
|
|
2807
|
+
} catch (err) {
|
|
2808
|
+
this.log({
|
|
2809
|
+
level: "error",
|
|
2810
|
+
message: `Runner file sync failed unexpectedly (message processing is unaffected): ${err instanceof Error ? err.message : String(err)}`
|
|
2811
|
+
});
|
|
2812
|
+
return 0;
|
|
2813
|
+
} finally {
|
|
2814
|
+
this.syncingFiles = false;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2023
2817
|
async runDrain() {
|
|
2024
2818
|
let dispatched = 0;
|
|
2025
2819
|
try {
|
|
@@ -2053,6 +2847,28 @@ var ChannelDriver = class {
|
|
|
2053
2847
|
}
|
|
2054
2848
|
return false;
|
|
2055
2849
|
}
|
|
2850
|
+
/**
|
|
2851
|
+
* File-pull work, for `run.ts`'s idle accounting (#559).
|
|
2852
|
+
*
|
|
2853
|
+
* Pulling a file is real work that `drainPending()` knows nothing about, so
|
|
2854
|
+
* without this a near-idle runner counts a credential pull as an empty tick
|
|
2855
|
+
* and `--idle-timeout` can `process.exit` mid-pull — leaving a
|
|
2856
|
+
* `.evident-push-*.tmp` behind — or immediately after the write, before the
|
|
2857
|
+
* browser has run the authorize/callback that activates it (the user then sees
|
|
2858
|
+
* `saved_not_activated` for a runner that was fine).
|
|
2859
|
+
*
|
|
2860
|
+
* Two signals because one cannot cover both cases: `inFlight` is the pull
|
|
2861
|
+
* happening RIGHT NOW (it may outlive the tick that started it), and
|
|
2862
|
+
* `appliedFiles` is monotonic so a pull that started AND finished between two
|
|
2863
|
+
* idle checks still shows up as an advance.
|
|
2864
|
+
*
|
|
2865
|
+
* CALLER CONTRACT: sample `inFlight` BEFORE calling `syncPendingFiles()` for
|
|
2866
|
+
* the cycle. `syncPendingFiles` sets the flag synchronously, so a caller that
|
|
2867
|
+
* samples afterwards reads `true` every single cycle and can never idle out.
|
|
2868
|
+
*/
|
|
2869
|
+
fileSyncActivity() {
|
|
2870
|
+
return { appliedFiles: this.appliedFileCount, inFlight: this.syncingFiles };
|
|
2871
|
+
}
|
|
2056
2872
|
/**
|
|
2057
2873
|
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2058
2874
|
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
@@ -2114,7 +2930,7 @@ var ChannelDriver = class {
|
|
|
2114
2930
|
await this.sleep(step);
|
|
2115
2931
|
}
|
|
2116
2932
|
}
|
|
2117
|
-
while (this.hasInFlightWatchers()) {
|
|
2933
|
+
while (this.hasInFlightWatchers() || this.syncingFiles) {
|
|
2118
2934
|
if (this.now() >= deadline) return false;
|
|
2119
2935
|
await this.sleep(step);
|
|
2120
2936
|
}
|
|
@@ -2139,9 +2955,7 @@ var ChannelDriver = class {
|
|
|
2139
2955
|
if (!stillLive) return;
|
|
2140
2956
|
}
|
|
2141
2957
|
}
|
|
2142
|
-
// -------------------------------------------------------------------------
|
|
2143
2958
|
// Conversation processing (WI-3 — async dispatch)
|
|
2144
|
-
// -------------------------------------------------------------------------
|
|
2145
2959
|
/**
|
|
2146
2960
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2147
2961
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2151,10 +2965,15 @@ var ChannelDriver = class {
|
|
|
2151
2965
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
2152
2966
|
*/
|
|
2153
2967
|
async processConversation(conv) {
|
|
2154
|
-
const sessionId = await this.ensureSession(conv);
|
|
2968
|
+
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
2155
2969
|
const messages = await this.getPendingMessages(conv.id);
|
|
2156
2970
|
let dispatched = 0;
|
|
2157
2971
|
let skippedAlreadyDispatched = 0;
|
|
2972
|
+
if (refusedSessionId && messages.length > 0) {
|
|
2973
|
+
void this.postSignal(conv.id, messages[0].id, "session_superseded", {
|
|
2974
|
+
superseded_session_id: refusedSessionId
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2158
2977
|
for (const message of messages) {
|
|
2159
2978
|
if (this.stopped) break;
|
|
2160
2979
|
if (this.dispatched.has(message.id)) {
|
|
@@ -2173,36 +2992,62 @@ var ChannelDriver = class {
|
|
|
2173
2992
|
conversation_id: conv.id,
|
|
2174
2993
|
message_id: message.id
|
|
2175
2994
|
});
|
|
2995
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2176
2996
|
opencodeMessageId = await this.dispatchLocked(
|
|
2177
2997
|
sessionId,
|
|
2178
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2998
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2179
2999
|
);
|
|
2180
3000
|
} catch (err) {
|
|
2181
3001
|
if (err instanceof ChannelAuthError) throw err;
|
|
2182
3002
|
this.dispatched.delete(message.id);
|
|
2183
|
-
|
|
3003
|
+
const exists = await sessionExists(this.port, sessionId);
|
|
3004
|
+
if (exists === false) {
|
|
2184
3005
|
this.sessions.delete(conv.id);
|
|
2185
3006
|
this.log({
|
|
2186
|
-
level: "
|
|
3007
|
+
level: "warn",
|
|
2187
3008
|
message: `Message ${message.id.slice(0, 8)} dispatch hit a session (${sessionId.slice(0, 8)}) that was deleted mid-dispatch (cleanup race) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick (recreated then, in order). Already-dispatched turns keep their watcher.`,
|
|
2188
3009
|
conversation_id: conv.id,
|
|
2189
3010
|
message_id: message.id
|
|
2190
3011
|
});
|
|
2191
3012
|
break;
|
|
2192
3013
|
}
|
|
2193
|
-
|
|
3014
|
+
if (exists === null) {
|
|
3015
|
+
this.log({
|
|
3016
|
+
level: "warn",
|
|
3017
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed and session (${sessionId.slice(0, 8)}) existence could not be confirmed (opencode momentarily unreachable) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick rather than treating it as a genuine failure.`,
|
|
3018
|
+
conversation_id: conv.id,
|
|
3019
|
+
message_id: message.id
|
|
3020
|
+
});
|
|
3021
|
+
break;
|
|
3022
|
+
}
|
|
3023
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
3024
|
+
this.sessions.delete(conv.id);
|
|
3025
|
+
this.supersede(conv.id, sessionId);
|
|
3026
|
+
this.log({
|
|
3027
|
+
level: "warn",
|
|
3028
|
+
message: `Abandoning OpenCode session ${sessionId.slice(0, 8)} as the binding for conversation ${conv.id.slice(0, 8)} (it exists but failed to run a turn) \u2014 a fresh session is created on the next tick, whatever the persisted binding says by then.`,
|
|
3029
|
+
conversation_id: conv.id,
|
|
3030
|
+
message_id: message.id
|
|
3031
|
+
});
|
|
3032
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
3033
|
+
this.log({
|
|
3034
|
+
level: "warn",
|
|
3035
|
+
message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
3036
|
+
conversation_id: conv.id,
|
|
3037
|
+
message_id: message.id
|
|
3038
|
+
});
|
|
2194
3039
|
});
|
|
2195
3040
|
this.log({
|
|
2196
3041
|
level: "error",
|
|
2197
|
-
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${
|
|
3042
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
|
|
2198
3043
|
conversation_id: conv.id,
|
|
2199
3044
|
message_id: message.id
|
|
2200
3045
|
});
|
|
2201
|
-
|
|
3046
|
+
break;
|
|
2202
3047
|
}
|
|
2203
3048
|
if (opencodeMessageId === null) {
|
|
2204
3049
|
this.log({
|
|
2205
|
-
level: "
|
|
3050
|
+
level: "warn",
|
|
2206
3051
|
message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
|
|
2207
3052
|
conversation_id: conv.id,
|
|
2208
3053
|
message_id: message.id
|
|
@@ -2216,7 +3061,7 @@ var ChannelDriver = class {
|
|
|
2216
3061
|
}
|
|
2217
3062
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2218
3063
|
this.log({
|
|
2219
|
-
level: "
|
|
3064
|
+
level: "warn",
|
|
2220
3065
|
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
2221
3066
|
conversation_id: conv.id
|
|
2222
3067
|
});
|
|
@@ -2224,23 +3069,57 @@ var ChannelDriver = class {
|
|
|
2224
3069
|
this.ensureWatcherRunning(sessionId);
|
|
2225
3070
|
return dispatched;
|
|
2226
3071
|
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
3074
|
+
* (#553). Keyed by conversation and hard-capped, so it cannot grow with the
|
|
3075
|
+
* number of failures — see the `supersededSessions` field doc.
|
|
3076
|
+
*/
|
|
3077
|
+
supersede(conversationId, sessionId) {
|
|
3078
|
+
this.supersededSessions.delete(conversationId);
|
|
3079
|
+
this.supersededSessions.set(conversationId, sessionId);
|
|
3080
|
+
while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
|
|
3081
|
+
const oldest = this.supersededSessions.keys().next().value;
|
|
3082
|
+
if (oldest === void 0) return;
|
|
3083
|
+
this.supersededSessions.delete(oldest);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
/** Whether `sessionId` is the session this conversation has abandoned (#553). */
|
|
3087
|
+
isSuperseded(conversationId, sessionId) {
|
|
3088
|
+
return this.supersededSessions.get(conversationId) === sessionId;
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Resolve the opencode session to run this conversation's turns in.
|
|
3092
|
+
*
|
|
3093
|
+
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
3094
|
+
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
3095
|
+
* happened and a fresh session was bound instead. The caller reports it.
|
|
3096
|
+
*/
|
|
2227
3097
|
async ensureSession(conv) {
|
|
2228
3098
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
3099
|
+
if (bound && this.isSuperseded(conv.id, bound)) {
|
|
3100
|
+
this.log({
|
|
3101
|
+
level: "warn",
|
|
3102
|
+
message: `OpenCode session ${bound.slice(0, 8)} was abandoned for conversation ${conv.id.slice(0, 8)} after a failed dispatch but is still bound to it (the persisted id was written back by a turn already in flight) \u2014 ignoring it and binding a fresh session.`,
|
|
3103
|
+
conversation_id: conv.id
|
|
3104
|
+
});
|
|
3105
|
+
this.sessions.delete(conv.id);
|
|
3106
|
+
return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
|
|
3107
|
+
}
|
|
2229
3108
|
if (bound) {
|
|
2230
3109
|
const exists = await sessionExists(this.port, bound);
|
|
2231
3110
|
if (exists === false) {
|
|
2232
3111
|
this.log({
|
|
2233
|
-
level: "
|
|
3112
|
+
level: "debug",
|
|
2234
3113
|
message: `OpenCode session ${bound} for conversation ${conv.id.slice(0, 8)} no longer exists (deleted or DB reset) \u2014 creating a fresh session and rebinding.`,
|
|
2235
3114
|
conversation_id: conv.id
|
|
2236
3115
|
});
|
|
2237
3116
|
this.sessions.delete(conv.id);
|
|
2238
|
-
return this.createAndBindSession(conv.id);
|
|
3117
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2239
3118
|
}
|
|
2240
3119
|
this.sessions.set(conv.id, bound);
|
|
2241
|
-
return bound;
|
|
3120
|
+
return { sessionId: bound };
|
|
2242
3121
|
}
|
|
2243
|
-
return this.createAndBindSession(conv.id);
|
|
3122
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2244
3123
|
}
|
|
2245
3124
|
/**
|
|
2246
3125
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -2265,15 +3144,13 @@ var ChannelDriver = class {
|
|
|
2265
3144
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2266
3145
|
if (!this.opencodeDirectory) {
|
|
2267
3146
|
this.log({
|
|
2268
|
-
level: "
|
|
3147
|
+
level: "warn",
|
|
2269
3148
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2270
3149
|
});
|
|
2271
3150
|
}
|
|
2272
3151
|
return this.opencodeDirectory;
|
|
2273
3152
|
}
|
|
2274
|
-
// -------------------------------------------------------------------------
|
|
2275
3153
|
// Per-session watcher (WI-3)
|
|
2276
|
-
// -------------------------------------------------------------------------
|
|
2277
3154
|
/**
|
|
2278
3155
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2279
3156
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2293,6 +3170,130 @@ var ChannelDriver = class {
|
|
|
2293
3170
|
);
|
|
2294
3171
|
return run2;
|
|
2295
3172
|
}
|
|
3173
|
+
// Inbound image attachments (#255, WI-8)
|
|
3174
|
+
/**
|
|
3175
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
3176
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
3177
|
+
*
|
|
3178
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
3179
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
3180
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
3181
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
3182
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
3183
|
+
* existing callback surface when any image was skipped/failed.
|
|
3184
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
3185
|
+
* reports outcomes back via `onOutcomes`.
|
|
3186
|
+
*/
|
|
3187
|
+
buildSendAttachments(conv, message) {
|
|
3188
|
+
const refs = message.attachments;
|
|
3189
|
+
if (!refs || refs.length === 0) return void 0;
|
|
3190
|
+
return {
|
|
3191
|
+
inputs: refs.map((a, index) => ({
|
|
3192
|
+
index,
|
|
3193
|
+
mime: a.mime,
|
|
3194
|
+
...a.filename ? { filename: a.filename } : {}
|
|
3195
|
+
})),
|
|
3196
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
3197
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
3202
|
+
* (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
|
|
3203
|
+
* existing authenticated fetch, and base64-encode into a
|
|
3204
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
3205
|
+
*
|
|
3206
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
3207
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
3208
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
3209
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
3210
|
+
* A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
|
|
3211
|
+
* a Slack `files:read` scope problem via `files.info`) instead resolves the
|
|
3212
|
+
* `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
|
|
3213
|
+
* user to reconnect Slack instead of a generic "unavailable". Failures are
|
|
3214
|
+
* logged with context (no silent swallow).
|
|
3215
|
+
*/
|
|
3216
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
3217
|
+
try {
|
|
3218
|
+
const res = await this.fetchImpl(
|
|
3219
|
+
`${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
|
|
3220
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3221
|
+
);
|
|
3222
|
+
if (!res.ok) {
|
|
3223
|
+
let reason;
|
|
3224
|
+
try {
|
|
3225
|
+
const body = await res.json();
|
|
3226
|
+
if (body && typeof body.reason === "string") reason = body.reason;
|
|
3227
|
+
} catch (parseErr) {
|
|
3228
|
+
this.log({
|
|
3229
|
+
level: "debug",
|
|
3230
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index}: error body was not JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}) \u2014 treating as a plain failure`,
|
|
3231
|
+
message_id: messageId
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
if (reason === "needs_reauth") {
|
|
3235
|
+
this.log({
|
|
3236
|
+
level: "error",
|
|
3237
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 server confirmed a Slack reauth/scope problem \u2014 omitting this image (text turn proceeds)`,
|
|
3238
|
+
message_id: messageId
|
|
3239
|
+
});
|
|
3240
|
+
return { needsReauth: true };
|
|
3241
|
+
}
|
|
3242
|
+
this.log({
|
|
3243
|
+
level: "error",
|
|
3244
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
3245
|
+
message_id: messageId
|
|
3246
|
+
});
|
|
3247
|
+
return null;
|
|
3248
|
+
}
|
|
3249
|
+
const buf = await res.arrayBuffer();
|
|
3250
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
3251
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
3252
|
+
return `data:${dataMime};base64,${base64}`;
|
|
3253
|
+
} catch (err) {
|
|
3254
|
+
this.log({
|
|
3255
|
+
level: "error",
|
|
3256
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} failed \u2014 omitting this image (text turn proceeds): ${err instanceof Error ? err.message : String(err)}`,
|
|
3257
|
+
message_id: messageId
|
|
3258
|
+
});
|
|
3259
|
+
return null;
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
3264
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
3265
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
3266
|
+
*
|
|
3267
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
3268
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
3269
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
3270
|
+
* reaches the channel.
|
|
3271
|
+
*
|
|
3272
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
3273
|
+
*/
|
|
3274
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
3275
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
3276
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
3277
|
+
if (skipped === 0 && failed === 0) return;
|
|
3278
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
3279
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
3280
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
3281
|
+
const failedReason = outcomes.some(
|
|
3282
|
+
(o) => o.status === "failed" && o.reason === "needs_reauth"
|
|
3283
|
+
) ? "needs_reauth" : void 0;
|
|
3284
|
+
this.log({
|
|
3285
|
+
level: "info",
|
|
3286
|
+
message: `Message ${messageId.slice(0, 8)}: ${skipped} image(s) skipped (${capabilityUnknown ? "capability was unreadable \u2014 failed open to text-only" : "model not attachment-capable"}), ${failed} image(s) unavailable (deleted-at-source or fetch failure) \u2014 noting to Evident`,
|
|
3287
|
+
conversation_id: conversationId,
|
|
3288
|
+
message_id: messageId
|
|
3289
|
+
});
|
|
3290
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
3291
|
+
skipped,
|
|
3292
|
+
failed,
|
|
3293
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {},
|
|
3294
|
+
...failedReason ? { failed_reason: failedReason } : {}
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
2296
3297
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2297
3298
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2298
3299
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2321,12 +3322,17 @@ var ChannelDriver = class {
|
|
|
2321
3322
|
stuckReported: false,
|
|
2322
3323
|
lastAliveAt: 0,
|
|
2323
3324
|
aliveInFlight: false,
|
|
3325
|
+
titleSynced: false,
|
|
3326
|
+
titleSyncInFlight: false,
|
|
2324
3327
|
awaitingHumanLatched: false,
|
|
2325
3328
|
pausedOnQuestion: false,
|
|
2326
3329
|
pausedOnPermission: false,
|
|
2327
3330
|
pausedClearConfirmed: false,
|
|
2328
3331
|
pausedInFlight: false,
|
|
2329
|
-
deliveryDeadlineAnchored: false
|
|
3332
|
+
deliveryDeadlineAnchored: false,
|
|
3333
|
+
b2PinnedSinceMs: 0,
|
|
3334
|
+
b2LastDescendantCheckMs: 0,
|
|
3335
|
+
b2AbandonedSignalled: false
|
|
2330
3336
|
});
|
|
2331
3337
|
}
|
|
2332
3338
|
/**
|
|
@@ -2394,12 +3400,17 @@ var ChannelDriver = class {
|
|
|
2394
3400
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2395
3401
|
lastAliveAt: 0,
|
|
2396
3402
|
aliveInFlight: false,
|
|
3403
|
+
titleSynced: false,
|
|
3404
|
+
titleSyncInFlight: false,
|
|
2397
3405
|
awaitingHumanLatched: false,
|
|
2398
3406
|
pausedOnQuestion: false,
|
|
2399
3407
|
pausedOnPermission: false,
|
|
2400
3408
|
pausedClearConfirmed: false,
|
|
2401
3409
|
pausedInFlight: false,
|
|
2402
|
-
deliveryDeadlineAnchored: false
|
|
3410
|
+
deliveryDeadlineAnchored: false,
|
|
3411
|
+
b2PinnedSinceMs: 0,
|
|
3412
|
+
b2LastDescendantCheckMs: 0,
|
|
3413
|
+
b2AbandonedSignalled: false
|
|
2403
3414
|
});
|
|
2404
3415
|
}
|
|
2405
3416
|
/**
|
|
@@ -2530,18 +3541,20 @@ var ChannelDriver = class {
|
|
|
2530
3541
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2531
3542
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2532
3543
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
3544
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2533
3545
|
let claimed;
|
|
2534
3546
|
try {
|
|
2535
3547
|
claimed = await this.markProcessing(
|
|
2536
3548
|
conv.id,
|
|
2537
3549
|
inFlight.evidentMessageId,
|
|
2538
3550
|
sessionId,
|
|
2539
|
-
inFlight.opencodeMessageId
|
|
3551
|
+
inFlight.opencodeMessageId,
|
|
3552
|
+
title
|
|
2540
3553
|
);
|
|
2541
3554
|
} catch (err) {
|
|
2542
3555
|
if (err instanceof ChannelAuthError) throw err;
|
|
2543
3556
|
this.log({
|
|
2544
|
-
level: "
|
|
3557
|
+
level: "warn",
|
|
2545
3558
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
3559
|
conversation_id: conv.id,
|
|
2547
3560
|
message_id: inFlight.evidentMessageId
|
|
@@ -2551,7 +3564,7 @@ var ChannelDriver = class {
|
|
|
2551
3564
|
inFlight.started = true;
|
|
2552
3565
|
if (!claimed) {
|
|
2553
3566
|
this.log({
|
|
2554
|
-
level: "
|
|
3567
|
+
level: "debug",
|
|
2555
3568
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2556
3569
|
conversation_id: conv.id,
|
|
2557
3570
|
message_id: inFlight.evidentMessageId
|
|
@@ -2559,54 +3572,7 @@ var ChannelDriver = class {
|
|
|
2559
3572
|
}
|
|
2560
3573
|
}
|
|
2561
3574
|
if (state === "done") {
|
|
2562
|
-
this.
|
|
2563
|
-
if (!inFlight.done) {
|
|
2564
|
-
this.log({
|
|
2565
|
-
level: "info",
|
|
2566
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
2567
|
-
conversation_id: conv.id,
|
|
2568
|
-
message_id: inFlight.evidentMessageId
|
|
2569
|
-
});
|
|
2570
|
-
try {
|
|
2571
|
-
await this.markDone(
|
|
2572
|
-
conv.id,
|
|
2573
|
-
inFlight.evidentMessageId,
|
|
2574
|
-
sessionId,
|
|
2575
|
-
inFlight.opencodeMessageId
|
|
2576
|
-
);
|
|
2577
|
-
} catch (err) {
|
|
2578
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
2579
|
-
if (err instanceof ChannelTerminalError) {
|
|
2580
|
-
this.log({
|
|
2581
|
-
level: "error",
|
|
2582
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2583
|
-
conversation_id: conv.id,
|
|
2584
|
-
message_id: inFlight.evidentMessageId
|
|
2585
|
-
});
|
|
2586
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2587
|
-
return;
|
|
2588
|
-
}
|
|
2589
|
-
if (this.now() >= inFlight.deadline) {
|
|
2590
|
-
this.log({
|
|
2591
|
-
level: "error",
|
|
2592
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2593
|
-
conversation_id: conv.id,
|
|
2594
|
-
message_id: inFlight.evidentMessageId
|
|
2595
|
-
});
|
|
2596
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2597
|
-
return;
|
|
2598
|
-
}
|
|
2599
|
-
this.log({
|
|
2600
|
-
level: "error",
|
|
2601
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2602
|
-
conversation_id: conv.id,
|
|
2603
|
-
message_id: inFlight.evidentMessageId
|
|
2604
|
-
});
|
|
2605
|
-
return;
|
|
2606
|
-
}
|
|
2607
|
-
inFlight.done = true;
|
|
2608
|
-
}
|
|
2609
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3575
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
2610
3576
|
return;
|
|
2611
3577
|
}
|
|
2612
3578
|
if (state === "failed") {
|
|
@@ -2619,13 +3585,14 @@ var ChannelDriver = class {
|
|
|
2619
3585
|
conversation_id: conv.id,
|
|
2620
3586
|
message_id: inFlight.evidentMessageId
|
|
2621
3587
|
});
|
|
3588
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2622
3589
|
try {
|
|
2623
|
-
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
3590
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
|
|
2624
3591
|
} catch (err) {
|
|
2625
3592
|
if (err instanceof ChannelAuthError) throw err;
|
|
2626
3593
|
if (err instanceof ChannelTerminalError) {
|
|
2627
3594
|
this.log({
|
|
2628
|
-
level: "
|
|
3595
|
+
level: "warn",
|
|
2629
3596
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2630
3597
|
conversation_id: conv.id,
|
|
2631
3598
|
message_id: inFlight.evidentMessageId
|
|
@@ -2635,7 +3602,7 @@ var ChannelDriver = class {
|
|
|
2635
3602
|
}
|
|
2636
3603
|
if (this.now() >= inFlight.deadline) {
|
|
2637
3604
|
this.log({
|
|
2638
|
-
level: "
|
|
3605
|
+
level: "warn",
|
|
2639
3606
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2640
3607
|
conversation_id: conv.id,
|
|
2641
3608
|
message_id: inFlight.evidentMessageId
|
|
@@ -2644,7 +3611,7 @@ var ChannelDriver = class {
|
|
|
2644
3611
|
return;
|
|
2645
3612
|
}
|
|
2646
3613
|
this.log({
|
|
2647
|
-
level: "
|
|
3614
|
+
level: "warn",
|
|
2648
3615
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2649
3616
|
conversation_id: conv.id,
|
|
2650
3617
|
message_id: inFlight.evidentMessageId
|
|
@@ -2665,9 +3632,47 @@ var ChannelDriver = class {
|
|
|
2665
3632
|
});
|
|
2666
3633
|
}
|
|
2667
3634
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
3635
|
+
const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
|
|
3636
|
+
const snapshotReadable = messages != null && messages.length > 0;
|
|
3637
|
+
if (!pinnedNow) {
|
|
3638
|
+
if (snapshotReadable) {
|
|
3639
|
+
inFlight.b2PinnedSinceMs = 0;
|
|
3640
|
+
inFlight.b2LastDescendantCheckMs = 0;
|
|
3641
|
+
inFlight.b2AbandonedSignalled = false;
|
|
3642
|
+
}
|
|
3643
|
+
} else {
|
|
3644
|
+
if (inFlight.b2AbandonedSignalled) {
|
|
3645
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
|
|
3649
|
+
const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
|
|
3650
|
+
if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
|
|
3651
|
+
inFlight.b2LastDescendantCheckMs = this.now();
|
|
3652
|
+
const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
|
|
3653
|
+
if (isB2AbandonmentConfirmed({
|
|
3654
|
+
pinnedForMs,
|
|
3655
|
+
minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
|
|
3656
|
+
descendantOngoing
|
|
3657
|
+
})) {
|
|
3658
|
+
inFlight.b2AbandonedSignalled = true;
|
|
3659
|
+
this.log({
|
|
3660
|
+
level: "warn",
|
|
3661
|
+
message: `Message ${id.slice(0, 8)} b2-pinned for ${Math.round(pinnedForMs / 1e3)}s with no ongoing descendant sub-agent session (status-map confirmed) \u2014 treating the delegated/tool turn as abandoned, resolving done`,
|
|
3662
|
+
conversation_id: conv.id,
|
|
3663
|
+
message_id: id
|
|
3664
|
+
});
|
|
3665
|
+
void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
|
|
3666
|
+
watched_for_ms: pinnedForMs
|
|
3667
|
+
});
|
|
3668
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
2668
3673
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2669
3674
|
this.log({
|
|
2670
|
-
level: "
|
|
3675
|
+
level: "warn",
|
|
2671
3676
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
|
|
2672
3677
|
conversation_id: conv.id,
|
|
2673
3678
|
message_id: inFlight.evidentMessageId
|
|
@@ -2684,6 +3689,18 @@ var ChannelDriver = class {
|
|
|
2684
3689
|
inFlight.aliveInFlight = false;
|
|
2685
3690
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
2686
3691
|
});
|
|
3692
|
+
if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
|
|
3693
|
+
inFlight.titleSyncInFlight = true;
|
|
3694
|
+
void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
|
|
3695
|
+
if (!title) {
|
|
3696
|
+
inFlight.titleSyncInFlight = false;
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
const ok = await this.patchConversationTitle(conv.id, title);
|
|
3700
|
+
inFlight.titleSyncInFlight = false;
|
|
3701
|
+
if (ok) inFlight.titleSynced = true;
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
2687
3704
|
}
|
|
2688
3705
|
if (awaitingHuman) {
|
|
2689
3706
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -2710,7 +3727,7 @@ var ChannelDriver = class {
|
|
|
2710
3727
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2711
3728
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2712
3729
|
this.log({
|
|
2713
|
-
level: "
|
|
3730
|
+
level: "debug",
|
|
2714
3731
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2715
3732
|
conversation_id: conv.id,
|
|
2716
3733
|
message_id: inFlight.evidentMessageId
|
|
@@ -2721,9 +3738,71 @@ var ChannelDriver = class {
|
|
|
2721
3738
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2722
3739
|
}
|
|
2723
3740
|
}
|
|
2724
|
-
|
|
3741
|
+
/**
|
|
3742
|
+
* Settle a message whose run-state has resolved `'done'` — extracted verbatim
|
|
3743
|
+
* (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
|
|
3744
|
+
* inline `state === 'done'` branch body, so a SECOND caller (the #721
|
|
3745
|
+
* b2-abandonment resolution) can reach the exact same completion behavior
|
|
3746
|
+
* (delivery-deadline anchoring, title resolution, usage extraction, and
|
|
3747
|
+
* `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
|
|
3748
|
+
* and risking the two copies silently drifting apart.
|
|
3749
|
+
*/
|
|
3750
|
+
async settleMessageDone(sessionId, watcher, inFlight, messages) {
|
|
3751
|
+
const conv = watcher.conv;
|
|
3752
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
3753
|
+
if (!inFlight.done) {
|
|
3754
|
+
this.log({
|
|
3755
|
+
level: "info",
|
|
3756
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
3757
|
+
conversation_id: conv.id,
|
|
3758
|
+
message_id: inFlight.evidentMessageId
|
|
3759
|
+
});
|
|
3760
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3761
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
3762
|
+
try {
|
|
3763
|
+
await this.markDone(
|
|
3764
|
+
conv.id,
|
|
3765
|
+
inFlight.evidentMessageId,
|
|
3766
|
+
sessionId,
|
|
3767
|
+
inFlight.opencodeMessageId,
|
|
3768
|
+
title,
|
|
3769
|
+
usage
|
|
3770
|
+
);
|
|
3771
|
+
} catch (err) {
|
|
3772
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
3773
|
+
if (err instanceof ChannelTerminalError) {
|
|
3774
|
+
this.log({
|
|
3775
|
+
level: "warn",
|
|
3776
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
3777
|
+
conversation_id: conv.id,
|
|
3778
|
+
message_id: inFlight.evidentMessageId
|
|
3779
|
+
});
|
|
3780
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
3783
|
+
if (this.now() >= inFlight.deadline) {
|
|
3784
|
+
this.log({
|
|
3785
|
+
level: "warn",
|
|
3786
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
3787
|
+
conversation_id: conv.id,
|
|
3788
|
+
message_id: inFlight.evidentMessageId
|
|
3789
|
+
});
|
|
3790
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3791
|
+
return;
|
|
3792
|
+
}
|
|
3793
|
+
this.log({
|
|
3794
|
+
level: "warn",
|
|
3795
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
3796
|
+
conversation_id: conv.id,
|
|
3797
|
+
message_id: inFlight.evidentMessageId
|
|
3798
|
+
});
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
inFlight.done = true;
|
|
3802
|
+
}
|
|
3803
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3804
|
+
}
|
|
2725
3805
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2726
|
-
// -------------------------------------------------------------------------
|
|
2727
3806
|
/**
|
|
2728
3807
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2729
3808
|
*
|
|
@@ -2753,7 +3832,7 @@ var ChannelDriver = class {
|
|
|
2753
3832
|
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2754
3833
|
if (cleared || clearedUndeliverable) {
|
|
2755
3834
|
this.log({
|
|
2756
|
-
level: "
|
|
3835
|
+
level: "debug",
|
|
2757
3836
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2758
3837
|
message_id: id
|
|
2759
3838
|
});
|
|
@@ -2766,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
2766
3845
|
for (const row of rows) {
|
|
2767
3846
|
if (!row.opencode_session_id) {
|
|
2768
3847
|
this.log({
|
|
2769
|
-
level: "
|
|
3848
|
+
level: "warn",
|
|
2770
3849
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2771
3850
|
conversation_id: row.conversation_id,
|
|
2772
3851
|
message_id: row.id
|
|
@@ -2783,7 +3862,7 @@ var ChannelDriver = class {
|
|
|
2783
3862
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2784
3863
|
if (!res.ok) {
|
|
2785
3864
|
this.log({
|
|
2786
|
-
level: "
|
|
3865
|
+
level: "warn",
|
|
2787
3866
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2788
3867
|
});
|
|
2789
3868
|
continue;
|
|
@@ -2791,7 +3870,7 @@ var ChannelDriver = class {
|
|
|
2791
3870
|
const body = await res.json();
|
|
2792
3871
|
if (!Array.isArray(body)) {
|
|
2793
3872
|
this.log({
|
|
2794
|
-
level: "
|
|
3873
|
+
level: "warn",
|
|
2795
3874
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2796
3875
|
});
|
|
2797
3876
|
continue;
|
|
@@ -2799,7 +3878,7 @@ var ChannelDriver = class {
|
|
|
2799
3878
|
messages = body;
|
|
2800
3879
|
} catch (err) {
|
|
2801
3880
|
this.log({
|
|
2802
|
-
level: "
|
|
3881
|
+
level: "warn",
|
|
2803
3882
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2804
3883
|
});
|
|
2805
3884
|
continue;
|
|
@@ -2832,7 +3911,7 @@ var ChannelDriver = class {
|
|
|
2832
3911
|
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2833
3912
|
if (this.isTracked(sessionId, row.id)) {
|
|
2834
3913
|
this.log({
|
|
2835
|
-
level: "
|
|
3914
|
+
level: "debug",
|
|
2836
3915
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2837
3916
|
conversation_id: row.conversation_id,
|
|
2838
3917
|
message_id: row.id
|
|
@@ -2844,7 +3923,7 @@ var ChannelDriver = class {
|
|
|
2844
3923
|
if (state === "done") {
|
|
2845
3924
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2846
3925
|
this.log({
|
|
2847
|
-
level: "
|
|
3926
|
+
level: "debug",
|
|
2848
3927
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2849
3928
|
conversation_id: row.conversation_id,
|
|
2850
3929
|
message_id: row.id
|
|
@@ -2858,13 +3937,15 @@ var ChannelDriver = class {
|
|
|
2858
3937
|
message_id: row.id
|
|
2859
3938
|
});
|
|
2860
3939
|
try {
|
|
2861
|
-
await this.
|
|
3940
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3941
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3942
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
2862
3943
|
} catch (err) {
|
|
2863
3944
|
if (err instanceof ChannelAuthError) throw err;
|
|
2864
3945
|
if (err instanceof ChannelTerminalError) {
|
|
2865
3946
|
this.doneUndeliverable.add(row.id);
|
|
2866
3947
|
this.log({
|
|
2867
|
-
level: "
|
|
3948
|
+
level: "warn",
|
|
2868
3949
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2869
3950
|
conversation_id: row.conversation_id,
|
|
2870
3951
|
message_id: row.id
|
|
@@ -2873,7 +3954,7 @@ var ChannelDriver = class {
|
|
|
2873
3954
|
return;
|
|
2874
3955
|
}
|
|
2875
3956
|
this.log({
|
|
2876
|
-
level: "
|
|
3957
|
+
level: "warn",
|
|
2877
3958
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2878
3959
|
conversation_id: row.conversation_id,
|
|
2879
3960
|
message_id: row.id
|
|
@@ -2886,6 +3967,7 @@ var ChannelDriver = class {
|
|
|
2886
3967
|
}
|
|
2887
3968
|
if (state === "failed") {
|
|
2888
3969
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3970
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
2889
3971
|
this.log({
|
|
2890
3972
|
level: "error",
|
|
2891
3973
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2893,13 +3975,13 @@ var ChannelDriver = class {
|
|
|
2893
3975
|
message_id: row.id
|
|
2894
3976
|
});
|
|
2895
3977
|
try {
|
|
2896
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
3978
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
2897
3979
|
} catch (err) {
|
|
2898
3980
|
if (err instanceof ChannelAuthError) throw err;
|
|
2899
3981
|
if (err instanceof ChannelTerminalError) {
|
|
2900
3982
|
this.doneUndeliverable.add(row.id);
|
|
2901
3983
|
this.log({
|
|
2902
|
-
level: "
|
|
3984
|
+
level: "warn",
|
|
2903
3985
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2904
3986
|
conversation_id: row.conversation_id,
|
|
2905
3987
|
message_id: row.id
|
|
@@ -2908,7 +3990,7 @@ var ChannelDriver = class {
|
|
|
2908
3990
|
return;
|
|
2909
3991
|
}
|
|
2910
3992
|
this.log({
|
|
2911
|
-
level: "
|
|
3993
|
+
level: "warn",
|
|
2912
3994
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2913
3995
|
conversation_id: row.conversation_id,
|
|
2914
3996
|
message_id: row.id
|
|
@@ -2921,7 +4003,7 @@ var ChannelDriver = class {
|
|
|
2921
4003
|
}
|
|
2922
4004
|
if (this.dontRedispatch.has(row.id)) {
|
|
2923
4005
|
this.log({
|
|
2924
|
-
level: "
|
|
4006
|
+
level: "debug",
|
|
2925
4007
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2926
4008
|
conversation_id: row.conversation_id,
|
|
2927
4009
|
message_id: row.id
|
|
@@ -2946,7 +4028,7 @@ var ChannelDriver = class {
|
|
|
2946
4028
|
}
|
|
2947
4029
|
if (ongoing === true) {
|
|
2948
4030
|
this.log({
|
|
2949
|
-
level: "
|
|
4031
|
+
level: "debug",
|
|
2950
4032
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
|
|
2951
4033
|
conversation_id: row.conversation_id,
|
|
2952
4034
|
message_id: row.id
|
|
@@ -2954,7 +4036,7 @@ var ChannelDriver = class {
|
|
|
2954
4036
|
} else {
|
|
2955
4037
|
if (shape === "b1") {
|
|
2956
4038
|
this.log({
|
|
2957
|
-
level: "
|
|
4039
|
+
level: "debug",
|
|
2958
4040
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
|
|
2959
4041
|
conversation_id: row.conversation_id,
|
|
2960
4042
|
message_id: row.id
|
|
@@ -2966,7 +4048,7 @@ var ChannelDriver = class {
|
|
|
2966
4048
|
return;
|
|
2967
4049
|
}
|
|
2968
4050
|
this.log({
|
|
2969
|
-
level: "
|
|
4051
|
+
level: "debug",
|
|
2970
4052
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
|
|
2971
4053
|
conversation_id: row.conversation_id,
|
|
2972
4054
|
message_id: row.id
|
|
@@ -2977,7 +4059,7 @@ var ChannelDriver = class {
|
|
|
2977
4059
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2978
4060
|
if (descendantAlive === true) {
|
|
2979
4061
|
this.log({
|
|
2980
|
-
level: "
|
|
4062
|
+
level: "debug",
|
|
2981
4063
|
message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
|
|
2982
4064
|
conversation_id: row.conversation_id,
|
|
2983
4065
|
message_id: row.id
|
|
@@ -3001,7 +4083,7 @@ var ChannelDriver = class {
|
|
|
3001
4083
|
this.readopted.add(row.id);
|
|
3002
4084
|
this.ensureWatcherRunning(sessionId);
|
|
3003
4085
|
this.log({
|
|
3004
|
-
level: "
|
|
4086
|
+
level: "debug",
|
|
3005
4087
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
3006
4088
|
conversation_id: row.conversation_id,
|
|
3007
4089
|
message_id: row.id
|
|
@@ -3035,7 +4117,7 @@ var ChannelDriver = class {
|
|
|
3035
4117
|
async forceReadoptRun(sessionId, row) {
|
|
3036
4118
|
if (this.stopped) {
|
|
3037
4119
|
this.log({
|
|
3038
|
-
level: "
|
|
4120
|
+
level: "debug",
|
|
3039
4121
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but the runner is stopping \u2014 not starting a fresh turn; leaving for restart recovery`,
|
|
3040
4122
|
conversation_id: row.conversation_id,
|
|
3041
4123
|
message_id: row.id
|
|
@@ -3044,7 +4126,7 @@ var ChannelDriver = class {
|
|
|
3044
4126
|
}
|
|
3045
4127
|
if (this.awaitingReadopt.has(row.id)) {
|
|
3046
4128
|
this.log({
|
|
3047
|
-
level: "
|
|
4129
|
+
level: "debug",
|
|
3048
4130
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3049
4131
|
conversation_id: row.conversation_id,
|
|
3050
4132
|
message_id: row.id
|
|
@@ -3054,7 +4136,7 @@ var ChannelDriver = class {
|
|
|
3054
4136
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
3055
4137
|
this.dontRedispatch.add(row.id);
|
|
3056
4138
|
this.log({
|
|
3057
|
-
level: "
|
|
4139
|
+
level: "debug",
|
|
3058
4140
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but its re-adopt window has already elapsed \u2014 not dispatching an unwatchable turn; parking until it leaves processing (cron will reset it)`,
|
|
3059
4141
|
conversation_id: row.conversation_id,
|
|
3060
4142
|
message_id: row.id
|
|
@@ -3073,17 +4155,20 @@ var ChannelDriver = class {
|
|
|
3073
4155
|
message_id: row.id
|
|
3074
4156
|
});
|
|
3075
4157
|
this.awaitingReadopt.add(row.id);
|
|
4158
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
4159
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
4160
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
3076
4161
|
let ocId;
|
|
3077
4162
|
try {
|
|
3078
4163
|
ocId = await this.dispatchLocked(
|
|
3079
4164
|
sessionId,
|
|
3080
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
4165
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
3081
4166
|
);
|
|
3082
4167
|
} catch (err) {
|
|
3083
4168
|
this.awaitingReadopt.delete(row.id);
|
|
3084
4169
|
if (err instanceof ChannelAuthError) throw err;
|
|
3085
4170
|
this.log({
|
|
3086
|
-
level: "
|
|
4171
|
+
level: "warn",
|
|
3087
4172
|
message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3088
4173
|
conversation_id: row.conversation_id,
|
|
3089
4174
|
message_id: row.id
|
|
@@ -3094,7 +4179,7 @@ var ChannelDriver = class {
|
|
|
3094
4179
|
if (ocId === null) {
|
|
3095
4180
|
this.awaitingReadopt.delete(row.id);
|
|
3096
4181
|
this.log({
|
|
3097
|
-
level: "
|
|
4182
|
+
level: "warn",
|
|
3098
4183
|
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
|
|
3099
4184
|
conversation_id: row.conversation_id,
|
|
3100
4185
|
message_id: row.id
|
|
@@ -3102,9 +4187,7 @@ var ChannelDriver = class {
|
|
|
3102
4187
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3103
4188
|
return;
|
|
3104
4189
|
}
|
|
3105
|
-
|
|
3106
|
-
const message = this.queuedMessageForRow(row);
|
|
3107
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
4190
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
3108
4191
|
this.dispatched.add(row.id);
|
|
3109
4192
|
this.readopted.add(row.id);
|
|
3110
4193
|
this.awaitingReadopt.delete(row.id);
|
|
@@ -3158,7 +4241,8 @@ var ChannelDriver = class {
|
|
|
3158
4241
|
opencode_agent: row.opencode_agent,
|
|
3159
4242
|
opencode_model: row.opencode_model,
|
|
3160
4243
|
source_message_id: row.source_message_id,
|
|
3161
|
-
slack_user_id: row.slack_user_id
|
|
4244
|
+
slack_user_id: row.slack_user_id,
|
|
4245
|
+
attachments: row.attachments ?? null
|
|
3162
4246
|
};
|
|
3163
4247
|
}
|
|
3164
4248
|
/**
|
|
@@ -3179,7 +4263,7 @@ var ChannelDriver = class {
|
|
|
3179
4263
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3180
4264
|
this.dontRedispatch.add(evidentMessageId);
|
|
3181
4265
|
this.log({
|
|
3182
|
-
level: "
|
|
4266
|
+
level: "debug",
|
|
3183
4267
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3184
4268
|
conversation_id: watcher.conv.id,
|
|
3185
4269
|
message_id: evidentMessageId
|
|
@@ -3297,6 +4381,47 @@ var ChannelDriver = class {
|
|
|
3297
4381
|
}
|
|
3298
4382
|
return false;
|
|
3299
4383
|
}
|
|
4384
|
+
/**
|
|
4385
|
+
* Tri-state variant of the upward parentID membership walk (#721), used ONLY
|
|
4386
|
+
* by `isAnyDescendantSessionOngoing`. Walks the SAME cached
|
|
4387
|
+
* `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
|
|
4388
|
+
* `sessionBelongsTo`, which deliberately collapses "confirmed not a
|
|
4389
|
+
* descendant" and "the walk's fetch failed" into the same `false` (safe for
|
|
4390
|
+
* its OTHER callers: interaction attribution and the recovery-path
|
|
4391
|
+
* `isAnyDescendantSessionAlive`, both of which just retry next tick with no
|
|
4392
|
+
* safety consequence either way) — this variant keeps those two outcomes
|
|
4393
|
+
* SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
|
|
4394
|
+
* (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
|
|
4395
|
+
* not ongoing".
|
|
4396
|
+
*
|
|
4397
|
+
* Return contract:
|
|
4398
|
+
* - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
|
|
4399
|
+
* - `false` → the walk reached a definitive, parent-less root session
|
|
4400
|
+
* WITHOUT ever matching `rootSessionId` — `sessionId` is
|
|
4401
|
+
* CONFIRMED NOT a descendant of it.
|
|
4402
|
+
* - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
|
|
4403
|
+
* through the walk (`resolveSessionParent` returned `undefined`),
|
|
4404
|
+
* or the depth cap (32) was hit without a definitive answer (a
|
|
4405
|
+
* pathological/cyclic chain proves nothing either way). NEVER
|
|
4406
|
+
* treat this the same as `false` — see `sessionBelongsTo`'s own
|
|
4407
|
+
* doc comment above for why that collapse is safe THERE but not
|
|
4408
|
+
* here.
|
|
4409
|
+
*
|
|
4410
|
+
* `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
|
|
4411
|
+
* to the live-path descendant check, not a modification of shared code used
|
|
4412
|
+
* by interaction attribution or the recovery path.
|
|
4413
|
+
*/
|
|
4414
|
+
async resolveSessionMembership(sessionId, rootSessionId) {
|
|
4415
|
+
let current = sessionId;
|
|
4416
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
4417
|
+
if (current === rootSessionId) return true;
|
|
4418
|
+
const parent = await this.resolveSessionParent(current);
|
|
4419
|
+
if (parent === void 0) return null;
|
|
4420
|
+
if (parent === null) return false;
|
|
4421
|
+
current = parent;
|
|
4422
|
+
}
|
|
4423
|
+
return null;
|
|
4424
|
+
}
|
|
3300
4425
|
/**
|
|
3301
4426
|
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
3302
4427
|
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
@@ -3319,6 +4444,116 @@ var ChannelDriver = class {
|
|
|
3319
4444
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3320
4445
|
return parent;
|
|
3321
4446
|
}
|
|
4447
|
+
/**
|
|
4448
|
+
* OpenCode's synchronous default session title (e.g.
|
|
4449
|
+
* `"New session - 1737800000000"`), assigned immediately when a session is
|
|
4450
|
+
* created — before OpenCode's async LLM-based auto-titling later renames it
|
|
4451
|
+
* mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
|
|
4452
|
+
* timestamp suffix's exact format is deliberately NOT matched, since the prefix
|
|
4453
|
+
* alone is the stable, cheap signal and over-anchoring on the timestamp
|
|
4454
|
+
* representation risks silently breaking if OpenCode ever changes it. Accepted
|
|
4455
|
+
* trade-off: a genuine LLM-assigned title that happens to literally start with
|
|
4456
|
+
* this prefix would also fail to latch (see `resolveSessionTitle`) —
|
|
4457
|
+
* vanishingly unlikely in practice, and deliberately not engineered around.
|
|
4458
|
+
*/
|
|
4459
|
+
static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
|
|
4460
|
+
/**
|
|
4461
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
4462
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
4463
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
4464
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
4465
|
+
* Best-effort:
|
|
4466
|
+
* - a resolved NON-EMPTY title that does NOT match
|
|
4467
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
|
|
4468
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
4469
|
+
* - while the title is still absent, empty, or matches the OpenCode
|
|
4470
|
+
* placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
|
|
4471
|
+
* asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
|
|
4472
|
+
* the cache unresolved and re-fetch on the next need so a later call (e.g. at
|
|
4473
|
+
* `done`) picks up the name assigned in the meantime. Such a call returns
|
|
4474
|
+
* `null` (omit the title on THIS PATCH) without caching. If a session is
|
|
4475
|
+
* never renamed, the title is omitted forever rather than ever persisting
|
|
4476
|
+
* the placeholder as a last resort;
|
|
4477
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
4478
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
4479
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
4480
|
+
*/
|
|
4481
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
4482
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
4483
|
+
if (cached != null) return cached;
|
|
4484
|
+
try {
|
|
4485
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
4486
|
+
if (res.ok) {
|
|
4487
|
+
const body = await res.json();
|
|
4488
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
4489
|
+
if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
|
|
4490
|
+
this.sessionTitles.set(sessionId, title);
|
|
4491
|
+
return title;
|
|
4492
|
+
}
|
|
4493
|
+
return null;
|
|
4494
|
+
}
|
|
4495
|
+
this.log({
|
|
4496
|
+
level: "debug",
|
|
4497
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
4498
|
+
conversation_id: conversationId
|
|
4499
|
+
});
|
|
4500
|
+
} catch (err) {
|
|
4501
|
+
this.log({
|
|
4502
|
+
level: "debug",
|
|
4503
|
+
message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
|
|
4504
|
+
conversation_id: conversationId
|
|
4505
|
+
});
|
|
4506
|
+
}
|
|
4507
|
+
return null;
|
|
4508
|
+
}
|
|
4509
|
+
/**
|
|
4510
|
+
* Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
|
|
4511
|
+
* session title onto the conversation via the PLAIN conversation-update
|
|
4512
|
+
* endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
|
|
4513
|
+
* message-status endpoint `markProcessing`/`markDone` use. Deliberately a
|
|
4514
|
+
* separate, lighter call: it carries no `status`, so it cannot re-trigger the
|
|
4515
|
+
* `processing`/`done` transition side effects (Slack notices, activity-log
|
|
4516
|
+
* rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
|
|
4517
|
+
* ever touches `conversations.title`. That route (`routes/conversations.ts`)
|
|
4518
|
+
* skips a title write matching the stored value, so a redundant call with the
|
|
4519
|
+
* same title is a real no-op — it does not bump `updated_at`, which the
|
|
4520
|
+
* conversation list sorts and paginates on. (Note this is a DIFFERENT guard
|
|
4521
|
+
* from `threads.ts`'s "non-empty AND changed" one, which only covers the
|
|
4522
|
+
* message-status PATCH; the non-empty half is enforced here instead, by
|
|
4523
|
+
* `resolveSessionTitle` never returning an empty/placeholder title.)
|
|
4524
|
+
*
|
|
4525
|
+
* Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
|
|
4526
|
+
* is logged and the title is simply retried on the next heartbeat tick (the
|
|
4527
|
+
* caller only latches `titleSynced` on `true`).
|
|
4528
|
+
*/
|
|
4529
|
+
async patchConversationTitle(conversationId, title) {
|
|
4530
|
+
try {
|
|
4531
|
+
const res = await this.fetchImpl(
|
|
4532
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
|
|
4533
|
+
{
|
|
4534
|
+
method: "PATCH",
|
|
4535
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
4536
|
+
body: JSON.stringify({ title })
|
|
4537
|
+
}
|
|
4538
|
+
);
|
|
4539
|
+
if (!res.ok) {
|
|
4540
|
+
this.log({
|
|
4541
|
+
level: "debug",
|
|
4542
|
+
message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
|
|
4543
|
+
conversation_id: conversationId
|
|
4544
|
+
});
|
|
4545
|
+
return false;
|
|
4546
|
+
}
|
|
4547
|
+
return true;
|
|
4548
|
+
} catch (err) {
|
|
4549
|
+
this.log({
|
|
4550
|
+
level: "debug",
|
|
4551
|
+
message: `Best-effort mid-turn title sync PATCH failed for conversation ${conversationId.slice(0, 8)} (will retry next heartbeat): ${err instanceof Error ? err.message : String(err)}`,
|
|
4552
|
+
conversation_id: conversationId
|
|
4553
|
+
});
|
|
4554
|
+
return false;
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
3322
4557
|
/**
|
|
3323
4558
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3324
4559
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3362,7 +4597,7 @@ var ChannelDriver = class {
|
|
|
3362
4597
|
const sessions = await listSessions(this.port);
|
|
3363
4598
|
if (!sessions) {
|
|
3364
4599
|
this.log({
|
|
3365
|
-
level: "
|
|
4600
|
+
level: "warn",
|
|
3366
4601
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3367
4602
|
});
|
|
3368
4603
|
return null;
|
|
@@ -3377,6 +4612,84 @@ var ChannelDriver = class {
|
|
|
3377
4612
|
}
|
|
3378
4613
|
return false;
|
|
3379
4614
|
}
|
|
4615
|
+
/**
|
|
4616
|
+
* LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
|
|
4617
|
+
* sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
|
|
4618
|
+
* in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
|
|
4619
|
+
*
|
|
4620
|
+
* Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
|
|
4621
|
+
* cross-check above): that method judges liveness from the child's OWN
|
|
4622
|
+
* TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
|
|
4623
|
+
* on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
|
|
4624
|
+
* path the local opencode server IS running, so its in-memory status map is
|
|
4625
|
+
* live and authoritative — and per ADR-0047 §4a ("the child has its own entry
|
|
4626
|
+
* [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
|
|
4627
|
+
* ENTIRE turn (including any tool call it is itself executing), not a
|
|
4628
|
+
* per-message transcript snapshot. This sidesteps the "child's own tool is
|
|
4629
|
+
* executing, between its step's completion and the next generation step"
|
|
4630
|
+
* transcript gap that a transcript-based check would need a second,
|
|
4631
|
+
* sustained-window bound to guard against — it is simply not derived from
|
|
4632
|
+
* message timestamps at all.
|
|
4633
|
+
*
|
|
4634
|
+
* Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
|
|
4635
|
+
* status, as the recovery path does per §4a)? Because on the LIVE path the
|
|
4636
|
+
* root session can be shared: a SECOND, unrelated user message can land on the
|
|
4637
|
+
* SAME session (issue #721's own root cause) and keep the root `busy` for a
|
|
4638
|
+
* reason that has nothing to do with THIS message's delegation. A `task`
|
|
4639
|
+
* descendant session is spawned for exactly one delegated turn and never
|
|
4640
|
+
* reused, so its OWN status-map entry is unambiguous evidence about that one
|
|
4641
|
+
* delegation — which the root's status is not.
|
|
4642
|
+
*
|
|
4643
|
+
* Why membership is checked via `resolveSessionMembership`, NOT
|
|
4644
|
+
* `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
|
|
4645
|
+
* `GET /session/:id` fetch failure into "not a descendant", which would
|
|
4646
|
+
* silently drop a genuinely-live candidate from consideration on the one
|
|
4647
|
+
* unlucky tick its membership-walk fetch hiccups (#721).
|
|
4648
|
+
* `resolveSessionMembership` keeps that failure mode as a distinct `null`
|
|
4649
|
+
* (indeterminate) so it is folded into THIS method's own `indeterminate` flag
|
|
4650
|
+
* instead.
|
|
4651
|
+
*
|
|
4652
|
+
* Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
|
|
4653
|
+
* - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
|
|
4654
|
+
* - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
|
|
4655
|
+
* confirmed either way (`resolveSessionMembership` never
|
|
4656
|
+
* returned `null`), and every CONFIRMED descendant's status read
|
|
4657
|
+
* succeeded and is not ongoing (includes "no descendant session
|
|
4658
|
+
* exists at all" — e.g. a plain, non-`task` tool call).
|
|
4659
|
+
* - `null` → INDETERMINATE: `listSessions` failed, OR at least one
|
|
4660
|
+
* candidate's MEMBERSHIP could not be confirmed
|
|
4661
|
+
* (`resolveSessionMembership` returned `null` — a fetch failure
|
|
4662
|
+
* or pathological chain partway through the parent walk), OR at
|
|
4663
|
+
* least one CONFIRMED descendant's `isSessionOngoing` read
|
|
4664
|
+
* failed — and no OTHER candidate was already confirmed `true`.
|
|
4665
|
+
* The caller MUST NOT treat `null` the same as `false` here
|
|
4666
|
+
* (unlike the recovery cross-check's contract) — see
|
|
4667
|
+
* `isB2AbandonmentConfirmed`.
|
|
4668
|
+
*/
|
|
4669
|
+
async isAnyDescendantSessionOngoing(rootSessionId) {
|
|
4670
|
+
const sessions = await listSessions(this.port);
|
|
4671
|
+
if (!sessions) {
|
|
4672
|
+
this.log({
|
|
4673
|
+
level: "warn",
|
|
4674
|
+
message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
|
|
4675
|
+
});
|
|
4676
|
+
return null;
|
|
4677
|
+
}
|
|
4678
|
+
let indeterminate = false;
|
|
4679
|
+
for (const candidate of sessions) {
|
|
4680
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
4681
|
+
const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
|
|
4682
|
+
if (membership === null) {
|
|
4683
|
+
indeterminate = true;
|
|
4684
|
+
continue;
|
|
4685
|
+
}
|
|
4686
|
+
if (membership === false) continue;
|
|
4687
|
+
const ongoing = await isSessionOngoing(this.port, candidate.id);
|
|
4688
|
+
if (ongoing === true) return true;
|
|
4689
|
+
if (ongoing === null) indeterminate = true;
|
|
4690
|
+
}
|
|
4691
|
+
return indeterminate ? null : false;
|
|
4692
|
+
}
|
|
3380
4693
|
/**
|
|
3381
4694
|
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3382
4695
|
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
@@ -3446,12 +4759,10 @@ var ChannelDriver = class {
|
|
|
3446
4759
|
}
|
|
3447
4760
|
return inFlight.sort(byOldest)[0];
|
|
3448
4761
|
}
|
|
3449
|
-
// -------------------------------------------------------------------------
|
|
3450
4762
|
// Evident API calls (combinedAuth thread routes)
|
|
3451
|
-
// -------------------------------------------------------------------------
|
|
3452
4763
|
async getPendingConversations() {
|
|
3453
4764
|
const res = await this.fetchImpl(
|
|
3454
|
-
`${this.apiUrl}/
|
|
4765
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
|
|
3455
4766
|
{
|
|
3456
4767
|
headers: { Authorization: this.getAuthHeader() }
|
|
3457
4768
|
}
|
|
@@ -3469,7 +4780,7 @@ var ChannelDriver = class {
|
|
|
3469
4780
|
}
|
|
3470
4781
|
async getPendingMessages(conversationId) {
|
|
3471
4782
|
const res = await this.fetchImpl(
|
|
3472
|
-
`${this.apiUrl}/
|
|
4783
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
3473
4784
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3474
4785
|
);
|
|
3475
4786
|
this.assertAuth(res, "fetching pending messages");
|
|
@@ -3493,7 +4804,7 @@ var ChannelDriver = class {
|
|
|
3493
4804
|
*/
|
|
3494
4805
|
async getProcessingMessages() {
|
|
3495
4806
|
const res = await this.fetchImpl(
|
|
3496
|
-
`${this.apiUrl}/
|
|
4807
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
|
|
3497
4808
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3498
4809
|
);
|
|
3499
4810
|
this.assertAuth(res, "fetching processing messages");
|
|
@@ -3507,6 +4818,32 @@ var ChannelDriver = class {
|
|
|
3507
4818
|
}
|
|
3508
4819
|
return messages;
|
|
3509
4820
|
}
|
|
4821
|
+
/**
|
|
4822
|
+
* The `opencode_session_id` fragment of a status PATCH body — `{}` when this
|
|
4823
|
+
* conversation has ABANDONED that session (#553). The field is optional
|
|
4824
|
+
* server-side and an absent one leaves the persisted binding untouched, so
|
|
4825
|
+
* omitting it is how a routine status write stops resurrecting it.
|
|
4826
|
+
*
|
|
4827
|
+
* ONLY for writes whose sole cost is a lost deep link. The `processing` notice
|
|
4828
|
+
* degrades to no "View in Evident" link (the reaction swap still fires) and the
|
|
4829
|
+
* turn-failure notice is built from the PATCH's own `error` text with a link off
|
|
4830
|
+
* the persisted row — neither loses content the user came for. `markDone`
|
|
4831
|
+
* deliberately does NOT use this helper: the server fetches the reply text
|
|
4832
|
+
* THROUGH the session id it is given, so suppressing there would replace the
|
|
4833
|
+
* agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
|
|
4834
|
+
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
4835
|
+
* stick.
|
|
4836
|
+
*/
|
|
4837
|
+
sessionIdBody(sessionId, conversationId, messageId, status) {
|
|
4838
|
+
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
4839
|
+
this.log({
|
|
4840
|
+
level: "debug",
|
|
4841
|
+
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
|
|
4842
|
+
conversation_id: conversationId,
|
|
4843
|
+
message_id: messageId
|
|
4844
|
+
});
|
|
4845
|
+
return {};
|
|
4846
|
+
}
|
|
3510
4847
|
/**
|
|
3511
4848
|
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
3512
4849
|
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
@@ -3528,16 +4865,17 @@ var ChannelDriver = class {
|
|
|
3528
4865
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3529
4866
|
* retry vehicle for the swap-to-running.
|
|
3530
4867
|
*/
|
|
3531
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4868
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3532
4869
|
const res = await this.fetchImpl(
|
|
3533
|
-
`${this.apiUrl}/
|
|
4870
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3534
4871
|
{
|
|
3535
4872
|
method: "PATCH",
|
|
3536
4873
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3537
4874
|
body: JSON.stringify({
|
|
3538
4875
|
status: "processing",
|
|
3539
|
-
|
|
3540
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4876
|
+
...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
|
|
4877
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4878
|
+
...title ? { title } : {}
|
|
3541
4879
|
})
|
|
3542
4880
|
}
|
|
3543
4881
|
);
|
|
@@ -3576,16 +4914,23 @@ var ChannelDriver = class {
|
|
|
3576
4914
|
* watcher retries next tick within the
|
|
3577
4915
|
* deadline, Finding 4).
|
|
3578
4916
|
*/
|
|
3579
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4917
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3580
4918
|
const res = await this.fetchImpl(
|
|
3581
|
-
`${this.apiUrl}/
|
|
4919
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3582
4920
|
{
|
|
3583
4921
|
method: "PATCH",
|
|
3584
4922
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3585
4923
|
body: JSON.stringify({
|
|
3586
4924
|
status: "done",
|
|
4925
|
+
// ALWAYS sent, even for a session this conversation has abandoned
|
|
4926
|
+
// (#553): the server reads the reply text back out of THIS session id
|
|
4927
|
+
// to deliver it. Omitting it would leave the user with "✅ Done!"
|
|
4928
|
+
// instead of the answer — a worse regression than the resurrection it
|
|
4929
|
+
// would prevent, which `ensureSession`'s guard handles anyway.
|
|
3587
4930
|
opencode_session_id: sessionId,
|
|
3588
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4931
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4932
|
+
...title ? { title } : {},
|
|
4933
|
+
...usage ? usage : {}
|
|
3589
4934
|
})
|
|
3590
4935
|
}
|
|
3591
4936
|
);
|
|
@@ -3598,19 +4943,29 @@ var ChannelDriver = class {
|
|
|
3598
4943
|
}
|
|
3599
4944
|
/**
|
|
3600
4945
|
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3601
|
-
* when provided (issue #182)
|
|
3602
|
-
* `
|
|
3603
|
-
*
|
|
3604
|
-
*
|
|
4946
|
+
* when provided (issue #182). Three states for `sessionId`:
|
|
4947
|
+
* - omitted (`undefined`) → don't send the field, leave the persisted
|
|
4948
|
+
* session untouched (unused today; kept for API symmetry).
|
|
4949
|
+
* - a real id (`string`) → send it, update the persisted session (the
|
|
4950
|
+
* turn-failure call sites: an errored OpenCode turn).
|
|
4951
|
+
* - explicit `null` → send it, CLEAR the persisted session (issue
|
|
4952
|
+
* #485's dispatch-handoff-failure call site: the session id still
|
|
4953
|
+
* exists but is wedged, so the next attempt must get a fresh one
|
|
4954
|
+
* instead of reusing it — see WI-1's server-side null-clearing PATCH).
|
|
3605
4955
|
*/
|
|
3606
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
4956
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
3607
4957
|
const body = { status: "failed" };
|
|
3608
|
-
if (sessionId
|
|
4958
|
+
if (sessionId === null) {
|
|
4959
|
+
body.opencode_session_id = null;
|
|
4960
|
+
} else if (sessionId !== void 0) {
|
|
4961
|
+
Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
|
|
4962
|
+
}
|
|
3609
4963
|
if (error2 !== void 0) body.error = error2;
|
|
4964
|
+
if (usage) Object.assign(body, usage);
|
|
3610
4965
|
await this.callWithRetry(
|
|
3611
4966
|
"marking message as failed",
|
|
3612
4967
|
() => this.fetchImpl(
|
|
3613
|
-
`${this.apiUrl}/
|
|
4968
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3614
4969
|
{
|
|
3615
4970
|
method: "PATCH",
|
|
3616
4971
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3637,7 +4992,7 @@ var ChannelDriver = class {
|
|
|
3637
4992
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3638
4993
|
try {
|
|
3639
4994
|
const res = await this.fetchImpl(
|
|
3640
|
-
`${this.apiUrl}/
|
|
4995
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
3641
4996
|
{
|
|
3642
4997
|
method: "POST",
|
|
3643
4998
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3646,7 +5001,7 @@ var ChannelDriver = class {
|
|
|
3646
5001
|
);
|
|
3647
5002
|
if (!res.ok) {
|
|
3648
5003
|
this.log({
|
|
3649
|
-
level: "
|
|
5004
|
+
level: "warn",
|
|
3650
5005
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3651
5006
|
conversation_id: conversationId,
|
|
3652
5007
|
message_id: messageId
|
|
@@ -3656,7 +5011,7 @@ var ChannelDriver = class {
|
|
|
3656
5011
|
return true;
|
|
3657
5012
|
} catch (err) {
|
|
3658
5013
|
this.log({
|
|
3659
|
-
level: "
|
|
5014
|
+
level: "warn",
|
|
3660
5015
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3661
5016
|
conversation_id: conversationId,
|
|
3662
5017
|
message_id: messageId
|
|
@@ -3666,7 +5021,7 @@ var ChannelDriver = class {
|
|
|
3666
5021
|
}
|
|
3667
5022
|
async persistSession(conversationId, sessionId) {
|
|
3668
5023
|
const res = await this.fetchImpl(
|
|
3669
|
-
`${this.apiUrl}/
|
|
5024
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
|
|
3670
5025
|
{
|
|
3671
5026
|
method: "PATCH",
|
|
3672
5027
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3692,7 +5047,7 @@ var ChannelDriver = class {
|
|
|
3692
5047
|
await this.callWithRetry(
|
|
3693
5048
|
"reporting interactive event",
|
|
3694
5049
|
() => this.fetchImpl(
|
|
3695
|
-
`${this.apiUrl}/
|
|
5050
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
3696
5051
|
{
|
|
3697
5052
|
method: "POST",
|
|
3698
5053
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3718,9 +5073,7 @@ var ChannelDriver = class {
|
|
|
3718
5073
|
return false;
|
|
3719
5074
|
}
|
|
3720
5075
|
}
|
|
3721
|
-
// -------------------------------------------------------------------------
|
|
3722
5076
|
// Retry wrapper
|
|
3723
|
-
// -------------------------------------------------------------------------
|
|
3724
5077
|
/**
|
|
3725
5078
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3726
5079
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3919,7 +5272,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3919
5272
|
if (!response.ok) {
|
|
3920
5273
|
const serverMessage = await readErrorMessage(response);
|
|
3921
5274
|
return {
|
|
3922
|
-
error: `Failed to resolve
|
|
5275
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3923
5276
|
};
|
|
3924
5277
|
}
|
|
3925
5278
|
const data = await response.json();
|
|
@@ -3927,19 +5280,21 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3927
5280
|
return { agent_id: data.agent_id };
|
|
3928
5281
|
}
|
|
3929
5282
|
return {
|
|
3930
|
-
error: "Cannot resolve
|
|
5283
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3931
5284
|
};
|
|
3932
5285
|
} catch (error2) {
|
|
3933
5286
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3934
|
-
return { error: `Failed to resolve
|
|
5287
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3935
5288
|
}
|
|
3936
5289
|
}
|
|
5290
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
3937
5291
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3938
5292
|
const apiUrl = getApiUrlConfig();
|
|
3939
5293
|
try {
|
|
3940
|
-
const response = await fetch(`${apiUrl}/
|
|
5294
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
3941
5295
|
method: "POST",
|
|
3942
|
-
headers: { Authorization: authHeader }
|
|
5296
|
+
headers: { Authorization: authHeader },
|
|
5297
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
3943
5298
|
});
|
|
3944
5299
|
if (!response.ok) {
|
|
3945
5300
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3950,13 +5305,41 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
3950
5305
|
}
|
|
3951
5306
|
return { ok: true };
|
|
3952
5307
|
} catch (error2) {
|
|
3953
|
-
return { ok: false, error:
|
|
5308
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5309
|
+
}
|
|
5310
|
+
}
|
|
5311
|
+
function describeBestEffortError(error2) {
|
|
5312
|
+
const name = error2?.name;
|
|
5313
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
5314
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5315
|
+
}
|
|
5316
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
5317
|
+
}
|
|
5318
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5319
|
+
try {
|
|
5320
|
+
const apiUrl = getApiUrlConfig();
|
|
5321
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5322
|
+
method: "POST",
|
|
5323
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5324
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5325
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5326
|
+
});
|
|
5327
|
+
if (!response.ok) {
|
|
5328
|
+
const serverMessage = await readErrorMessage(response);
|
|
5329
|
+
return {
|
|
5330
|
+
ok: false,
|
|
5331
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5332
|
+
};
|
|
5333
|
+
}
|
|
5334
|
+
return { ok: true };
|
|
5335
|
+
} catch (error2) {
|
|
5336
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
3954
5337
|
}
|
|
3955
5338
|
}
|
|
3956
5339
|
async function getAgentInfo(agentId, authHeader) {
|
|
3957
5340
|
const apiUrl = getApiUrlConfig();
|
|
3958
5341
|
try {
|
|
3959
|
-
const response = await fetch(`${apiUrl}/
|
|
5342
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
3960
5343
|
headers: { Authorization: authHeader }
|
|
3961
5344
|
});
|
|
3962
5345
|
if (response.status === 401) {
|
|
@@ -3967,12 +5350,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3967
5350
|
const serverMessage = await readErrorMessage(response);
|
|
3968
5351
|
return {
|
|
3969
5352
|
valid: false,
|
|
3970
|
-
error: serverMessage ?? "You do not have access to this
|
|
5353
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3971
5354
|
};
|
|
3972
5355
|
}
|
|
3973
5356
|
if (response.status === 404) {
|
|
3974
5357
|
const serverMessage = await readErrorMessage(response);
|
|
3975
|
-
return { valid: false, error: serverMessage ?? `
|
|
5358
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3976
5359
|
}
|
|
3977
5360
|
if (!response.ok) {
|
|
3978
5361
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3985,13 +5368,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3985
5368
|
if (agent.agent_type !== "local") {
|
|
3986
5369
|
return {
|
|
3987
5370
|
valid: false,
|
|
3988
|
-
error: `
|
|
5371
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3989
5372
|
};
|
|
3990
5373
|
}
|
|
3991
5374
|
return { valid: true, agent };
|
|
3992
5375
|
} catch (error2) {
|
|
3993
5376
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3994
|
-
return { valid: false, error: `Failed to validate
|
|
5377
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3995
5378
|
}
|
|
3996
5379
|
}
|
|
3997
5380
|
|
|
@@ -4000,23 +5383,82 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4000
5383
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4001
5384
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4002
5385
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4003
|
-
|
|
5386
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
5387
|
+
function resolveLogLevel(options) {
|
|
5388
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
5389
|
+
const validate = (value, source) => {
|
|
5390
|
+
const normalized = value.trim().toLowerCase();
|
|
5391
|
+
if (!accepted.includes(normalized)) {
|
|
5392
|
+
throw new Error(
|
|
5393
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
5394
|
+
);
|
|
5395
|
+
}
|
|
5396
|
+
return normalized;
|
|
5397
|
+
};
|
|
5398
|
+
if (options.logLevel !== void 0) {
|
|
5399
|
+
return validate(options.logLevel, " (--log-level)");
|
|
5400
|
+
}
|
|
5401
|
+
if (options.verbose) {
|
|
5402
|
+
return "debug";
|
|
5403
|
+
}
|
|
5404
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
5405
|
+
if (env !== void 0 && env !== "") {
|
|
5406
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
5407
|
+
}
|
|
5408
|
+
return "info";
|
|
5409
|
+
}
|
|
5410
|
+
function resolveFileSyncDirectories(raw, homeDir) {
|
|
5411
|
+
const directories = [];
|
|
5412
|
+
for (const entry of raw ?? []) {
|
|
5413
|
+
const trimmed = entry.trim();
|
|
5414
|
+
if (trimmed === "") {
|
|
5415
|
+
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5416
|
+
}
|
|
5417
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
|
|
5418
|
+
if (!isAbsolute2(expanded)) {
|
|
5419
|
+
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5420
|
+
}
|
|
5421
|
+
const normalized = resolvePath(expanded);
|
|
5422
|
+
if (parse(normalized).root === normalized) {
|
|
5423
|
+
throw new Error(
|
|
5424
|
+
`--enable-file-sync-to will not allow-list the filesystem root ("${entry}"); name the specific directory the credentials belong in (for example ~/.claude)`
|
|
5425
|
+
);
|
|
5426
|
+
}
|
|
5427
|
+
if (!directories.includes(normalized)) {
|
|
5428
|
+
directories.push(normalized);
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
if (directories.length > MAX_FILE_SYNC_DIRECTORIES) {
|
|
5432
|
+
throw new Error(
|
|
5433
|
+
`--enable-file-sync-to accepts at most ${MAX_FILE_SYNC_DIRECTORIES} directories; got ${directories.length}`
|
|
5434
|
+
);
|
|
5435
|
+
}
|
|
5436
|
+
return directories;
|
|
5437
|
+
}
|
|
5438
|
+
function meetsThreshold(state, level) {
|
|
5439
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
5440
|
+
}
|
|
5441
|
+
function log2(state, message, level = "info") {
|
|
5442
|
+
if (!meetsThreshold(state, level)) return;
|
|
4004
5443
|
if (state.json) {
|
|
4005
5444
|
console.log(
|
|
4006
5445
|
JSON.stringify({
|
|
4007
5446
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4008
|
-
level
|
|
5447
|
+
level,
|
|
4009
5448
|
message
|
|
4010
5449
|
})
|
|
4011
5450
|
);
|
|
4012
5451
|
} else if (!state.interactive) {
|
|
4013
|
-
const prefix =
|
|
5452
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
4014
5453
|
console.log(`${prefix} ${message}`);
|
|
4015
5454
|
}
|
|
4016
5455
|
}
|
|
4017
5456
|
function logActivity(state, entry) {
|
|
5457
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
5458
|
+
if (!meetsThreshold(state, level)) return;
|
|
4018
5459
|
const fullEntry = {
|
|
4019
5460
|
...entry,
|
|
5461
|
+
level,
|
|
4020
5462
|
timestamp: /* @__PURE__ */ new Date()
|
|
4021
5463
|
};
|
|
4022
5464
|
state.activityLog.push(fullEntry);
|
|
@@ -4025,9 +5467,9 @@ function logActivity(state, entry) {
|
|
|
4025
5467
|
}
|
|
4026
5468
|
if (!state.interactive) {
|
|
4027
5469
|
if (entry.type === "error") {
|
|
4028
|
-
log2(state, entry.error ?? "Unknown error",
|
|
4029
|
-
} else if (entry.
|
|
4030
|
-
log2(state, entry.message);
|
|
5470
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
5471
|
+
} else if (entry.message) {
|
|
5472
|
+
log2(state, entry.message, level);
|
|
4031
5473
|
}
|
|
4032
5474
|
}
|
|
4033
5475
|
}
|
|
@@ -4114,18 +5556,29 @@ async function handleAuthError(state, error2) {
|
|
|
4114
5556
|
async function driveChannels(state, driver) {
|
|
4115
5557
|
let idlePolls = 0;
|
|
4116
5558
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5559
|
+
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
4117
5560
|
while (state.running) {
|
|
4118
5561
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
4119
5562
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
4120
5563
|
if (state.interactive) displayStatus(state);
|
|
4121
5564
|
await state.connection.reconnectPromise;
|
|
4122
5565
|
}
|
|
5566
|
+
const carriedOverFileSync = driver.fileSyncActivity().inFlight;
|
|
5567
|
+
void driver.syncPendingFiles().catch(
|
|
5568
|
+
(error2) => logActivity(state, {
|
|
5569
|
+
type: "error",
|
|
5570
|
+
error: `Runner file sync failed: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
5571
|
+
})
|
|
5572
|
+
);
|
|
4123
5573
|
try {
|
|
4124
5574
|
const processed = await driver.drainPending();
|
|
4125
5575
|
state.messageCount += processed;
|
|
4126
5576
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
4127
5577
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
4128
|
-
|
|
5578
|
+
const appliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5579
|
+
const fileActivity = carriedOverFileSync || appliedFiles !== lastSeenAppliedFiles;
|
|
5580
|
+
lastSeenAppliedFiles = appliedFiles;
|
|
5581
|
+
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
|
|
4129
5582
|
idlePolls = 0;
|
|
4130
5583
|
if (processed > 0 && state.interactive) displayStatus(state);
|
|
4131
5584
|
} else if (state.idleTimeout !== null) {
|
|
@@ -4154,7 +5607,7 @@ async function driveChannels(state, driver) {
|
|
|
4154
5607
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
4155
5608
|
if (state.interactive) displayStatus(state);
|
|
4156
5609
|
}
|
|
4157
|
-
await new Promise((
|
|
5610
|
+
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
4158
5611
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
4159
5612
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
4160
5613
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -4226,7 +5679,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4226
5679
|
process.env
|
|
4227
5680
|
);
|
|
4228
5681
|
for (const warning2 of config2.warnings) {
|
|
4229
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
5682
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4230
5683
|
}
|
|
4231
5684
|
if (!config2.enabled) return;
|
|
4232
5685
|
logActivity(state, {
|
|
@@ -4248,7 +5701,7 @@ async function notifyOffline(state) {
|
|
|
4248
5701
|
}
|
|
4249
5702
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4250
5703
|
if (result.ok) {
|
|
4251
|
-
log2(state, "Notified Evident the
|
|
5704
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
4252
5705
|
} else {
|
|
4253
5706
|
logActivity(state, {
|
|
4254
5707
|
type: "error",
|
|
@@ -4257,7 +5710,18 @@ async function notifyOffline(state) {
|
|
|
4257
5710
|
if (state.interactive) displayStatus(state);
|
|
4258
5711
|
}
|
|
4259
5712
|
}
|
|
5713
|
+
async function timeShutdownPhase(state, durations, name, run2) {
|
|
5714
|
+
const startedAt = Date.now();
|
|
5715
|
+
try {
|
|
5716
|
+
return await run2();
|
|
5717
|
+
} finally {
|
|
5718
|
+
const elapsedMs = Date.now() - startedAt;
|
|
5719
|
+
durations[name] = elapsedMs;
|
|
5720
|
+
log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
4260
5723
|
async function cleanup(state, opts = {}) {
|
|
5724
|
+
const durations = {};
|
|
4261
5725
|
state.running = false;
|
|
4262
5726
|
for (const timer of state.sessionCleanupTimers) {
|
|
4263
5727
|
clearInterval(timer);
|
|
@@ -4271,7 +5735,13 @@ async function cleanup(state, opts = {}) {
|
|
|
4271
5735
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
4272
5736
|
displayStatus(state);
|
|
4273
5737
|
}
|
|
4274
|
-
const
|
|
5738
|
+
const driver = state.channelDriver;
|
|
5739
|
+
const settled = await timeShutdownPhase(
|
|
5740
|
+
state,
|
|
5741
|
+
durations,
|
|
5742
|
+
"drain",
|
|
5743
|
+
() => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
5744
|
+
);
|
|
4275
5745
|
if (!settled) {
|
|
4276
5746
|
logActivity(state, {
|
|
4277
5747
|
type: "info",
|
|
@@ -4280,13 +5750,15 @@ async function cleanup(state, opts = {}) {
|
|
|
4280
5750
|
if (state.interactive) displayStatus(state);
|
|
4281
5751
|
}
|
|
4282
5752
|
}
|
|
4283
|
-
await notifyOffline(state);
|
|
5753
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
4284
5754
|
if (state.connection) {
|
|
4285
|
-
state.connection
|
|
5755
|
+
const connection = state.connection;
|
|
5756
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
4286
5757
|
state.connection = null;
|
|
4287
5758
|
}
|
|
4288
5759
|
if (state.opencodeProcess) {
|
|
4289
|
-
|
|
5760
|
+
const opencodeProcess = state.opencodeProcess;
|
|
5761
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
4290
5762
|
if (state.interactive) {
|
|
4291
5763
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
4292
5764
|
displayStatus(state);
|
|
@@ -4295,17 +5767,35 @@ async function cleanup(state, opts = {}) {
|
|
|
4295
5767
|
}
|
|
4296
5768
|
state.opencodeProcess = null;
|
|
4297
5769
|
}
|
|
5770
|
+
return durations;
|
|
4298
5771
|
}
|
|
4299
5772
|
async function run(options) {
|
|
4300
5773
|
const interactive = isInteractive(options.json);
|
|
5774
|
+
let logLevel;
|
|
5775
|
+
let fileSyncDirectories;
|
|
5776
|
+
try {
|
|
5777
|
+
logLevel = resolveLogLevel(options);
|
|
5778
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
|
|
5779
|
+
} catch (error2) {
|
|
5780
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
5781
|
+
if (options.json) {
|
|
5782
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
5783
|
+
} else {
|
|
5784
|
+
printError(message);
|
|
5785
|
+
}
|
|
5786
|
+
await shutdownTelemetry();
|
|
5787
|
+
process.exit(1);
|
|
5788
|
+
return;
|
|
5789
|
+
}
|
|
4301
5790
|
const state = {
|
|
4302
|
-
agentId: options.agent || "",
|
|
5791
|
+
agentId: options.runner || options.agent || "",
|
|
4303
5792
|
agentName: null,
|
|
4304
5793
|
port: options.port ?? 4096,
|
|
4305
5794
|
conversationFilter: options.conversation ?? null,
|
|
4306
5795
|
idleTimeout: options.idleTimeout ?? null,
|
|
4307
5796
|
json: options.json ?? false,
|
|
4308
5797
|
interactive,
|
|
5798
|
+
logLevel,
|
|
4309
5799
|
connected: false,
|
|
4310
5800
|
opencodeConnected: false,
|
|
4311
5801
|
opencodeVersion: null,
|
|
@@ -4320,24 +5810,66 @@ async function run(options) {
|
|
|
4320
5810
|
sessionCleanupTimers: [],
|
|
4321
5811
|
authHeader: ""
|
|
4322
5812
|
};
|
|
5813
|
+
if (fileSyncDirectories.length > 0) {
|
|
5814
|
+
log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
|
|
5815
|
+
} else {
|
|
5816
|
+
log2(state, "File sync is disabled (no --enable-file-sync-to given)", "debug");
|
|
5817
|
+
}
|
|
5818
|
+
if (!options.runner && options.agent) {
|
|
5819
|
+
telemetry.info(
|
|
5820
|
+
EventTypes.DEPRECATED_AGENT_FLAG_USED,
|
|
5821
|
+
"Deprecated --agent flag used instead of --runner",
|
|
5822
|
+
{ command: "run" },
|
|
5823
|
+
state.agentId
|
|
5824
|
+
);
|
|
5825
|
+
const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
|
|
5826
|
+
log2(state, agentFlagNotice, "warn");
|
|
5827
|
+
if (state.interactive && !state.json) {
|
|
5828
|
+
logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
4323
5831
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4324
5832
|
log2(
|
|
4325
5833
|
state,
|
|
4326
|
-
"
|
|
4327
|
-
|
|
5834
|
+
"No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
|
|
5835
|
+
"warn"
|
|
4328
5836
|
);
|
|
4329
5837
|
}
|
|
4330
5838
|
const handleSignal = async () => {
|
|
4331
5839
|
if (state.shuttingDown) return;
|
|
4332
5840
|
state.shuttingDown = true;
|
|
5841
|
+
const shutdownStartedAt = Date.now();
|
|
4333
5842
|
if (state.interactive) {
|
|
4334
5843
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
4335
5844
|
displayStatus(state);
|
|
4336
5845
|
} else {
|
|
4337
5846
|
log2(state, "Shutting down...");
|
|
4338
5847
|
}
|
|
4339
|
-
await cleanup(state, { graceful: true });
|
|
4340
|
-
|
|
5848
|
+
const durations = await cleanup(state, { graceful: true });
|
|
5849
|
+
const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
|
|
5850
|
+
await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
|
|
5851
|
+
let timer;
|
|
5852
|
+
const flushed = shutdownTelemetry().then(
|
|
5853
|
+
() => true,
|
|
5854
|
+
(error2) => {
|
|
5855
|
+
log2(
|
|
5856
|
+
state,
|
|
5857
|
+
`Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
5858
|
+
"warn"
|
|
5859
|
+
);
|
|
5860
|
+
return true;
|
|
5861
|
+
}
|
|
5862
|
+
);
|
|
5863
|
+
const timedOut = new Promise((resolve3) => {
|
|
5864
|
+
timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
|
|
5865
|
+
});
|
|
5866
|
+
if (!await Promise.race([flushed, timedOut])) {
|
|
5867
|
+
log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
|
|
5868
|
+
}
|
|
5869
|
+
clearTimeout(timer);
|
|
5870
|
+
});
|
|
5871
|
+
const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
|
|
5872
|
+
log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
|
|
4341
5873
|
process.exit(0);
|
|
4342
5874
|
};
|
|
4343
5875
|
process.on("SIGINT", handleSignal);
|
|
@@ -4348,7 +5880,9 @@ async function run(options) {
|
|
|
4348
5880
|
if (!interactive) {
|
|
4349
5881
|
printError("Authentication required");
|
|
4350
5882
|
blank();
|
|
4351
|
-
console.log(
|
|
5883
|
+
console.log(
|
|
5884
|
+
chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
|
|
5885
|
+
);
|
|
4352
5886
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
4353
5887
|
blank();
|
|
4354
5888
|
process.exit(1);
|
|
@@ -4362,26 +5896,51 @@ async function run(options) {
|
|
|
4362
5896
|
);
|
|
4363
5897
|
}
|
|
4364
5898
|
state.authHeader = getAuthHeader(credentials2);
|
|
5899
|
+
if (credentials2.notice) {
|
|
5900
|
+
log2(state, credentials2.notice, "warn");
|
|
5901
|
+
if (state.interactive && !state.json) {
|
|
5902
|
+
logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
if (credentials2.keySource === "agent_key") {
|
|
5906
|
+
telemetry.info(
|
|
5907
|
+
EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
|
|
5908
|
+
"Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
|
|
5909
|
+
{ command: "run" },
|
|
5910
|
+
state.agentId
|
|
5911
|
+
);
|
|
5912
|
+
const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
|
|
5913
|
+
log2(state, agentKeyNotice, "warn");
|
|
5914
|
+
if (state.interactive && !state.json) {
|
|
5915
|
+
logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
|
|
5916
|
+
}
|
|
5917
|
+
}
|
|
4365
5918
|
if (!state.agentId) {
|
|
4366
5919
|
if (credentials2.authType === "agent_key") {
|
|
4367
5920
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
4368
5921
|
if (resolved.agent_id) {
|
|
4369
5922
|
state.agentId = resolved.agent_id;
|
|
4370
|
-
log2(state, `Resolved
|
|
5923
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
4371
5924
|
if (state.interactive && !state.json) {
|
|
4372
5925
|
logActivity(state, {
|
|
4373
5926
|
type: "info",
|
|
4374
|
-
message: `
|
|
5927
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
4375
5928
|
});
|
|
4376
5929
|
}
|
|
4377
5930
|
} else {
|
|
4378
|
-
printError(resolved.error || "Failed to resolve
|
|
5931
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
4379
5932
|
process.exit(1);
|
|
4380
5933
|
}
|
|
4381
5934
|
} else {
|
|
4382
|
-
printError(
|
|
5935
|
+
printError(
|
|
5936
|
+
"--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
|
|
5937
|
+
);
|
|
4383
5938
|
blank();
|
|
4384
|
-
console.log(
|
|
5939
|
+
console.log(
|
|
5940
|
+
chalk6.dim(
|
|
5941
|
+
"Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
|
|
5942
|
+
)
|
|
5943
|
+
);
|
|
4385
5944
|
blank();
|
|
4386
5945
|
process.exit(1);
|
|
4387
5946
|
}
|
|
@@ -4403,7 +5962,7 @@ async function run(options) {
|
|
|
4403
5962
|
console.log(chalk6.bold("Evident Run"));
|
|
4404
5963
|
console.log(chalk6.dim("-".repeat(40)));
|
|
4405
5964
|
}
|
|
4406
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
5965
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
4407
5966
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4408
5967
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
4409
5968
|
spinner?.fail("Authentication failed");
|
|
@@ -4415,15 +5974,30 @@ async function run(options) {
|
|
|
4415
5974
|
"Login successful! Retrying..."
|
|
4416
5975
|
);
|
|
4417
5976
|
state.authHeader = getAuthHeader(credentials2);
|
|
4418
|
-
spinner?.start("Validating
|
|
5977
|
+
spinner?.start("Validating runner...");
|
|
4419
5978
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4420
5979
|
}
|
|
4421
5980
|
if (!validation.valid) {
|
|
4422
|
-
spinner?.fail(`
|
|
5981
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
4423
5982
|
throw new Error(validation.error);
|
|
4424
5983
|
}
|
|
4425
|
-
spinner?.succeed(`
|
|
5984
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
4426
5985
|
state.agentName = validation.agent.name;
|
|
5986
|
+
const microvmId = process.env.MICROVM_ID?.trim();
|
|
5987
|
+
if (microvmId) {
|
|
5988
|
+
const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
|
|
5989
|
+
if (reported.ok) {
|
|
5990
|
+
log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
|
|
5991
|
+
} else {
|
|
5992
|
+
const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
|
|
5993
|
+
log2(state, message, "warn");
|
|
5994
|
+
if (state.interactive && !state.json) {
|
|
5995
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
5996
|
+
}
|
|
5997
|
+
}
|
|
5998
|
+
} else {
|
|
5999
|
+
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
6000
|
+
}
|
|
4427
6001
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
4428
6002
|
try {
|
|
4429
6003
|
const oc = await ensureOpenCodeRunning({
|
|
@@ -4440,9 +6014,24 @@ async function run(options) {
|
|
|
4440
6014
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4441
6015
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4442
6016
|
if (versionWarning) {
|
|
4443
|
-
log2(state, versionWarning,
|
|
6017
|
+
log2(state, versionWarning, "warn");
|
|
4444
6018
|
if (state.interactive && !state.json) {
|
|
4445
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
6019
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
|
|
6023
|
+
if (noProviderWarning) {
|
|
6024
|
+
log2(state, noProviderWarning, "warn");
|
|
6025
|
+
if (state.interactive && !state.json) {
|
|
6026
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
6027
|
+
blank();
|
|
6028
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
6029
|
+
console.log(
|
|
6030
|
+
chalk6.dim(
|
|
6031
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
6032
|
+
)
|
|
6033
|
+
);
|
|
6034
|
+
blank();
|
|
4446
6035
|
}
|
|
4447
6036
|
}
|
|
4448
6037
|
} catch (error2) {
|
|
@@ -4457,11 +6046,21 @@ async function run(options) {
|
|
|
4457
6046
|
getAuthHeader: () => state.authHeader,
|
|
4458
6047
|
conversationFilter: state.conversationFilter,
|
|
4459
6048
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
6049
|
+
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
6050
|
+
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
6051
|
+
fileSyncDirectories,
|
|
6052
|
+
homeDir: homedir2(),
|
|
6053
|
+
log: (entry) => (
|
|
6054
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
6055
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
6056
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
6057
|
+
logActivity(state, {
|
|
6058
|
+
type: entry.level === "error" ? "error" : "info",
|
|
6059
|
+
level: entry.level,
|
|
6060
|
+
message: entry.message,
|
|
6061
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
6062
|
+
})
|
|
6063
|
+
)
|
|
4465
6064
|
});
|
|
4466
6065
|
state.channelDriver = channelDriver;
|
|
4467
6066
|
const connection = new RunnerConnection({
|
|
@@ -4475,7 +6074,7 @@ async function run(options) {
|
|
|
4475
6074
|
state.agentId = agentId;
|
|
4476
6075
|
logActivity(state, {
|
|
4477
6076
|
type: "info",
|
|
4478
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
6077
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
4479
6078
|
});
|
|
4480
6079
|
emitAgentConnected(state.agentId, {
|
|
4481
6080
|
port: state.port,
|
|
@@ -4532,6 +6131,12 @@ async function run(options) {
|
|
|
4532
6131
|
onDrainPing: () => {
|
|
4533
6132
|
if (!state.running) return;
|
|
4534
6133
|
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
6134
|
+
void channelDriver.syncPendingFiles().catch(
|
|
6135
|
+
(error2) => logActivity(state, {
|
|
6136
|
+
type: "error",
|
|
6137
|
+
error: `Runner file sync failed on ping: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
6138
|
+
})
|
|
6139
|
+
);
|
|
4535
6140
|
channelDriver.drainPending().then((processed) => {
|
|
4536
6141
|
if (processed > 0) {
|
|
4537
6142
|
state.messageCount += processed;
|
|
@@ -4590,7 +6195,7 @@ async function run(options) {
|
|
|
4590
6195
|
}
|
|
4591
6196
|
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
4592
6197
|
command: "run",
|
|
4593
|
-
agentId: options.agent
|
|
6198
|
+
agentId: options.runner || options.agent
|
|
4594
6199
|
});
|
|
4595
6200
|
await shutdownTelemetry();
|
|
4596
6201
|
process.exit(1);
|
|
@@ -4615,7 +6220,13 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4615
6220
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
4616
6221
|
program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
|
|
4617
6222
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4618
|
-
program.command("run").description("Connect to Evident and process messages").option("
|
|
6223
|
+
program.command("run").description("Connect to Evident and process messages").option("--runner [id]", "Runner ID to connect to (optional when EVIDENT_RUNNER_KEY is set)").option(
|
|
6224
|
+
"-a, --agent [id]",
|
|
6225
|
+
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
6226
|
+
).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
6227
|
+
"--log-level <level>",
|
|
6228
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
6229
|
+
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
|
|
4619
6230
|
"--session-cleanup-max-age <duration>",
|
|
4620
6231
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4621
6232
|
).option(
|
|
@@ -4624,11 +6235,20 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4624
6235
|
).option(
|
|
4625
6236
|
"--session-cleanup-interval <duration>",
|
|
4626
6237
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
6238
|
+
).option(
|
|
6239
|
+
"--enable-file-sync-to <dir>",
|
|
6240
|
+
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
6241
|
+
(value, previous) => previous.concat([value]),
|
|
6242
|
+
[]
|
|
4627
6243
|
).action(
|
|
4628
6244
|
(options) => {
|
|
4629
6245
|
run({
|
|
4630
6246
|
agent: options.agent,
|
|
6247
|
+
runner: options.runner,
|
|
4631
6248
|
port: parseInt(options.port, 10),
|
|
6249
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
6250
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
6251
|
+
logLevel: options.logLevel,
|
|
4632
6252
|
verbose: options.verbose,
|
|
4633
6253
|
conversation: options.conversation,
|
|
4634
6254
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
@@ -4636,7 +6256,10 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4636
6256
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4637
6257
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4638
6258
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4639
|
-
sessionCleanupInterval: options.sessionCleanupInterval
|
|
6259
|
+
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
6260
|
+
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
6261
|
+
// resolveFileSyncDirectories.
|
|
6262
|
+
enableFileSyncTo: options.enableFileSyncTo
|
|
4640
6263
|
});
|
|
4641
6264
|
}
|
|
4642
6265
|
);
|