@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.0d1732f
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 +1640 -198
- 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);
|
|
@@ -1306,6 +1551,37 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1306
1551
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1307
1552
|
);
|
|
1308
1553
|
}
|
|
1554
|
+
async function hasAnyConfiguredProvider(port) {
|
|
1555
|
+
try {
|
|
1556
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1557
|
+
if (!res.ok) {
|
|
1558
|
+
console.error(
|
|
1559
|
+
`[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1560
|
+
);
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
const body = await res.json();
|
|
1564
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1565
|
+
console.error(
|
|
1566
|
+
`[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
|
|
1567
|
+
);
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1570
|
+
const defaults2 = body.default;
|
|
1571
|
+
if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
|
|
1572
|
+
console.error(
|
|
1573
|
+
`[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
|
|
1574
|
+
);
|
|
1575
|
+
return null;
|
|
1576
|
+
}
|
|
1577
|
+
return Object.keys(defaults2).length > 0;
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
console.error(
|
|
1580
|
+
`[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1581
|
+
);
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1309
1585
|
|
|
1310
1586
|
// src/lib/opencode/session-cleanup.ts
|
|
1311
1587
|
var DURATION_UNIT_MS = {
|
|
@@ -1464,10 +1740,11 @@ var StreamForwarder = class {
|
|
|
1464
1740
|
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1465
1741
|
*/
|
|
1466
1742
|
abortAll() {
|
|
1467
|
-
for (const stream of this.inflight.
|
|
1743
|
+
for (const [sid, stream] of this.inflight.entries()) {
|
|
1468
1744
|
try {
|
|
1469
1745
|
stream.abort();
|
|
1470
|
-
} catch {
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
|
|
1471
1748
|
}
|
|
1472
1749
|
}
|
|
1473
1750
|
this.inflight.clear();
|
|
@@ -1501,12 +1778,12 @@ var StreamForwarder = class {
|
|
|
1501
1778
|
let endBody;
|
|
1502
1779
|
if (has_body) {
|
|
1503
1780
|
const chunks = [];
|
|
1504
|
-
bodyPromise = new Promise((
|
|
1781
|
+
bodyPromise = new Promise((resolve3) => {
|
|
1505
1782
|
pushBody = (buf) => {
|
|
1506
1783
|
chunks.push(buf);
|
|
1507
1784
|
};
|
|
1508
1785
|
endBody = () => {
|
|
1509
|
-
|
|
1786
|
+
resolve3(Buffer.concat(chunks));
|
|
1510
1787
|
};
|
|
1511
1788
|
});
|
|
1512
1789
|
}
|
|
@@ -1617,31 +1894,20 @@ function connectTunnel(options) {
|
|
|
1617
1894
|
onConnected,
|
|
1618
1895
|
onDisconnected,
|
|
1619
1896
|
onError,
|
|
1620
|
-
onRequest,
|
|
1621
1897
|
onResponse,
|
|
1622
1898
|
onInfo,
|
|
1623
1899
|
onDrainPing
|
|
1624
1900
|
} = options;
|
|
1625
1901
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1626
1902
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1627
|
-
return new Promise((
|
|
1903
|
+
return new Promise((resolve3, reject) => {
|
|
1628
1904
|
const ws = new WebSocket2(url, {
|
|
1629
1905
|
headers: {
|
|
1630
1906
|
Authorization: authHeader
|
|
1631
1907
|
}
|
|
1632
1908
|
});
|
|
1633
|
-
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1634
1909
|
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
|
-
},
|
|
1910
|
+
onHead: () => onResponse?.(),
|
|
1645
1911
|
onDrainPing: () => onDrainPing?.()
|
|
1646
1912
|
});
|
|
1647
1913
|
const connectionTimeout = setTimeout(() => {
|
|
@@ -1689,7 +1955,7 @@ function connectTunnel(options) {
|
|
|
1689
1955
|
clearTimeout(connectionTimeout);
|
|
1690
1956
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1691
1957
|
onConnected?.(connectedAgentId);
|
|
1692
|
-
|
|
1958
|
+
resolve3({
|
|
1693
1959
|
ws,
|
|
1694
1960
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1695
1961
|
});
|
|
@@ -1717,7 +1983,6 @@ function connectTunnel(options) {
|
|
|
1717
1983
|
ws.on("close", (code, reason) => {
|
|
1718
1984
|
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1719
1985
|
forwarder.abortAll();
|
|
1720
|
-
streamStartTimes.clear();
|
|
1721
1986
|
onDisconnected?.(code, reasonStr);
|
|
1722
1987
|
});
|
|
1723
1988
|
});
|
|
@@ -1752,7 +2017,11 @@ var RunnerConnection = class {
|
|
|
1752
2017
|
if (this.connection) {
|
|
1753
2018
|
try {
|
|
1754
2019
|
this.connection.close();
|
|
1755
|
-
} catch {
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
log("error", "runner_connection_close_failed", {
|
|
2022
|
+
agent_id: this.resolvedAgentId,
|
|
2023
|
+
...errorFields(err)
|
|
2024
|
+
});
|
|
1756
2025
|
}
|
|
1757
2026
|
this.connection = null;
|
|
1758
2027
|
}
|
|
@@ -1804,6 +2073,404 @@ var RunnerConnection = class {
|
|
|
1804
2073
|
}
|
|
1805
2074
|
};
|
|
1806
2075
|
|
|
2076
|
+
// src/lib/channels/driver.ts
|
|
2077
|
+
import { homedir } from "os";
|
|
2078
|
+
|
|
2079
|
+
// src/lib/file-push.ts
|
|
2080
|
+
import { randomUUID } from "crypto";
|
|
2081
|
+
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2082
|
+
import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
|
|
2083
|
+
var FILE_MODE = 384;
|
|
2084
|
+
var DIRECTORY_MODE = 448;
|
|
2085
|
+
async function writePushedFile(request) {
|
|
2086
|
+
const { requestedPath, content, allowedDirectories, homeDir } = request;
|
|
2087
|
+
const bytes = content.byteLength;
|
|
2088
|
+
if (allowedDirectories.length === 0) {
|
|
2089
|
+
return refuse("file_sync_disabled", "File sync is not enabled on this runner.", {
|
|
2090
|
+
path: requestedPath,
|
|
2091
|
+
bytes
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
if (bytes > MAX_FILE_PUSH_BYTES) {
|
|
2095
|
+
return refuse(
|
|
2096
|
+
"file_too_large",
|
|
2097
|
+
`File is ${bytes} bytes; the limit is ${MAX_FILE_PUSH_BYTES}.`,
|
|
2098
|
+
{
|
|
2099
|
+
path: requestedPath,
|
|
2100
|
+
bytes
|
|
2101
|
+
}
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
const candidate = expandAndValidate(requestedPath, homeDir);
|
|
2105
|
+
if (candidate === null) {
|
|
2106
|
+
return refuse("invalid_path", "The requested path is not a valid absolute file path.", {
|
|
2107
|
+
path: requestedPath,
|
|
2108
|
+
bytes
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
try {
|
|
2112
|
+
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2113
|
+
dirname2(candidate)
|
|
2114
|
+
);
|
|
2115
|
+
const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
|
|
2116
|
+
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2117
|
+
if (allowedDirectory === null) {
|
|
2118
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2119
|
+
path: realTarget,
|
|
2120
|
+
bytes
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
if (missingSegments.length > 0) {
|
|
2124
|
+
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2125
|
+
const realParent = await realpath(dirname2(realTarget));
|
|
2126
|
+
if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2127
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2128
|
+
path: realTarget,
|
|
2129
|
+
bytes,
|
|
2130
|
+
reason: "parent_changed_after_create"
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
await writeAtomically(realTarget, content);
|
|
2135
|
+
log("info", "file_push_written", { path: realTarget, bytes });
|
|
2136
|
+
return { ok: true, path: realTarget };
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
const errno = err.code ?? "UNKNOWN";
|
|
2139
|
+
return refuse("write_failed", `The runner could not write the file (${errno}).`, {
|
|
2140
|
+
path: candidate,
|
|
2141
|
+
bytes,
|
|
2142
|
+
errno,
|
|
2143
|
+
...errorFields(err)
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
function expandAndValidate(requestedPath, homeDir) {
|
|
2148
|
+
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2152
|
+
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2153
|
+
return null;
|
|
2154
|
+
}
|
|
2155
|
+
if (!isAbsolute(expanded)) {
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
const candidate = resolve2(expanded);
|
|
2159
|
+
const name = basename(candidate);
|
|
2160
|
+
return name === "" || name === "." || name === ".." ? null : candidate;
|
|
2161
|
+
}
|
|
2162
|
+
async function resolveNearestExistingAncestor(directory) {
|
|
2163
|
+
const missingSegments = [];
|
|
2164
|
+
let current = directory;
|
|
2165
|
+
for (; ; ) {
|
|
2166
|
+
try {
|
|
2167
|
+
return { existingAncestor: await realpath(current), missingSegments };
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
const parent = dirname2(current);
|
|
2170
|
+
if (err.code !== "ENOENT" || parent === current) {
|
|
2171
|
+
throw err;
|
|
2172
|
+
}
|
|
2173
|
+
missingSegments.unshift(basename(current));
|
|
2174
|
+
current = parent;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function findContainingAllowedDirectory(allowedDirectories, realTarget) {
|
|
2179
|
+
for (const directory of allowedDirectories) {
|
|
2180
|
+
if (!isAbsolute(directory)) {
|
|
2181
|
+
log("warn", "file_push_allowed_directory_skipped", { directory, reason: "not_absolute" });
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
const realDirectory = await realpathCreatingIfMissing(directory);
|
|
2185
|
+
if (realDirectory !== null && contains(realDirectory, realTarget)) {
|
|
2186
|
+
return realDirectory;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
async function realpathCreatingIfMissing(directory) {
|
|
2192
|
+
try {
|
|
2193
|
+
return await realpath(directory);
|
|
2194
|
+
} catch (err) {
|
|
2195
|
+
if (err.code !== "ENOENT") {
|
|
2196
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2197
|
+
directory,
|
|
2198
|
+
reason: "unresolvable",
|
|
2199
|
+
...errorFields(err)
|
|
2200
|
+
});
|
|
2201
|
+
return null;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
try {
|
|
2205
|
+
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
2206
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
2207
|
+
return await realpath(directory);
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2210
|
+
directory,
|
|
2211
|
+
reason: "create_failed",
|
|
2212
|
+
...errorFields(err)
|
|
2213
|
+
});
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
function contains(realDirectory, realTarget) {
|
|
2218
|
+
const rel = relative(realDirectory, realTarget);
|
|
2219
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
2220
|
+
}
|
|
2221
|
+
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2222
|
+
let current = existingAncestor;
|
|
2223
|
+
for (const segment of missingSegments) {
|
|
2224
|
+
current = join(current, segment);
|
|
2225
|
+
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2226
|
+
await chmod(current, DIRECTORY_MODE);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async function writeAtomically(realTarget, content) {
|
|
2230
|
+
const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2231
|
+
let handle;
|
|
2232
|
+
try {
|
|
2233
|
+
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
2234
|
+
await handle.writeFile(content);
|
|
2235
|
+
await handle.chmod(FILE_MODE);
|
|
2236
|
+
await handle.close();
|
|
2237
|
+
handle = void 0;
|
|
2238
|
+
await rename(temporaryPath, realTarget);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
await discardTemporaryFile(temporaryPath, handle);
|
|
2241
|
+
throw err;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function discardTemporaryFile(temporaryPath, handle) {
|
|
2245
|
+
try {
|
|
2246
|
+
await handle?.close();
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
log("warn", "file_push_temp_close_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2249
|
+
}
|
|
2250
|
+
try {
|
|
2251
|
+
await unlink(temporaryPath);
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
const errno = err.code;
|
|
2254
|
+
if (errno !== "ENOENT" && errno !== "ENOTDIR") {
|
|
2255
|
+
log("warn", "file_push_temp_cleanup_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
function refuse(code, message, fields) {
|
|
2260
|
+
log(code === "write_failed" ? "error" : "warn", "file_push_refused", { code, ...fields });
|
|
2261
|
+
return { ok: false, code, message };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// src/lib/runner-file-sync.ts
|
|
2265
|
+
var MAX_ACK_ATTEMPTS = 5;
|
|
2266
|
+
async function syncPendingRunnerFiles(options) {
|
|
2267
|
+
const pending = await listPendingFiles(options);
|
|
2268
|
+
const pendingIds = new Set(pending.map((file) => file.id));
|
|
2269
|
+
for (const id of options.ackFailures.keys()) {
|
|
2270
|
+
if (!pendingIds.has(id)) options.ackFailures.delete(id);
|
|
2271
|
+
}
|
|
2272
|
+
if (pending.length === 0) return 0;
|
|
2273
|
+
options.log({
|
|
2274
|
+
level: "info",
|
|
2275
|
+
message: `Runner file sync: ${pending.length} file(s) queued for this runner`
|
|
2276
|
+
});
|
|
2277
|
+
let applied = 0;
|
|
2278
|
+
for (const file of pending) {
|
|
2279
|
+
if ((options.ackFailures.get(file.id) ?? 0) >= MAX_ACK_ATTEMPTS) continue;
|
|
2280
|
+
if (await applyOne(options, file)) applied += 1;
|
|
2281
|
+
}
|
|
2282
|
+
return applied;
|
|
2283
|
+
}
|
|
2284
|
+
async function listPendingFiles(options) {
|
|
2285
|
+
let res;
|
|
2286
|
+
try {
|
|
2287
|
+
res = await options.fetchImpl(`${options.apiUrl}/runners/${options.agentId}/files/pending`, {
|
|
2288
|
+
headers: { Authorization: options.getAuthHeader() }
|
|
2289
|
+
});
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
options.log({
|
|
2292
|
+
level: "warn",
|
|
2293
|
+
message: `Could not list pending runner files \u2014 retrying on the next drain: ${describe(err)}`
|
|
2294
|
+
});
|
|
2295
|
+
return [];
|
|
2296
|
+
}
|
|
2297
|
+
if (!res.ok) {
|
|
2298
|
+
options.log({
|
|
2299
|
+
level: res.status === 404 ? "debug" : "warn",
|
|
2300
|
+
message: `Listing pending runner files returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2301
|
+
});
|
|
2302
|
+
return [];
|
|
2303
|
+
}
|
|
2304
|
+
let body;
|
|
2305
|
+
try {
|
|
2306
|
+
body = await res.json();
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
options.log({
|
|
2309
|
+
level: "warn",
|
|
2310
|
+
message: `Pending runner file list was not readable JSON \u2014 retrying on the next drain: ${describe(err)}`
|
|
2311
|
+
});
|
|
2312
|
+
return [];
|
|
2313
|
+
}
|
|
2314
|
+
if (!Array.isArray(body)) {
|
|
2315
|
+
options.log({
|
|
2316
|
+
level: "warn",
|
|
2317
|
+
message: "Pending runner file list was not an array \u2014 ignoring it for this drain"
|
|
2318
|
+
});
|
|
2319
|
+
return [];
|
|
2320
|
+
}
|
|
2321
|
+
const files = [];
|
|
2322
|
+
for (const entry of body) {
|
|
2323
|
+
const file = asPendingFile(entry);
|
|
2324
|
+
if (file === null) {
|
|
2325
|
+
options.log({
|
|
2326
|
+
level: "warn",
|
|
2327
|
+
message: "Ignoring a malformed pending runner file entry (expected id, path and size)"
|
|
2328
|
+
});
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
files.push(file);
|
|
2332
|
+
}
|
|
2333
|
+
return files;
|
|
2334
|
+
}
|
|
2335
|
+
function asPendingFile(entry) {
|
|
2336
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
2337
|
+
const { id, path, size } = entry;
|
|
2338
|
+
if (typeof id !== "string" || id === "") return null;
|
|
2339
|
+
if (typeof path !== "string" || path === "") return null;
|
|
2340
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
|
|
2341
|
+
return { id, path, size };
|
|
2342
|
+
}
|
|
2343
|
+
async function applyOne(options, file) {
|
|
2344
|
+
const label = `${file.id.slice(0, 8)} (${file.path})`;
|
|
2345
|
+
if (options.allowedDirectories.length === 0) {
|
|
2346
|
+
options.log({
|
|
2347
|
+
level: "warn",
|
|
2348
|
+
message: `Runner file ${label} rejected: file sync is not enabled on this runner (start it with --enable-file-sync-to)`
|
|
2349
|
+
});
|
|
2350
|
+
await ack(options, file, "rejected", "file_sync_disabled");
|
|
2351
|
+
return false;
|
|
2352
|
+
}
|
|
2353
|
+
if (file.size > MAX_FILE_PUSH_BYTES) {
|
|
2354
|
+
options.log({
|
|
2355
|
+
level: "warn",
|
|
2356
|
+
message: `Runner file ${label} rejected: declared ${file.size} bytes, the limit is ${MAX_FILE_PUSH_BYTES}`
|
|
2357
|
+
});
|
|
2358
|
+
await ack(options, file, "rejected", "file_too_large");
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
const download = await downloadContent(options, file, label);
|
|
2362
|
+
if (!download.ok) {
|
|
2363
|
+
if (download.terminal) await ack(options, file, "rejected", download.code);
|
|
2364
|
+
return false;
|
|
2365
|
+
}
|
|
2366
|
+
let outcome;
|
|
2367
|
+
try {
|
|
2368
|
+
outcome = await writePushedFile({
|
|
2369
|
+
requestedPath: file.path,
|
|
2370
|
+
content: download.content,
|
|
2371
|
+
allowedDirectories: options.allowedDirectories,
|
|
2372
|
+
homeDir: options.homeDir
|
|
2373
|
+
});
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
options.log({
|
|
2376
|
+
level: "error",
|
|
2377
|
+
message: `Runner file ${label} could not be written: ${describe(err)}`
|
|
2378
|
+
});
|
|
2379
|
+
await ack(options, file, "rejected", "write_failed");
|
|
2380
|
+
return false;
|
|
2381
|
+
}
|
|
2382
|
+
if (!outcome.ok) {
|
|
2383
|
+
options.log({
|
|
2384
|
+
level: "warn",
|
|
2385
|
+
message: `Runner file ${label} rejected (${outcome.code}): ${outcome.message}`
|
|
2386
|
+
});
|
|
2387
|
+
await ack(options, file, "rejected", outcome.code);
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
options.log({
|
|
2391
|
+
level: "info",
|
|
2392
|
+
message: `Runner file ${label} applied (${download.content.byteLength} bytes)`
|
|
2393
|
+
});
|
|
2394
|
+
await ack(options, file, "applied");
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
function durableDownloadCode(status) {
|
|
2398
|
+
return status === 413 ? "file_too_large" : "write_failed";
|
|
2399
|
+
}
|
|
2400
|
+
async function downloadContent(options, file, label) {
|
|
2401
|
+
try {
|
|
2402
|
+
const res = await options.fetchImpl(
|
|
2403
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/content`,
|
|
2404
|
+
{ headers: { Authorization: options.getAuthHeader() } }
|
|
2405
|
+
);
|
|
2406
|
+
if (!res.ok) {
|
|
2407
|
+
const terminal = res.status >= 400 && res.status < 500 && res.status !== 401 && res.status !== 403 && res.status !== 408 && res.status !== 429;
|
|
2408
|
+
if (!terminal) {
|
|
2409
|
+
options.log({
|
|
2410
|
+
level: "warn",
|
|
2411
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2412
|
+
});
|
|
2413
|
+
return { ok: false, terminal: false };
|
|
2414
|
+
}
|
|
2415
|
+
const code = durableDownloadCode(res.status);
|
|
2416
|
+
options.log({
|
|
2417
|
+
level: "error",
|
|
2418
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 rejecting it as ${code} (the bytes never reached the writer)`
|
|
2419
|
+
});
|
|
2420
|
+
return { ok: false, terminal: true, code };
|
|
2421
|
+
}
|
|
2422
|
+
return { ok: true, content: Buffer.from(await res.arrayBuffer()) };
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
options.log({
|
|
2425
|
+
level: "warn",
|
|
2426
|
+
message: `Downloading runner file ${label} failed \u2014 retrying on the next drain: ${describe(err)}`
|
|
2427
|
+
});
|
|
2428
|
+
return { ok: false, terminal: false };
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
async function ack(options, file, status, reason) {
|
|
2432
|
+
const outcome = `${status}${reason ? ` (${reason})` : ""}`;
|
|
2433
|
+
try {
|
|
2434
|
+
const res = await options.fetchImpl(
|
|
2435
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
2436
|
+
{
|
|
2437
|
+
method: "POST",
|
|
2438
|
+
headers: {
|
|
2439
|
+
Authorization: options.getAuthHeader(),
|
|
2440
|
+
"Content-Type": "application/json"
|
|
2441
|
+
},
|
|
2442
|
+
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
2443
|
+
}
|
|
2444
|
+
);
|
|
2445
|
+
if (!res.ok) {
|
|
2446
|
+
recordAckFailure(
|
|
2447
|
+
options,
|
|
2448
|
+
file,
|
|
2449
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} returned HTTP ${res.status}`
|
|
2450
|
+
);
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
options.ackFailures.delete(file.id);
|
|
2454
|
+
} catch (err) {
|
|
2455
|
+
recordAckFailure(
|
|
2456
|
+
options,
|
|
2457
|
+
file,
|
|
2458
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} failed: ${describe(err)}`
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
function recordAckFailure(options, file, what) {
|
|
2463
|
+
const attempts = (options.ackFailures.get(file.id) ?? 0) + 1;
|
|
2464
|
+
options.ackFailures.set(file.id, attempts);
|
|
2465
|
+
options.log({
|
|
2466
|
+
level: "error",
|
|
2467
|
+
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})`
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
function describe(err) {
|
|
2471
|
+
return err instanceof Error ? err.message : String(err);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
1807
2474
|
// src/lib/channels/driver.ts
|
|
1808
2475
|
function messageIdOf(m) {
|
|
1809
2476
|
if (!m || typeof m !== "object") return void 0;
|
|
@@ -1811,6 +2478,17 @@ function messageIdOf(m) {
|
|
|
1811
2478
|
const infoId = m.info?.id;
|
|
1812
2479
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1813
2480
|
}
|
|
2481
|
+
function cleanImageMime(contentType) {
|
|
2482
|
+
if (!contentType) return null;
|
|
2483
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
2484
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
2485
|
+
}
|
|
2486
|
+
var LOG_LEVELS = {
|
|
2487
|
+
debug: 0,
|
|
2488
|
+
info: 1,
|
|
2489
|
+
warn: 2,
|
|
2490
|
+
error: 3
|
|
2491
|
+
};
|
|
1814
2492
|
var DEFAULT_RETRY_POLICY = {
|
|
1815
2493
|
maxAttempts: 6,
|
|
1816
2494
|
baseDelayMs: 500,
|
|
@@ -1822,6 +2500,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
|
1822
2500
|
var HEARTBEAT_MS = 6e4;
|
|
1823
2501
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1824
2502
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2503
|
+
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
1825
2504
|
var ChannelAuthError = class extends Error {
|
|
1826
2505
|
constructor(message) {
|
|
1827
2506
|
super(message);
|
|
@@ -1844,7 +2523,7 @@ function backoffDelay(attempt, policy) {
|
|
|
1844
2523
|
function isRetryableStatus(status) {
|
|
1845
2524
|
return status === 429 || status >= 500 && status <= 599;
|
|
1846
2525
|
}
|
|
1847
|
-
var ChannelDriver = class {
|
|
2526
|
+
var ChannelDriver = class _ChannelDriver {
|
|
1848
2527
|
agentId;
|
|
1849
2528
|
port;
|
|
1850
2529
|
apiUrl;
|
|
@@ -1858,8 +2537,38 @@ var ChannelDriver = class {
|
|
|
1858
2537
|
pausedMaxWaitMs;
|
|
1859
2538
|
stuckQueuedMs;
|
|
1860
2539
|
now;
|
|
2540
|
+
fileSyncDirectories;
|
|
2541
|
+
homeDir;
|
|
1861
2542
|
/** Cache of conversationId → opencode sessionId. */
|
|
1862
2543
|
sessions = /* @__PURE__ */ new Map();
|
|
2544
|
+
/**
|
|
2545
|
+
* conversationId → the opencode session this runner has ABANDONED as that
|
|
2546
|
+
* conversation's binding (#553), after a genuine (`sessionExists === true`)
|
|
2547
|
+
* dispatch failure: the session still exists but is wedged, so #485's self-heal
|
|
2548
|
+
* must bind a fresh one.
|
|
2549
|
+
*
|
|
2550
|
+
* Dropping the local binding + clearing the server row is not enough on its own:
|
|
2551
|
+
* a SIBLING message dispatched earlier in the same drain is still in-flight under
|
|
2552
|
+
* the same session, and its watcher's routine status writes carry
|
|
2553
|
+
* `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
|
|
2554
|
+
* and `ensureSession`'s persisted-id fallback then reuses it, defeating the
|
|
2555
|
+
* self-heal. This map makes the runner authoritative instead of racing those
|
|
2556
|
+
* writes: *`ensureSession` never reuses an abandoned id for that conversation,
|
|
2557
|
+
* whatever the server row says* — which holds even when the resurrecting write
|
|
2558
|
+
* is one we deliberately keep (see `markDone`).
|
|
2559
|
+
*
|
|
2560
|
+
* Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
|
|
2561
|
+
* one conversation hold ONE entry (the newest abandonment replaces the older), and
|
|
2562
|
+
* hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
|
|
2563
|
+
* NEWEST abandoned id per conversation is guarded: after a second abandonment a
|
|
2564
|
+
* late sibling of the FIRST session can write that id back and `ensureSession`
|
|
2565
|
+
* will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
|
|
2566
|
+
* NOT dropped when the session's watcher tears down: `markDone` still writes the
|
|
2567
|
+
* abandoned id back (it must, or the reply is lost), so the guard has to outlive
|
|
2568
|
+
* the turn that resurrects it. In-memory only — a restart forgets it, at the same
|
|
2569
|
+
* bounded cost.
|
|
2570
|
+
*/
|
|
2571
|
+
supersededSessions = /* @__PURE__ */ new Map();
|
|
1863
2572
|
/**
|
|
1864
2573
|
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1865
2574
|
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
@@ -1941,6 +2650,15 @@ var ChannelDriver = class {
|
|
|
1941
2650
|
* so the NEXT tick may retry exactly once more).
|
|
1942
2651
|
*/
|
|
1943
2652
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2653
|
+
/**
|
|
2654
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2655
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2656
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2657
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2658
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2659
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2660
|
+
*/
|
|
2661
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1944
2662
|
/**
|
|
1945
2663
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1946
2664
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1958,8 +2676,39 @@ var ChannelDriver = class {
|
|
|
1958
2676
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1959
2677
|
*/
|
|
1960
2678
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2679
|
+
/**
|
|
2680
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2681
|
+
* NON-EMPTY, non-placeholder name is stored (terminal — a real session name
|
|
2682
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
|
|
2683
|
+
* excludes OpenCode's synchronous default title (see
|
|
2684
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
|
|
2685
|
+
* as an empty title so it never latches. A missing entry = not yet resolved OR
|
|
2686
|
+
* resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
|
|
2687
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2688
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2689
|
+
* no watcher) can resolve the title.
|
|
2690
|
+
*/
|
|
2691
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1961
2692
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1962
2693
|
draining = false;
|
|
2694
|
+
/**
|
|
2695
|
+
* Serialises runner-file syncs (#559) so the ~2s poll tick and a concurrent
|
|
2696
|
+
* drain ping don't download, write and ack the same file twice.
|
|
2697
|
+
*/
|
|
2698
|
+
syncingFiles = false;
|
|
2699
|
+
/**
|
|
2700
|
+
* Consecutive failed acks per pending file (#559). Lives on the driver so it
|
|
2701
|
+
* survives across drains — without it, a file whose ack keeps failing is
|
|
2702
|
+
* re-downloaded and re-written every ~2s until the server expires it.
|
|
2703
|
+
*/
|
|
2704
|
+
fileAckFailures = /* @__PURE__ */ new Map();
|
|
2705
|
+
/**
|
|
2706
|
+
* Monotonic count of files this runner has pulled and written (#559). Only
|
|
2707
|
+
* ever increases, so `run.ts` detects work by comparing it against the value
|
|
2708
|
+
* it saw on the previous cycle — including work that landed mid-sleep, the
|
|
2709
|
+
* same trick `lastProxiedActivityAt` uses.
|
|
2710
|
+
*/
|
|
2711
|
+
appliedFileCount = 0;
|
|
1963
2712
|
/**
|
|
1964
2713
|
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
1965
2714
|
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
@@ -1990,14 +2739,13 @@ var ChannelDriver = class {
|
|
|
1990
2739
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1991
2740
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1992
2741
|
this.now = config2.now ?? (() => Date.now());
|
|
2742
|
+
this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
|
|
2743
|
+
this.homeDir = config2.homeDir ?? homedir();
|
|
1993
2744
|
}
|
|
1994
2745
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1995
2746
|
get opencodeBase() {
|
|
1996
2747
|
return `http://127.0.0.1:${this.port}`;
|
|
1997
2748
|
}
|
|
1998
|
-
// -------------------------------------------------------------------------
|
|
1999
|
-
// Public API
|
|
2000
|
-
// -------------------------------------------------------------------------
|
|
2001
2749
|
/**
|
|
2002
2750
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
2003
2751
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2020,6 +2768,47 @@ var ChannelDriver = class {
|
|
|
2020
2768
|
);
|
|
2021
2769
|
return run2;
|
|
2022
2770
|
}
|
|
2771
|
+
/**
|
|
2772
|
+
* Pull-and-apply any files Evident has queued for this runner (#559), riding
|
|
2773
|
+
* the EXISTING drain cycle — `run.ts` calls it from the same ~2s channel poll
|
|
2774
|
+
* and drain ping that call `drainPending()`. There is deliberately no channel,
|
|
2775
|
+
* control frame or poll loop of its own: worst-case latency is one poll tick.
|
|
2776
|
+
*
|
|
2777
|
+
* NEVER throws and never surfaces a `ChannelAuthError`: a file failure must not
|
|
2778
|
+
* cost a conversation turn. Failures are logged and either acked as a terminal
|
|
2779
|
+
* outcome or left pending for the next drain (see `runner-file-sync.ts`).
|
|
2780
|
+
*
|
|
2781
|
+
* Re-entrant calls are skipped (the poll tick and a drain ping can overlap).
|
|
2782
|
+
*
|
|
2783
|
+
* @returns the number of files written to disk.
|
|
2784
|
+
*/
|
|
2785
|
+
async syncPendingFiles() {
|
|
2786
|
+
if (this.stopped) return 0;
|
|
2787
|
+
if (this.syncingFiles) return 0;
|
|
2788
|
+
this.syncingFiles = true;
|
|
2789
|
+
try {
|
|
2790
|
+
const applied = await syncPendingRunnerFiles({
|
|
2791
|
+
agentId: this.agentId,
|
|
2792
|
+
apiUrl: this.apiUrl,
|
|
2793
|
+
getAuthHeader: this.getAuthHeader,
|
|
2794
|
+
fetchImpl: this.fetchImpl,
|
|
2795
|
+
allowedDirectories: this.fileSyncDirectories,
|
|
2796
|
+
homeDir: this.homeDir,
|
|
2797
|
+
ackFailures: this.fileAckFailures,
|
|
2798
|
+
log: this.log
|
|
2799
|
+
});
|
|
2800
|
+
this.appliedFileCount += applied;
|
|
2801
|
+
return applied;
|
|
2802
|
+
} catch (err) {
|
|
2803
|
+
this.log({
|
|
2804
|
+
level: "error",
|
|
2805
|
+
message: `Runner file sync failed unexpectedly (message processing is unaffected): ${err instanceof Error ? err.message : String(err)}`
|
|
2806
|
+
});
|
|
2807
|
+
return 0;
|
|
2808
|
+
} finally {
|
|
2809
|
+
this.syncingFiles = false;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2023
2812
|
async runDrain() {
|
|
2024
2813
|
let dispatched = 0;
|
|
2025
2814
|
try {
|
|
@@ -2053,6 +2842,28 @@ var ChannelDriver = class {
|
|
|
2053
2842
|
}
|
|
2054
2843
|
return false;
|
|
2055
2844
|
}
|
|
2845
|
+
/**
|
|
2846
|
+
* File-pull work, for `run.ts`'s idle accounting (#559).
|
|
2847
|
+
*
|
|
2848
|
+
* Pulling a file is real work that `drainPending()` knows nothing about, so
|
|
2849
|
+
* without this a near-idle runner counts a credential pull as an empty tick
|
|
2850
|
+
* and `--idle-timeout` can `process.exit` mid-pull — leaving a
|
|
2851
|
+
* `.evident-push-*.tmp` behind — or immediately after the write, before the
|
|
2852
|
+
* browser has run the authorize/callback that activates it (the user then sees
|
|
2853
|
+
* `saved_not_activated` for a runner that was fine).
|
|
2854
|
+
*
|
|
2855
|
+
* Two signals because one cannot cover both cases: `inFlight` is the pull
|
|
2856
|
+
* happening RIGHT NOW (it may outlive the tick that started it), and
|
|
2857
|
+
* `appliedFiles` is monotonic so a pull that started AND finished between two
|
|
2858
|
+
* idle checks still shows up as an advance.
|
|
2859
|
+
*
|
|
2860
|
+
* CALLER CONTRACT: sample `inFlight` BEFORE calling `syncPendingFiles()` for
|
|
2861
|
+
* the cycle. `syncPendingFiles` sets the flag synchronously, so a caller that
|
|
2862
|
+
* samples afterwards reads `true` every single cycle and can never idle out.
|
|
2863
|
+
*/
|
|
2864
|
+
fileSyncActivity() {
|
|
2865
|
+
return { appliedFiles: this.appliedFileCount, inFlight: this.syncingFiles };
|
|
2866
|
+
}
|
|
2056
2867
|
/**
|
|
2057
2868
|
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2058
2869
|
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
@@ -2114,7 +2925,7 @@ var ChannelDriver = class {
|
|
|
2114
2925
|
await this.sleep(step);
|
|
2115
2926
|
}
|
|
2116
2927
|
}
|
|
2117
|
-
while (this.hasInFlightWatchers()) {
|
|
2928
|
+
while (this.hasInFlightWatchers() || this.syncingFiles) {
|
|
2118
2929
|
if (this.now() >= deadline) return false;
|
|
2119
2930
|
await this.sleep(step);
|
|
2120
2931
|
}
|
|
@@ -2139,9 +2950,7 @@ var ChannelDriver = class {
|
|
|
2139
2950
|
if (!stillLive) return;
|
|
2140
2951
|
}
|
|
2141
2952
|
}
|
|
2142
|
-
// -------------------------------------------------------------------------
|
|
2143
2953
|
// Conversation processing (WI-3 — async dispatch)
|
|
2144
|
-
// -------------------------------------------------------------------------
|
|
2145
2954
|
/**
|
|
2146
2955
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2147
2956
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2151,10 +2960,15 @@ var ChannelDriver = class {
|
|
|
2151
2960
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
2152
2961
|
*/
|
|
2153
2962
|
async processConversation(conv) {
|
|
2154
|
-
const sessionId = await this.ensureSession(conv);
|
|
2963
|
+
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
2155
2964
|
const messages = await this.getPendingMessages(conv.id);
|
|
2156
2965
|
let dispatched = 0;
|
|
2157
2966
|
let skippedAlreadyDispatched = 0;
|
|
2967
|
+
if (refusedSessionId && messages.length > 0) {
|
|
2968
|
+
void this.postSignal(conv.id, messages[0].id, "session_superseded", {
|
|
2969
|
+
superseded_session_id: refusedSessionId
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2158
2972
|
for (const message of messages) {
|
|
2159
2973
|
if (this.stopped) break;
|
|
2160
2974
|
if (this.dispatched.has(message.id)) {
|
|
@@ -2168,41 +2982,67 @@ var ChannelDriver = class {
|
|
|
2168
2982
|
let opencodeMessageId;
|
|
2169
2983
|
try {
|
|
2170
2984
|
this.log({
|
|
2171
|
-
level: "info",
|
|
2172
|
-
message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
|
|
2985
|
+
level: "info",
|
|
2986
|
+
message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
|
|
2987
|
+
conversation_id: conv.id,
|
|
2988
|
+
message_id: message.id
|
|
2989
|
+
});
|
|
2990
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2991
|
+
opencodeMessageId = await this.dispatchLocked(
|
|
2992
|
+
sessionId,
|
|
2993
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2994
|
+
);
|
|
2995
|
+
} catch (err) {
|
|
2996
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2997
|
+
this.dispatched.delete(message.id);
|
|
2998
|
+
const exists = await sessionExists(this.port, sessionId);
|
|
2999
|
+
if (exists === false) {
|
|
3000
|
+
this.sessions.delete(conv.id);
|
|
3001
|
+
this.log({
|
|
3002
|
+
level: "warn",
|
|
3003
|
+
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.`,
|
|
3004
|
+
conversation_id: conv.id,
|
|
3005
|
+
message_id: message.id
|
|
3006
|
+
});
|
|
3007
|
+
break;
|
|
3008
|
+
}
|
|
3009
|
+
if (exists === null) {
|
|
3010
|
+
this.log({
|
|
3011
|
+
level: "warn",
|
|
3012
|
+
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.`,
|
|
3013
|
+
conversation_id: conv.id,
|
|
3014
|
+
message_id: message.id
|
|
3015
|
+
});
|
|
3016
|
+
break;
|
|
3017
|
+
}
|
|
3018
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
3019
|
+
this.sessions.delete(conv.id);
|
|
3020
|
+
this.supersede(conv.id, sessionId);
|
|
3021
|
+
this.log({
|
|
3022
|
+
level: "warn",
|
|
3023
|
+
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.`,
|
|
2173
3024
|
conversation_id: conv.id,
|
|
2174
3025
|
message_id: message.id
|
|
2175
3026
|
});
|
|
2176
|
-
|
|
2177
|
-
sessionId,
|
|
2178
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2179
|
-
);
|
|
2180
|
-
} catch (err) {
|
|
2181
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
2182
|
-
this.dispatched.delete(message.id);
|
|
2183
|
-
if (await sessionExists(this.port, sessionId) === false) {
|
|
2184
|
-
this.sessions.delete(conv.id);
|
|
3027
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
2185
3028
|
this.log({
|
|
2186
|
-
level: "
|
|
2187
|
-
message: `
|
|
3029
|
+
level: "warn",
|
|
3030
|
+
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)}`,
|
|
2188
3031
|
conversation_id: conv.id,
|
|
2189
3032
|
message_id: message.id
|
|
2190
3033
|
});
|
|
2191
|
-
break;
|
|
2192
|
-
}
|
|
2193
|
-
await this.markFailed(conv.id, message.id).catch(() => {
|
|
2194
3034
|
});
|
|
2195
3035
|
this.log({
|
|
2196
3036
|
level: "error",
|
|
2197
|
-
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${
|
|
3037
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
|
|
2198
3038
|
conversation_id: conv.id,
|
|
2199
3039
|
message_id: message.id
|
|
2200
3040
|
});
|
|
2201
|
-
|
|
3041
|
+
break;
|
|
2202
3042
|
}
|
|
2203
3043
|
if (opencodeMessageId === null) {
|
|
2204
3044
|
this.log({
|
|
2205
|
-
level: "
|
|
3045
|
+
level: "warn",
|
|
2206
3046
|
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
3047
|
conversation_id: conv.id,
|
|
2208
3048
|
message_id: message.id
|
|
@@ -2216,7 +3056,7 @@ var ChannelDriver = class {
|
|
|
2216
3056
|
}
|
|
2217
3057
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2218
3058
|
this.log({
|
|
2219
|
-
level: "
|
|
3059
|
+
level: "warn",
|
|
2220
3060
|
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
3061
|
conversation_id: conv.id
|
|
2222
3062
|
});
|
|
@@ -2224,23 +3064,57 @@ var ChannelDriver = class {
|
|
|
2224
3064
|
this.ensureWatcherRunning(sessionId);
|
|
2225
3065
|
return dispatched;
|
|
2226
3066
|
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
3069
|
+
* (#553). Keyed by conversation and hard-capped, so it cannot grow with the
|
|
3070
|
+
* number of failures — see the `supersededSessions` field doc.
|
|
3071
|
+
*/
|
|
3072
|
+
supersede(conversationId, sessionId) {
|
|
3073
|
+
this.supersededSessions.delete(conversationId);
|
|
3074
|
+
this.supersededSessions.set(conversationId, sessionId);
|
|
3075
|
+
while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
|
|
3076
|
+
const oldest = this.supersededSessions.keys().next().value;
|
|
3077
|
+
if (oldest === void 0) return;
|
|
3078
|
+
this.supersededSessions.delete(oldest);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
/** Whether `sessionId` is the session this conversation has abandoned (#553). */
|
|
3082
|
+
isSuperseded(conversationId, sessionId) {
|
|
3083
|
+
return this.supersededSessions.get(conversationId) === sessionId;
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Resolve the opencode session to run this conversation's turns in.
|
|
3087
|
+
*
|
|
3088
|
+
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
3089
|
+
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
3090
|
+
* happened and a fresh session was bound instead. The caller reports it.
|
|
3091
|
+
*/
|
|
2227
3092
|
async ensureSession(conv) {
|
|
2228
3093
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
3094
|
+
if (bound && this.isSuperseded(conv.id, bound)) {
|
|
3095
|
+
this.log({
|
|
3096
|
+
level: "warn",
|
|
3097
|
+
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.`,
|
|
3098
|
+
conversation_id: conv.id
|
|
3099
|
+
});
|
|
3100
|
+
this.sessions.delete(conv.id);
|
|
3101
|
+
return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
|
|
3102
|
+
}
|
|
2229
3103
|
if (bound) {
|
|
2230
3104
|
const exists = await sessionExists(this.port, bound);
|
|
2231
3105
|
if (exists === false) {
|
|
2232
3106
|
this.log({
|
|
2233
|
-
level: "
|
|
3107
|
+
level: "debug",
|
|
2234
3108
|
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
3109
|
conversation_id: conv.id
|
|
2236
3110
|
});
|
|
2237
3111
|
this.sessions.delete(conv.id);
|
|
2238
|
-
return this.createAndBindSession(conv.id);
|
|
3112
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2239
3113
|
}
|
|
2240
3114
|
this.sessions.set(conv.id, bound);
|
|
2241
|
-
return bound;
|
|
3115
|
+
return { sessionId: bound };
|
|
2242
3116
|
}
|
|
2243
|
-
return this.createAndBindSession(conv.id);
|
|
3117
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2244
3118
|
}
|
|
2245
3119
|
/**
|
|
2246
3120
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -2265,15 +3139,13 @@ var ChannelDriver = class {
|
|
|
2265
3139
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2266
3140
|
if (!this.opencodeDirectory) {
|
|
2267
3141
|
this.log({
|
|
2268
|
-
level: "
|
|
3142
|
+
level: "warn",
|
|
2269
3143
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2270
3144
|
});
|
|
2271
3145
|
}
|
|
2272
3146
|
return this.opencodeDirectory;
|
|
2273
3147
|
}
|
|
2274
|
-
// -------------------------------------------------------------------------
|
|
2275
3148
|
// Per-session watcher (WI-3)
|
|
2276
|
-
// -------------------------------------------------------------------------
|
|
2277
3149
|
/**
|
|
2278
3150
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2279
3151
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2293,6 +3165,130 @@ var ChannelDriver = class {
|
|
|
2293
3165
|
);
|
|
2294
3166
|
return run2;
|
|
2295
3167
|
}
|
|
3168
|
+
// Inbound image attachments (#255, WI-8)
|
|
3169
|
+
/**
|
|
3170
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
3171
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
3172
|
+
*
|
|
3173
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
3174
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
3175
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
3176
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
3177
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
3178
|
+
* existing callback surface when any image was skipped/failed.
|
|
3179
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
3180
|
+
* reports outcomes back via `onOutcomes`.
|
|
3181
|
+
*/
|
|
3182
|
+
buildSendAttachments(conv, message) {
|
|
3183
|
+
const refs = message.attachments;
|
|
3184
|
+
if (!refs || refs.length === 0) return void 0;
|
|
3185
|
+
return {
|
|
3186
|
+
inputs: refs.map((a, index) => ({
|
|
3187
|
+
index,
|
|
3188
|
+
mime: a.mime,
|
|
3189
|
+
...a.filename ? { filename: a.filename } : {}
|
|
3190
|
+
})),
|
|
3191
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
3192
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
/**
|
|
3196
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
3197
|
+
* (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
|
|
3198
|
+
* existing authenticated fetch, and base64-encode into a
|
|
3199
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
3200
|
+
*
|
|
3201
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
3202
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
3203
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
3204
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
3205
|
+
* A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
|
|
3206
|
+
* a Slack `files:read` scope problem via `files.info`) instead resolves the
|
|
3207
|
+
* `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
|
|
3208
|
+
* user to reconnect Slack instead of a generic "unavailable". Failures are
|
|
3209
|
+
* logged with context (no silent swallow).
|
|
3210
|
+
*/
|
|
3211
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
3212
|
+
try {
|
|
3213
|
+
const res = await this.fetchImpl(
|
|
3214
|
+
`${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
|
|
3215
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3216
|
+
);
|
|
3217
|
+
if (!res.ok) {
|
|
3218
|
+
let reason;
|
|
3219
|
+
try {
|
|
3220
|
+
const body = await res.json();
|
|
3221
|
+
if (body && typeof body.reason === "string") reason = body.reason;
|
|
3222
|
+
} catch (parseErr) {
|
|
3223
|
+
this.log({
|
|
3224
|
+
level: "debug",
|
|
3225
|
+
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`,
|
|
3226
|
+
message_id: messageId
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
if (reason === "needs_reauth") {
|
|
3230
|
+
this.log({
|
|
3231
|
+
level: "error",
|
|
3232
|
+
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)`,
|
|
3233
|
+
message_id: messageId
|
|
3234
|
+
});
|
|
3235
|
+
return { needsReauth: true };
|
|
3236
|
+
}
|
|
3237
|
+
this.log({
|
|
3238
|
+
level: "error",
|
|
3239
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
3240
|
+
message_id: messageId
|
|
3241
|
+
});
|
|
3242
|
+
return null;
|
|
3243
|
+
}
|
|
3244
|
+
const buf = await res.arrayBuffer();
|
|
3245
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
3246
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
3247
|
+
return `data:${dataMime};base64,${base64}`;
|
|
3248
|
+
} catch (err) {
|
|
3249
|
+
this.log({
|
|
3250
|
+
level: "error",
|
|
3251
|
+
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)}`,
|
|
3252
|
+
message_id: messageId
|
|
3253
|
+
});
|
|
3254
|
+
return null;
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
3259
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
3260
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
3261
|
+
*
|
|
3262
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
3263
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
3264
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
3265
|
+
* reaches the channel.
|
|
3266
|
+
*
|
|
3267
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
3268
|
+
*/
|
|
3269
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
3270
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
3271
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
3272
|
+
if (skipped === 0 && failed === 0) return;
|
|
3273
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
3274
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
3275
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
3276
|
+
const failedReason = outcomes.some(
|
|
3277
|
+
(o) => o.status === "failed" && o.reason === "needs_reauth"
|
|
3278
|
+
) ? "needs_reauth" : void 0;
|
|
3279
|
+
this.log({
|
|
3280
|
+
level: "info",
|
|
3281
|
+
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`,
|
|
3282
|
+
conversation_id: conversationId,
|
|
3283
|
+
message_id: messageId
|
|
3284
|
+
});
|
|
3285
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
3286
|
+
skipped,
|
|
3287
|
+
failed,
|
|
3288
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {},
|
|
3289
|
+
...failedReason ? { failed_reason: failedReason } : {}
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
2296
3292
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2297
3293
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2298
3294
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2321,6 +3317,8 @@ var ChannelDriver = class {
|
|
|
2321
3317
|
stuckReported: false,
|
|
2322
3318
|
lastAliveAt: 0,
|
|
2323
3319
|
aliveInFlight: false,
|
|
3320
|
+
titleSynced: false,
|
|
3321
|
+
titleSyncInFlight: false,
|
|
2324
3322
|
awaitingHumanLatched: false,
|
|
2325
3323
|
pausedOnQuestion: false,
|
|
2326
3324
|
pausedOnPermission: false,
|
|
@@ -2394,6 +3392,8 @@ var ChannelDriver = class {
|
|
|
2394
3392
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2395
3393
|
lastAliveAt: 0,
|
|
2396
3394
|
aliveInFlight: false,
|
|
3395
|
+
titleSynced: false,
|
|
3396
|
+
titleSyncInFlight: false,
|
|
2397
3397
|
awaitingHumanLatched: false,
|
|
2398
3398
|
pausedOnQuestion: false,
|
|
2399
3399
|
pausedOnPermission: false,
|
|
@@ -2530,18 +3530,20 @@ var ChannelDriver = class {
|
|
|
2530
3530
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2531
3531
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2532
3532
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
3533
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2533
3534
|
let claimed;
|
|
2534
3535
|
try {
|
|
2535
3536
|
claimed = await this.markProcessing(
|
|
2536
3537
|
conv.id,
|
|
2537
3538
|
inFlight.evidentMessageId,
|
|
2538
3539
|
sessionId,
|
|
2539
|
-
inFlight.opencodeMessageId
|
|
3540
|
+
inFlight.opencodeMessageId,
|
|
3541
|
+
title
|
|
2540
3542
|
);
|
|
2541
3543
|
} catch (err) {
|
|
2542
3544
|
if (err instanceof ChannelAuthError) throw err;
|
|
2543
3545
|
this.log({
|
|
2544
|
-
level: "
|
|
3546
|
+
level: "warn",
|
|
2545
3547
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
3548
|
conversation_id: conv.id,
|
|
2547
3549
|
message_id: inFlight.evidentMessageId
|
|
@@ -2551,7 +3553,7 @@ var ChannelDriver = class {
|
|
|
2551
3553
|
inFlight.started = true;
|
|
2552
3554
|
if (!claimed) {
|
|
2553
3555
|
this.log({
|
|
2554
|
-
level: "
|
|
3556
|
+
level: "debug",
|
|
2555
3557
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2556
3558
|
conversation_id: conv.id,
|
|
2557
3559
|
message_id: inFlight.evidentMessageId
|
|
@@ -2567,18 +3569,22 @@ var ChannelDriver = class {
|
|
|
2567
3569
|
conversation_id: conv.id,
|
|
2568
3570
|
message_id: inFlight.evidentMessageId
|
|
2569
3571
|
});
|
|
3572
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3573
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2570
3574
|
try {
|
|
2571
3575
|
await this.markDone(
|
|
2572
3576
|
conv.id,
|
|
2573
3577
|
inFlight.evidentMessageId,
|
|
2574
3578
|
sessionId,
|
|
2575
|
-
inFlight.opencodeMessageId
|
|
3579
|
+
inFlight.opencodeMessageId,
|
|
3580
|
+
title,
|
|
3581
|
+
usage
|
|
2576
3582
|
);
|
|
2577
3583
|
} catch (err) {
|
|
2578
3584
|
if (err instanceof ChannelAuthError) throw err;
|
|
2579
3585
|
if (err instanceof ChannelTerminalError) {
|
|
2580
3586
|
this.log({
|
|
2581
|
-
level: "
|
|
3587
|
+
level: "warn",
|
|
2582
3588
|
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
3589
|
conversation_id: conv.id,
|
|
2584
3590
|
message_id: inFlight.evidentMessageId
|
|
@@ -2588,7 +3594,7 @@ var ChannelDriver = class {
|
|
|
2588
3594
|
}
|
|
2589
3595
|
if (this.now() >= inFlight.deadline) {
|
|
2590
3596
|
this.log({
|
|
2591
|
-
level: "
|
|
3597
|
+
level: "warn",
|
|
2592
3598
|
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
3599
|
conversation_id: conv.id,
|
|
2594
3600
|
message_id: inFlight.evidentMessageId
|
|
@@ -2597,7 +3603,7 @@ var ChannelDriver = class {
|
|
|
2597
3603
|
return;
|
|
2598
3604
|
}
|
|
2599
3605
|
this.log({
|
|
2600
|
-
level: "
|
|
3606
|
+
level: "warn",
|
|
2601
3607
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2602
3608
|
conversation_id: conv.id,
|
|
2603
3609
|
message_id: inFlight.evidentMessageId
|
|
@@ -2619,13 +3625,14 @@ var ChannelDriver = class {
|
|
|
2619
3625
|
conversation_id: conv.id,
|
|
2620
3626
|
message_id: inFlight.evidentMessageId
|
|
2621
3627
|
});
|
|
3628
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2622
3629
|
try {
|
|
2623
|
-
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
3630
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
|
|
2624
3631
|
} catch (err) {
|
|
2625
3632
|
if (err instanceof ChannelAuthError) throw err;
|
|
2626
3633
|
if (err instanceof ChannelTerminalError) {
|
|
2627
3634
|
this.log({
|
|
2628
|
-
level: "
|
|
3635
|
+
level: "warn",
|
|
2629
3636
|
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
3637
|
conversation_id: conv.id,
|
|
2631
3638
|
message_id: inFlight.evidentMessageId
|
|
@@ -2635,7 +3642,7 @@ var ChannelDriver = class {
|
|
|
2635
3642
|
}
|
|
2636
3643
|
if (this.now() >= inFlight.deadline) {
|
|
2637
3644
|
this.log({
|
|
2638
|
-
level: "
|
|
3645
|
+
level: "warn",
|
|
2639
3646
|
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
3647
|
conversation_id: conv.id,
|
|
2641
3648
|
message_id: inFlight.evidentMessageId
|
|
@@ -2644,7 +3651,7 @@ var ChannelDriver = class {
|
|
|
2644
3651
|
return;
|
|
2645
3652
|
}
|
|
2646
3653
|
this.log({
|
|
2647
|
-
level: "
|
|
3654
|
+
level: "warn",
|
|
2648
3655
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2649
3656
|
conversation_id: conv.id,
|
|
2650
3657
|
message_id: inFlight.evidentMessageId
|
|
@@ -2667,7 +3674,7 @@ var ChannelDriver = class {
|
|
|
2667
3674
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2668
3675
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2669
3676
|
this.log({
|
|
2670
|
-
level: "
|
|
3677
|
+
level: "warn",
|
|
2671
3678
|
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
3679
|
conversation_id: conv.id,
|
|
2673
3680
|
message_id: inFlight.evidentMessageId
|
|
@@ -2684,6 +3691,18 @@ var ChannelDriver = class {
|
|
|
2684
3691
|
inFlight.aliveInFlight = false;
|
|
2685
3692
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
2686
3693
|
});
|
|
3694
|
+
if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
|
|
3695
|
+
inFlight.titleSyncInFlight = true;
|
|
3696
|
+
void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
|
|
3697
|
+
if (!title) {
|
|
3698
|
+
inFlight.titleSyncInFlight = false;
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
const ok = await this.patchConversationTitle(conv.id, title);
|
|
3702
|
+
inFlight.titleSyncInFlight = false;
|
|
3703
|
+
if (ok) inFlight.titleSynced = true;
|
|
3704
|
+
});
|
|
3705
|
+
}
|
|
2687
3706
|
}
|
|
2688
3707
|
if (awaitingHuman) {
|
|
2689
3708
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -2710,7 +3729,7 @@ var ChannelDriver = class {
|
|
|
2710
3729
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2711
3730
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2712
3731
|
this.log({
|
|
2713
|
-
level: "
|
|
3732
|
+
level: "debug",
|
|
2714
3733
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2715
3734
|
conversation_id: conv.id,
|
|
2716
3735
|
message_id: inFlight.evidentMessageId
|
|
@@ -2721,9 +3740,7 @@ var ChannelDriver = class {
|
|
|
2721
3740
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2722
3741
|
}
|
|
2723
3742
|
}
|
|
2724
|
-
// -------------------------------------------------------------------------
|
|
2725
3743
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2726
|
-
// -------------------------------------------------------------------------
|
|
2727
3744
|
/**
|
|
2728
3745
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2729
3746
|
*
|
|
@@ -2753,7 +3770,7 @@ var ChannelDriver = class {
|
|
|
2753
3770
|
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2754
3771
|
if (cleared || clearedUndeliverable) {
|
|
2755
3772
|
this.log({
|
|
2756
|
-
level: "
|
|
3773
|
+
level: "debug",
|
|
2757
3774
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2758
3775
|
message_id: id
|
|
2759
3776
|
});
|
|
@@ -2766,7 +3783,7 @@ var ChannelDriver = class {
|
|
|
2766
3783
|
for (const row of rows) {
|
|
2767
3784
|
if (!row.opencode_session_id) {
|
|
2768
3785
|
this.log({
|
|
2769
|
-
level: "
|
|
3786
|
+
level: "warn",
|
|
2770
3787
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2771
3788
|
conversation_id: row.conversation_id,
|
|
2772
3789
|
message_id: row.id
|
|
@@ -2783,7 +3800,7 @@ var ChannelDriver = class {
|
|
|
2783
3800
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2784
3801
|
if (!res.ok) {
|
|
2785
3802
|
this.log({
|
|
2786
|
-
level: "
|
|
3803
|
+
level: "warn",
|
|
2787
3804
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2788
3805
|
});
|
|
2789
3806
|
continue;
|
|
@@ -2791,7 +3808,7 @@ var ChannelDriver = class {
|
|
|
2791
3808
|
const body = await res.json();
|
|
2792
3809
|
if (!Array.isArray(body)) {
|
|
2793
3810
|
this.log({
|
|
2794
|
-
level: "
|
|
3811
|
+
level: "warn",
|
|
2795
3812
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2796
3813
|
});
|
|
2797
3814
|
continue;
|
|
@@ -2799,7 +3816,7 @@ var ChannelDriver = class {
|
|
|
2799
3816
|
messages = body;
|
|
2800
3817
|
} catch (err) {
|
|
2801
3818
|
this.log({
|
|
2802
|
-
level: "
|
|
3819
|
+
level: "warn",
|
|
2803
3820
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2804
3821
|
});
|
|
2805
3822
|
continue;
|
|
@@ -2832,7 +3849,7 @@ var ChannelDriver = class {
|
|
|
2832
3849
|
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2833
3850
|
if (this.isTracked(sessionId, row.id)) {
|
|
2834
3851
|
this.log({
|
|
2835
|
-
level: "
|
|
3852
|
+
level: "debug",
|
|
2836
3853
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2837
3854
|
conversation_id: row.conversation_id,
|
|
2838
3855
|
message_id: row.id
|
|
@@ -2844,7 +3861,7 @@ var ChannelDriver = class {
|
|
|
2844
3861
|
if (state === "done") {
|
|
2845
3862
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2846
3863
|
this.log({
|
|
2847
|
-
level: "
|
|
3864
|
+
level: "debug",
|
|
2848
3865
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2849
3866
|
conversation_id: row.conversation_id,
|
|
2850
3867
|
message_id: row.id
|
|
@@ -2858,13 +3875,15 @@ var ChannelDriver = class {
|
|
|
2858
3875
|
message_id: row.id
|
|
2859
3876
|
});
|
|
2860
3877
|
try {
|
|
2861
|
-
await this.
|
|
3878
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3879
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3880
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
2862
3881
|
} catch (err) {
|
|
2863
3882
|
if (err instanceof ChannelAuthError) throw err;
|
|
2864
3883
|
if (err instanceof ChannelTerminalError) {
|
|
2865
3884
|
this.doneUndeliverable.add(row.id);
|
|
2866
3885
|
this.log({
|
|
2867
|
-
level: "
|
|
3886
|
+
level: "warn",
|
|
2868
3887
|
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
3888
|
conversation_id: row.conversation_id,
|
|
2870
3889
|
message_id: row.id
|
|
@@ -2873,7 +3892,7 @@ var ChannelDriver = class {
|
|
|
2873
3892
|
return;
|
|
2874
3893
|
}
|
|
2875
3894
|
this.log({
|
|
2876
|
-
level: "
|
|
3895
|
+
level: "warn",
|
|
2877
3896
|
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
3897
|
conversation_id: row.conversation_id,
|
|
2879
3898
|
message_id: row.id
|
|
@@ -2886,6 +3905,7 @@ var ChannelDriver = class {
|
|
|
2886
3905
|
}
|
|
2887
3906
|
if (state === "failed") {
|
|
2888
3907
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3908
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
2889
3909
|
this.log({
|
|
2890
3910
|
level: "error",
|
|
2891
3911
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2893,13 +3913,13 @@ var ChannelDriver = class {
|
|
|
2893
3913
|
message_id: row.id
|
|
2894
3914
|
});
|
|
2895
3915
|
try {
|
|
2896
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
3916
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
2897
3917
|
} catch (err) {
|
|
2898
3918
|
if (err instanceof ChannelAuthError) throw err;
|
|
2899
3919
|
if (err instanceof ChannelTerminalError) {
|
|
2900
3920
|
this.doneUndeliverable.add(row.id);
|
|
2901
3921
|
this.log({
|
|
2902
|
-
level: "
|
|
3922
|
+
level: "warn",
|
|
2903
3923
|
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
3924
|
conversation_id: row.conversation_id,
|
|
2905
3925
|
message_id: row.id
|
|
@@ -2908,7 +3928,7 @@ var ChannelDriver = class {
|
|
|
2908
3928
|
return;
|
|
2909
3929
|
}
|
|
2910
3930
|
this.log({
|
|
2911
|
-
level: "
|
|
3931
|
+
level: "warn",
|
|
2912
3932
|
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
3933
|
conversation_id: row.conversation_id,
|
|
2914
3934
|
message_id: row.id
|
|
@@ -2921,7 +3941,7 @@ var ChannelDriver = class {
|
|
|
2921
3941
|
}
|
|
2922
3942
|
if (this.dontRedispatch.has(row.id)) {
|
|
2923
3943
|
this.log({
|
|
2924
|
-
level: "
|
|
3944
|
+
level: "debug",
|
|
2925
3945
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2926
3946
|
conversation_id: row.conversation_id,
|
|
2927
3947
|
message_id: row.id
|
|
@@ -2946,7 +3966,7 @@ var ChannelDriver = class {
|
|
|
2946
3966
|
}
|
|
2947
3967
|
if (ongoing === true) {
|
|
2948
3968
|
this.log({
|
|
2949
|
-
level: "
|
|
3969
|
+
level: "debug",
|
|
2950
3970
|
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
3971
|
conversation_id: row.conversation_id,
|
|
2952
3972
|
message_id: row.id
|
|
@@ -2954,7 +3974,7 @@ var ChannelDriver = class {
|
|
|
2954
3974
|
} else {
|
|
2955
3975
|
if (shape === "b1") {
|
|
2956
3976
|
this.log({
|
|
2957
|
-
level: "
|
|
3977
|
+
level: "debug",
|
|
2958
3978
|
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
3979
|
conversation_id: row.conversation_id,
|
|
2960
3980
|
message_id: row.id
|
|
@@ -2966,7 +3986,7 @@ var ChannelDriver = class {
|
|
|
2966
3986
|
return;
|
|
2967
3987
|
}
|
|
2968
3988
|
this.log({
|
|
2969
|
-
level: "
|
|
3989
|
+
level: "debug",
|
|
2970
3990
|
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
3991
|
conversation_id: row.conversation_id,
|
|
2972
3992
|
message_id: row.id
|
|
@@ -2977,7 +3997,7 @@ var ChannelDriver = class {
|
|
|
2977
3997
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2978
3998
|
if (descendantAlive === true) {
|
|
2979
3999
|
this.log({
|
|
2980
|
-
level: "
|
|
4000
|
+
level: "debug",
|
|
2981
4001
|
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
4002
|
conversation_id: row.conversation_id,
|
|
2983
4003
|
message_id: row.id
|
|
@@ -3001,7 +4021,7 @@ var ChannelDriver = class {
|
|
|
3001
4021
|
this.readopted.add(row.id);
|
|
3002
4022
|
this.ensureWatcherRunning(sessionId);
|
|
3003
4023
|
this.log({
|
|
3004
|
-
level: "
|
|
4024
|
+
level: "debug",
|
|
3005
4025
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
3006
4026
|
conversation_id: row.conversation_id,
|
|
3007
4027
|
message_id: row.id
|
|
@@ -3035,7 +4055,7 @@ var ChannelDriver = class {
|
|
|
3035
4055
|
async forceReadoptRun(sessionId, row) {
|
|
3036
4056
|
if (this.stopped) {
|
|
3037
4057
|
this.log({
|
|
3038
|
-
level: "
|
|
4058
|
+
level: "debug",
|
|
3039
4059
|
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
4060
|
conversation_id: row.conversation_id,
|
|
3041
4061
|
message_id: row.id
|
|
@@ -3044,7 +4064,7 @@ var ChannelDriver = class {
|
|
|
3044
4064
|
}
|
|
3045
4065
|
if (this.awaitingReadopt.has(row.id)) {
|
|
3046
4066
|
this.log({
|
|
3047
|
-
level: "
|
|
4067
|
+
level: "debug",
|
|
3048
4068
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3049
4069
|
conversation_id: row.conversation_id,
|
|
3050
4070
|
message_id: row.id
|
|
@@ -3054,7 +4074,7 @@ var ChannelDriver = class {
|
|
|
3054
4074
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
3055
4075
|
this.dontRedispatch.add(row.id);
|
|
3056
4076
|
this.log({
|
|
3057
|
-
level: "
|
|
4077
|
+
level: "debug",
|
|
3058
4078
|
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
4079
|
conversation_id: row.conversation_id,
|
|
3060
4080
|
message_id: row.id
|
|
@@ -3073,17 +4093,20 @@ var ChannelDriver = class {
|
|
|
3073
4093
|
message_id: row.id
|
|
3074
4094
|
});
|
|
3075
4095
|
this.awaitingReadopt.add(row.id);
|
|
4096
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
4097
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
4098
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
3076
4099
|
let ocId;
|
|
3077
4100
|
try {
|
|
3078
4101
|
ocId = await this.dispatchLocked(
|
|
3079
4102
|
sessionId,
|
|
3080
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
4103
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
3081
4104
|
);
|
|
3082
4105
|
} catch (err) {
|
|
3083
4106
|
this.awaitingReadopt.delete(row.id);
|
|
3084
4107
|
if (err instanceof ChannelAuthError) throw err;
|
|
3085
4108
|
this.log({
|
|
3086
|
-
level: "
|
|
4109
|
+
level: "warn",
|
|
3087
4110
|
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
4111
|
conversation_id: row.conversation_id,
|
|
3089
4112
|
message_id: row.id
|
|
@@ -3094,7 +4117,7 @@ var ChannelDriver = class {
|
|
|
3094
4117
|
if (ocId === null) {
|
|
3095
4118
|
this.awaitingReadopt.delete(row.id);
|
|
3096
4119
|
this.log({
|
|
3097
|
-
level: "
|
|
4120
|
+
level: "warn",
|
|
3098
4121
|
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
4122
|
conversation_id: row.conversation_id,
|
|
3100
4123
|
message_id: row.id
|
|
@@ -3102,9 +4125,7 @@ var ChannelDriver = class {
|
|
|
3102
4125
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3103
4126
|
return;
|
|
3104
4127
|
}
|
|
3105
|
-
|
|
3106
|
-
const message = this.queuedMessageForRow(row);
|
|
3107
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
4128
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
3108
4129
|
this.dispatched.add(row.id);
|
|
3109
4130
|
this.readopted.add(row.id);
|
|
3110
4131
|
this.awaitingReadopt.delete(row.id);
|
|
@@ -3158,7 +4179,8 @@ var ChannelDriver = class {
|
|
|
3158
4179
|
opencode_agent: row.opencode_agent,
|
|
3159
4180
|
opencode_model: row.opencode_model,
|
|
3160
4181
|
source_message_id: row.source_message_id,
|
|
3161
|
-
slack_user_id: row.slack_user_id
|
|
4182
|
+
slack_user_id: row.slack_user_id,
|
|
4183
|
+
attachments: row.attachments ?? null
|
|
3162
4184
|
};
|
|
3163
4185
|
}
|
|
3164
4186
|
/**
|
|
@@ -3179,7 +4201,7 @@ var ChannelDriver = class {
|
|
|
3179
4201
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3180
4202
|
this.dontRedispatch.add(evidentMessageId);
|
|
3181
4203
|
this.log({
|
|
3182
|
-
level: "
|
|
4204
|
+
level: "debug",
|
|
3183
4205
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3184
4206
|
conversation_id: watcher.conv.id,
|
|
3185
4207
|
message_id: evidentMessageId
|
|
@@ -3319,6 +4341,116 @@ var ChannelDriver = class {
|
|
|
3319
4341
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3320
4342
|
return parent;
|
|
3321
4343
|
}
|
|
4344
|
+
/**
|
|
4345
|
+
* OpenCode's synchronous default session title (e.g.
|
|
4346
|
+
* `"New session - 1737800000000"`), assigned immediately when a session is
|
|
4347
|
+
* created — before OpenCode's async LLM-based auto-titling later renames it
|
|
4348
|
+
* mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
|
|
4349
|
+
* timestamp suffix's exact format is deliberately NOT matched, since the prefix
|
|
4350
|
+
* alone is the stable, cheap signal and over-anchoring on the timestamp
|
|
4351
|
+
* representation risks silently breaking if OpenCode ever changes it. Accepted
|
|
4352
|
+
* trade-off: a genuine LLM-assigned title that happens to literally start with
|
|
4353
|
+
* this prefix would also fail to latch (see `resolveSessionTitle`) —
|
|
4354
|
+
* vanishingly unlikely in practice, and deliberately not engineered around.
|
|
4355
|
+
*/
|
|
4356
|
+
static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
|
|
4357
|
+
/**
|
|
4358
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
4359
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
4360
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
4361
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
4362
|
+
* Best-effort:
|
|
4363
|
+
* - a resolved NON-EMPTY title that does NOT match
|
|
4364
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
|
|
4365
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
4366
|
+
* - while the title is still absent, empty, or matches the OpenCode
|
|
4367
|
+
* placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
|
|
4368
|
+
* asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
|
|
4369
|
+
* the cache unresolved and re-fetch on the next need so a later call (e.g. at
|
|
4370
|
+
* `done`) picks up the name assigned in the meantime. Such a call returns
|
|
4371
|
+
* `null` (omit the title on THIS PATCH) without caching. If a session is
|
|
4372
|
+
* never renamed, the title is omitted forever rather than ever persisting
|
|
4373
|
+
* the placeholder as a last resort;
|
|
4374
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
4375
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
4376
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
4377
|
+
*/
|
|
4378
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
4379
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
4380
|
+
if (cached != null) return cached;
|
|
4381
|
+
try {
|
|
4382
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
4383
|
+
if (res.ok) {
|
|
4384
|
+
const body = await res.json();
|
|
4385
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
4386
|
+
if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
|
|
4387
|
+
this.sessionTitles.set(sessionId, title);
|
|
4388
|
+
return title;
|
|
4389
|
+
}
|
|
4390
|
+
return null;
|
|
4391
|
+
}
|
|
4392
|
+
this.log({
|
|
4393
|
+
level: "debug",
|
|
4394
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
4395
|
+
conversation_id: conversationId
|
|
4396
|
+
});
|
|
4397
|
+
} catch (err) {
|
|
4398
|
+
this.log({
|
|
4399
|
+
level: "debug",
|
|
4400
|
+
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)}`,
|
|
4401
|
+
conversation_id: conversationId
|
|
4402
|
+
});
|
|
4403
|
+
}
|
|
4404
|
+
return null;
|
|
4405
|
+
}
|
|
4406
|
+
/**
|
|
4407
|
+
* Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
|
|
4408
|
+
* session title onto the conversation via the PLAIN conversation-update
|
|
4409
|
+
* endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
|
|
4410
|
+
* message-status endpoint `markProcessing`/`markDone` use. Deliberately a
|
|
4411
|
+
* separate, lighter call: it carries no `status`, so it cannot re-trigger the
|
|
4412
|
+
* `processing`/`done` transition side effects (Slack notices, activity-log
|
|
4413
|
+
* rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
|
|
4414
|
+
* ever touches `conversations.title`. That route (`routes/conversations.ts`)
|
|
4415
|
+
* skips a title write matching the stored value, so a redundant call with the
|
|
4416
|
+
* same title is a real no-op — it does not bump `updated_at`, which the
|
|
4417
|
+
* conversation list sorts and paginates on. (Note this is a DIFFERENT guard
|
|
4418
|
+
* from `threads.ts`'s "non-empty AND changed" one, which only covers the
|
|
4419
|
+
* message-status PATCH; the non-empty half is enforced here instead, by
|
|
4420
|
+
* `resolveSessionTitle` never returning an empty/placeholder title.)
|
|
4421
|
+
*
|
|
4422
|
+
* Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
|
|
4423
|
+
* is logged and the title is simply retried on the next heartbeat tick (the
|
|
4424
|
+
* caller only latches `titleSynced` on `true`).
|
|
4425
|
+
*/
|
|
4426
|
+
async patchConversationTitle(conversationId, title) {
|
|
4427
|
+
try {
|
|
4428
|
+
const res = await this.fetchImpl(
|
|
4429
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
|
|
4430
|
+
{
|
|
4431
|
+
method: "PATCH",
|
|
4432
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
4433
|
+
body: JSON.stringify({ title })
|
|
4434
|
+
}
|
|
4435
|
+
);
|
|
4436
|
+
if (!res.ok) {
|
|
4437
|
+
this.log({
|
|
4438
|
+
level: "debug",
|
|
4439
|
+
message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
|
|
4440
|
+
conversation_id: conversationId
|
|
4441
|
+
});
|
|
4442
|
+
return false;
|
|
4443
|
+
}
|
|
4444
|
+
return true;
|
|
4445
|
+
} catch (err) {
|
|
4446
|
+
this.log({
|
|
4447
|
+
level: "debug",
|
|
4448
|
+
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)}`,
|
|
4449
|
+
conversation_id: conversationId
|
|
4450
|
+
});
|
|
4451
|
+
return false;
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
3322
4454
|
/**
|
|
3323
4455
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3324
4456
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3362,7 +4494,7 @@ var ChannelDriver = class {
|
|
|
3362
4494
|
const sessions = await listSessions(this.port);
|
|
3363
4495
|
if (!sessions) {
|
|
3364
4496
|
this.log({
|
|
3365
|
-
level: "
|
|
4497
|
+
level: "warn",
|
|
3366
4498
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3367
4499
|
});
|
|
3368
4500
|
return null;
|
|
@@ -3446,12 +4578,10 @@ var ChannelDriver = class {
|
|
|
3446
4578
|
}
|
|
3447
4579
|
return inFlight.sort(byOldest)[0];
|
|
3448
4580
|
}
|
|
3449
|
-
// -------------------------------------------------------------------------
|
|
3450
4581
|
// Evident API calls (combinedAuth thread routes)
|
|
3451
|
-
// -------------------------------------------------------------------------
|
|
3452
4582
|
async getPendingConversations() {
|
|
3453
4583
|
const res = await this.fetchImpl(
|
|
3454
|
-
`${this.apiUrl}/
|
|
4584
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
|
|
3455
4585
|
{
|
|
3456
4586
|
headers: { Authorization: this.getAuthHeader() }
|
|
3457
4587
|
}
|
|
@@ -3469,7 +4599,7 @@ var ChannelDriver = class {
|
|
|
3469
4599
|
}
|
|
3470
4600
|
async getPendingMessages(conversationId) {
|
|
3471
4601
|
const res = await this.fetchImpl(
|
|
3472
|
-
`${this.apiUrl}/
|
|
4602
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
3473
4603
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3474
4604
|
);
|
|
3475
4605
|
this.assertAuth(res, "fetching pending messages");
|
|
@@ -3493,7 +4623,7 @@ var ChannelDriver = class {
|
|
|
3493
4623
|
*/
|
|
3494
4624
|
async getProcessingMessages() {
|
|
3495
4625
|
const res = await this.fetchImpl(
|
|
3496
|
-
`${this.apiUrl}/
|
|
4626
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
|
|
3497
4627
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3498
4628
|
);
|
|
3499
4629
|
this.assertAuth(res, "fetching processing messages");
|
|
@@ -3507,6 +4637,32 @@ var ChannelDriver = class {
|
|
|
3507
4637
|
}
|
|
3508
4638
|
return messages;
|
|
3509
4639
|
}
|
|
4640
|
+
/**
|
|
4641
|
+
* The `opencode_session_id` fragment of a status PATCH body — `{}` when this
|
|
4642
|
+
* conversation has ABANDONED that session (#553). The field is optional
|
|
4643
|
+
* server-side and an absent one leaves the persisted binding untouched, so
|
|
4644
|
+
* omitting it is how a routine status write stops resurrecting it.
|
|
4645
|
+
*
|
|
4646
|
+
* ONLY for writes whose sole cost is a lost deep link. The `processing` notice
|
|
4647
|
+
* degrades to no "View in Evident" link (the reaction swap still fires) and the
|
|
4648
|
+
* turn-failure notice is built from the PATCH's own `error` text with a link off
|
|
4649
|
+
* the persisted row — neither loses content the user came for. `markDone`
|
|
4650
|
+
* deliberately does NOT use this helper: the server fetches the reply text
|
|
4651
|
+
* THROUGH the session id it is given, so suppressing there would replace the
|
|
4652
|
+
* agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
|
|
4653
|
+
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
4654
|
+
* stick.
|
|
4655
|
+
*/
|
|
4656
|
+
sessionIdBody(sessionId, conversationId, messageId, status) {
|
|
4657
|
+
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
4658
|
+
this.log({
|
|
4659
|
+
level: "debug",
|
|
4660
|
+
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)}`,
|
|
4661
|
+
conversation_id: conversationId,
|
|
4662
|
+
message_id: messageId
|
|
4663
|
+
});
|
|
4664
|
+
return {};
|
|
4665
|
+
}
|
|
3510
4666
|
/**
|
|
3511
4667
|
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
3512
4668
|
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
@@ -3528,16 +4684,17 @@ var ChannelDriver = class {
|
|
|
3528
4684
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3529
4685
|
* retry vehicle for the swap-to-running.
|
|
3530
4686
|
*/
|
|
3531
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4687
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3532
4688
|
const res = await this.fetchImpl(
|
|
3533
|
-
`${this.apiUrl}/
|
|
4689
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3534
4690
|
{
|
|
3535
4691
|
method: "PATCH",
|
|
3536
4692
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3537
4693
|
body: JSON.stringify({
|
|
3538
4694
|
status: "processing",
|
|
3539
|
-
|
|
3540
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4695
|
+
...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
|
|
4696
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4697
|
+
...title ? { title } : {}
|
|
3541
4698
|
})
|
|
3542
4699
|
}
|
|
3543
4700
|
);
|
|
@@ -3576,16 +4733,23 @@ var ChannelDriver = class {
|
|
|
3576
4733
|
* watcher retries next tick within the
|
|
3577
4734
|
* deadline, Finding 4).
|
|
3578
4735
|
*/
|
|
3579
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4736
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3580
4737
|
const res = await this.fetchImpl(
|
|
3581
|
-
`${this.apiUrl}/
|
|
4738
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3582
4739
|
{
|
|
3583
4740
|
method: "PATCH",
|
|
3584
4741
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3585
4742
|
body: JSON.stringify({
|
|
3586
4743
|
status: "done",
|
|
4744
|
+
// ALWAYS sent, even for a session this conversation has abandoned
|
|
4745
|
+
// (#553): the server reads the reply text back out of THIS session id
|
|
4746
|
+
// to deliver it. Omitting it would leave the user with "✅ Done!"
|
|
4747
|
+
// instead of the answer — a worse regression than the resurrection it
|
|
4748
|
+
// would prevent, which `ensureSession`'s guard handles anyway.
|
|
3587
4749
|
opencode_session_id: sessionId,
|
|
3588
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4750
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4751
|
+
...title ? { title } : {},
|
|
4752
|
+
...usage ? usage : {}
|
|
3589
4753
|
})
|
|
3590
4754
|
}
|
|
3591
4755
|
);
|
|
@@ -3598,19 +4762,29 @@ var ChannelDriver = class {
|
|
|
3598
4762
|
}
|
|
3599
4763
|
/**
|
|
3600
4764
|
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3601
|
-
* when provided (issue #182)
|
|
3602
|
-
* `
|
|
3603
|
-
*
|
|
3604
|
-
*
|
|
4765
|
+
* when provided (issue #182). Three states for `sessionId`:
|
|
4766
|
+
* - omitted (`undefined`) → don't send the field, leave the persisted
|
|
4767
|
+
* session untouched (unused today; kept for API symmetry).
|
|
4768
|
+
* - a real id (`string`) → send it, update the persisted session (the
|
|
4769
|
+
* turn-failure call sites: an errored OpenCode turn).
|
|
4770
|
+
* - explicit `null` → send it, CLEAR the persisted session (issue
|
|
4771
|
+
* #485's dispatch-handoff-failure call site: the session id still
|
|
4772
|
+
* exists but is wedged, so the next attempt must get a fresh one
|
|
4773
|
+
* instead of reusing it — see WI-1's server-side null-clearing PATCH).
|
|
3605
4774
|
*/
|
|
3606
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
4775
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
3607
4776
|
const body = { status: "failed" };
|
|
3608
|
-
if (sessionId
|
|
4777
|
+
if (sessionId === null) {
|
|
4778
|
+
body.opencode_session_id = null;
|
|
4779
|
+
} else if (sessionId !== void 0) {
|
|
4780
|
+
Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
|
|
4781
|
+
}
|
|
3609
4782
|
if (error2 !== void 0) body.error = error2;
|
|
4783
|
+
if (usage) Object.assign(body, usage);
|
|
3610
4784
|
await this.callWithRetry(
|
|
3611
4785
|
"marking message as failed",
|
|
3612
4786
|
() => this.fetchImpl(
|
|
3613
|
-
`${this.apiUrl}/
|
|
4787
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3614
4788
|
{
|
|
3615
4789
|
method: "PATCH",
|
|
3616
4790
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3637,7 +4811,7 @@ var ChannelDriver = class {
|
|
|
3637
4811
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3638
4812
|
try {
|
|
3639
4813
|
const res = await this.fetchImpl(
|
|
3640
|
-
`${this.apiUrl}/
|
|
4814
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
3641
4815
|
{
|
|
3642
4816
|
method: "POST",
|
|
3643
4817
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3646,7 +4820,7 @@ var ChannelDriver = class {
|
|
|
3646
4820
|
);
|
|
3647
4821
|
if (!res.ok) {
|
|
3648
4822
|
this.log({
|
|
3649
|
-
level: "
|
|
4823
|
+
level: "warn",
|
|
3650
4824
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3651
4825
|
conversation_id: conversationId,
|
|
3652
4826
|
message_id: messageId
|
|
@@ -3656,7 +4830,7 @@ var ChannelDriver = class {
|
|
|
3656
4830
|
return true;
|
|
3657
4831
|
} catch (err) {
|
|
3658
4832
|
this.log({
|
|
3659
|
-
level: "
|
|
4833
|
+
level: "warn",
|
|
3660
4834
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3661
4835
|
conversation_id: conversationId,
|
|
3662
4836
|
message_id: messageId
|
|
@@ -3666,7 +4840,7 @@ var ChannelDriver = class {
|
|
|
3666
4840
|
}
|
|
3667
4841
|
async persistSession(conversationId, sessionId) {
|
|
3668
4842
|
const res = await this.fetchImpl(
|
|
3669
|
-
`${this.apiUrl}/
|
|
4843
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
|
|
3670
4844
|
{
|
|
3671
4845
|
method: "PATCH",
|
|
3672
4846
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3692,7 +4866,7 @@ var ChannelDriver = class {
|
|
|
3692
4866
|
await this.callWithRetry(
|
|
3693
4867
|
"reporting interactive event",
|
|
3694
4868
|
() => this.fetchImpl(
|
|
3695
|
-
`${this.apiUrl}/
|
|
4869
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
3696
4870
|
{
|
|
3697
4871
|
method: "POST",
|
|
3698
4872
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3718,9 +4892,7 @@ var ChannelDriver = class {
|
|
|
3718
4892
|
return false;
|
|
3719
4893
|
}
|
|
3720
4894
|
}
|
|
3721
|
-
// -------------------------------------------------------------------------
|
|
3722
4895
|
// Retry wrapper
|
|
3723
|
-
// -------------------------------------------------------------------------
|
|
3724
4896
|
/**
|
|
3725
4897
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3726
4898
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3919,7 +5091,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3919
5091
|
if (!response.ok) {
|
|
3920
5092
|
const serverMessage = await readErrorMessage(response);
|
|
3921
5093
|
return {
|
|
3922
|
-
error: `Failed to resolve
|
|
5094
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3923
5095
|
};
|
|
3924
5096
|
}
|
|
3925
5097
|
const data = await response.json();
|
|
@@ -3927,19 +5099,49 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3927
5099
|
return { agent_id: data.agent_id };
|
|
3928
5100
|
}
|
|
3929
5101
|
return {
|
|
3930
|
-
error: "Cannot resolve
|
|
5102
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3931
5103
|
};
|
|
3932
5104
|
} catch (error2) {
|
|
3933
5105
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3934
|
-
return { error: `Failed to resolve
|
|
5106
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3935
5107
|
}
|
|
3936
5108
|
}
|
|
5109
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
3937
5110
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3938
5111
|
const apiUrl = getApiUrlConfig();
|
|
3939
5112
|
try {
|
|
3940
|
-
const response = await fetch(`${apiUrl}/
|
|
5113
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
3941
5114
|
method: "POST",
|
|
3942
|
-
headers: { Authorization: authHeader }
|
|
5115
|
+
headers: { Authorization: authHeader },
|
|
5116
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5117
|
+
});
|
|
5118
|
+
if (!response.ok) {
|
|
5119
|
+
const serverMessage = await readErrorMessage(response);
|
|
5120
|
+
return {
|
|
5121
|
+
ok: false,
|
|
5122
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5123
|
+
};
|
|
5124
|
+
}
|
|
5125
|
+
return { ok: true };
|
|
5126
|
+
} catch (error2) {
|
|
5127
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5128
|
+
}
|
|
5129
|
+
}
|
|
5130
|
+
function describeBestEffortError(error2) {
|
|
5131
|
+
const name = error2?.name;
|
|
5132
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
5133
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5134
|
+
}
|
|
5135
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
5136
|
+
}
|
|
5137
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5138
|
+
try {
|
|
5139
|
+
const apiUrl = getApiUrlConfig();
|
|
5140
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5141
|
+
method: "POST",
|
|
5142
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5143
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5144
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
3943
5145
|
});
|
|
3944
5146
|
if (!response.ok) {
|
|
3945
5147
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3950,13 +5152,13 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
3950
5152
|
}
|
|
3951
5153
|
return { ok: true };
|
|
3952
5154
|
} catch (error2) {
|
|
3953
|
-
return { ok: false, error:
|
|
5155
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
3954
5156
|
}
|
|
3955
5157
|
}
|
|
3956
5158
|
async function getAgentInfo(agentId, authHeader) {
|
|
3957
5159
|
const apiUrl = getApiUrlConfig();
|
|
3958
5160
|
try {
|
|
3959
|
-
const response = await fetch(`${apiUrl}/
|
|
5161
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
3960
5162
|
headers: { Authorization: authHeader }
|
|
3961
5163
|
});
|
|
3962
5164
|
if (response.status === 401) {
|
|
@@ -3967,12 +5169,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3967
5169
|
const serverMessage = await readErrorMessage(response);
|
|
3968
5170
|
return {
|
|
3969
5171
|
valid: false,
|
|
3970
|
-
error: serverMessage ?? "You do not have access to this
|
|
5172
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3971
5173
|
};
|
|
3972
5174
|
}
|
|
3973
5175
|
if (response.status === 404) {
|
|
3974
5176
|
const serverMessage = await readErrorMessage(response);
|
|
3975
|
-
return { valid: false, error: serverMessage ?? `
|
|
5177
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3976
5178
|
}
|
|
3977
5179
|
if (!response.ok) {
|
|
3978
5180
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3985,13 +5187,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3985
5187
|
if (agent.agent_type !== "local") {
|
|
3986
5188
|
return {
|
|
3987
5189
|
valid: false,
|
|
3988
|
-
error: `
|
|
5190
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3989
5191
|
};
|
|
3990
5192
|
}
|
|
3991
5193
|
return { valid: true, agent };
|
|
3992
5194
|
} catch (error2) {
|
|
3993
5195
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3994
|
-
return { valid: false, error: `Failed to validate
|
|
5196
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3995
5197
|
}
|
|
3996
5198
|
}
|
|
3997
5199
|
|
|
@@ -4000,23 +5202,82 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4000
5202
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4001
5203
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4002
5204
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4003
|
-
|
|
5205
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
5206
|
+
function resolveLogLevel(options) {
|
|
5207
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
5208
|
+
const validate = (value, source) => {
|
|
5209
|
+
const normalized = value.trim().toLowerCase();
|
|
5210
|
+
if (!accepted.includes(normalized)) {
|
|
5211
|
+
throw new Error(
|
|
5212
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
5213
|
+
);
|
|
5214
|
+
}
|
|
5215
|
+
return normalized;
|
|
5216
|
+
};
|
|
5217
|
+
if (options.logLevel !== void 0) {
|
|
5218
|
+
return validate(options.logLevel, " (--log-level)");
|
|
5219
|
+
}
|
|
5220
|
+
if (options.verbose) {
|
|
5221
|
+
return "debug";
|
|
5222
|
+
}
|
|
5223
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
5224
|
+
if (env !== void 0 && env !== "") {
|
|
5225
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
5226
|
+
}
|
|
5227
|
+
return "info";
|
|
5228
|
+
}
|
|
5229
|
+
function resolveFileSyncDirectories(raw, homeDir) {
|
|
5230
|
+
const directories = [];
|
|
5231
|
+
for (const entry of raw ?? []) {
|
|
5232
|
+
const trimmed = entry.trim();
|
|
5233
|
+
if (trimmed === "") {
|
|
5234
|
+
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5235
|
+
}
|
|
5236
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
|
|
5237
|
+
if (!isAbsolute2(expanded)) {
|
|
5238
|
+
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5239
|
+
}
|
|
5240
|
+
const normalized = resolvePath(expanded);
|
|
5241
|
+
if (parse(normalized).root === normalized) {
|
|
5242
|
+
throw new Error(
|
|
5243
|
+
`--enable-file-sync-to will not allow-list the filesystem root ("${entry}"); name the specific directory the credentials belong in (for example ~/.claude)`
|
|
5244
|
+
);
|
|
5245
|
+
}
|
|
5246
|
+
if (!directories.includes(normalized)) {
|
|
5247
|
+
directories.push(normalized);
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
if (directories.length > MAX_FILE_SYNC_DIRECTORIES) {
|
|
5251
|
+
throw new Error(
|
|
5252
|
+
`--enable-file-sync-to accepts at most ${MAX_FILE_SYNC_DIRECTORIES} directories; got ${directories.length}`
|
|
5253
|
+
);
|
|
5254
|
+
}
|
|
5255
|
+
return directories;
|
|
5256
|
+
}
|
|
5257
|
+
function meetsThreshold(state, level) {
|
|
5258
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
5259
|
+
}
|
|
5260
|
+
function log2(state, message, level = "info") {
|
|
5261
|
+
if (!meetsThreshold(state, level)) return;
|
|
4004
5262
|
if (state.json) {
|
|
4005
5263
|
console.log(
|
|
4006
5264
|
JSON.stringify({
|
|
4007
5265
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4008
|
-
level
|
|
5266
|
+
level,
|
|
4009
5267
|
message
|
|
4010
5268
|
})
|
|
4011
5269
|
);
|
|
4012
5270
|
} else if (!state.interactive) {
|
|
4013
|
-
const prefix =
|
|
5271
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
4014
5272
|
console.log(`${prefix} ${message}`);
|
|
4015
5273
|
}
|
|
4016
5274
|
}
|
|
4017
5275
|
function logActivity(state, entry) {
|
|
5276
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
5277
|
+
if (!meetsThreshold(state, level)) return;
|
|
4018
5278
|
const fullEntry = {
|
|
4019
5279
|
...entry,
|
|
5280
|
+
level,
|
|
4020
5281
|
timestamp: /* @__PURE__ */ new Date()
|
|
4021
5282
|
};
|
|
4022
5283
|
state.activityLog.push(fullEntry);
|
|
@@ -4025,9 +5286,9 @@ function logActivity(state, entry) {
|
|
|
4025
5286
|
}
|
|
4026
5287
|
if (!state.interactive) {
|
|
4027
5288
|
if (entry.type === "error") {
|
|
4028
|
-
log2(state, entry.error ?? "Unknown error",
|
|
4029
|
-
} else if (entry.
|
|
4030
|
-
log2(state, entry.message);
|
|
5289
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
5290
|
+
} else if (entry.message) {
|
|
5291
|
+
log2(state, entry.message, level);
|
|
4031
5292
|
}
|
|
4032
5293
|
}
|
|
4033
5294
|
}
|
|
@@ -4114,18 +5375,29 @@ async function handleAuthError(state, error2) {
|
|
|
4114
5375
|
async function driveChannels(state, driver) {
|
|
4115
5376
|
let idlePolls = 0;
|
|
4116
5377
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5378
|
+
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
4117
5379
|
while (state.running) {
|
|
4118
5380
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
4119
5381
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
4120
5382
|
if (state.interactive) displayStatus(state);
|
|
4121
5383
|
await state.connection.reconnectPromise;
|
|
4122
5384
|
}
|
|
5385
|
+
const carriedOverFileSync = driver.fileSyncActivity().inFlight;
|
|
5386
|
+
void driver.syncPendingFiles().catch(
|
|
5387
|
+
(error2) => logActivity(state, {
|
|
5388
|
+
type: "error",
|
|
5389
|
+
error: `Runner file sync failed: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
5390
|
+
})
|
|
5391
|
+
);
|
|
4123
5392
|
try {
|
|
4124
5393
|
const processed = await driver.drainPending();
|
|
4125
5394
|
state.messageCount += processed;
|
|
4126
5395
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
4127
5396
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
4128
|
-
|
|
5397
|
+
const appliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5398
|
+
const fileActivity = carriedOverFileSync || appliedFiles !== lastSeenAppliedFiles;
|
|
5399
|
+
lastSeenAppliedFiles = appliedFiles;
|
|
5400
|
+
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
|
|
4129
5401
|
idlePolls = 0;
|
|
4130
5402
|
if (processed > 0 && state.interactive) displayStatus(state);
|
|
4131
5403
|
} else if (state.idleTimeout !== null) {
|
|
@@ -4154,7 +5426,7 @@ async function driveChannels(state, driver) {
|
|
|
4154
5426
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
4155
5427
|
if (state.interactive) displayStatus(state);
|
|
4156
5428
|
}
|
|
4157
|
-
await new Promise((
|
|
5429
|
+
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
4158
5430
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
4159
5431
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
4160
5432
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -4226,7 +5498,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4226
5498
|
process.env
|
|
4227
5499
|
);
|
|
4228
5500
|
for (const warning2 of config2.warnings) {
|
|
4229
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
5501
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4230
5502
|
}
|
|
4231
5503
|
if (!config2.enabled) return;
|
|
4232
5504
|
logActivity(state, {
|
|
@@ -4248,7 +5520,7 @@ async function notifyOffline(state) {
|
|
|
4248
5520
|
}
|
|
4249
5521
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4250
5522
|
if (result.ok) {
|
|
4251
|
-
log2(state, "Notified Evident the
|
|
5523
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
4252
5524
|
} else {
|
|
4253
5525
|
logActivity(state, {
|
|
4254
5526
|
type: "error",
|
|
@@ -4257,7 +5529,18 @@ async function notifyOffline(state) {
|
|
|
4257
5529
|
if (state.interactive) displayStatus(state);
|
|
4258
5530
|
}
|
|
4259
5531
|
}
|
|
5532
|
+
async function timeShutdownPhase(state, durations, name, run2) {
|
|
5533
|
+
const startedAt = Date.now();
|
|
5534
|
+
try {
|
|
5535
|
+
return await run2();
|
|
5536
|
+
} finally {
|
|
5537
|
+
const elapsedMs = Date.now() - startedAt;
|
|
5538
|
+
durations[name] = elapsedMs;
|
|
5539
|
+
log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
4260
5542
|
async function cleanup(state, opts = {}) {
|
|
5543
|
+
const durations = {};
|
|
4261
5544
|
state.running = false;
|
|
4262
5545
|
for (const timer of state.sessionCleanupTimers) {
|
|
4263
5546
|
clearInterval(timer);
|
|
@@ -4271,7 +5554,13 @@ async function cleanup(state, opts = {}) {
|
|
|
4271
5554
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
4272
5555
|
displayStatus(state);
|
|
4273
5556
|
}
|
|
4274
|
-
const
|
|
5557
|
+
const driver = state.channelDriver;
|
|
5558
|
+
const settled = await timeShutdownPhase(
|
|
5559
|
+
state,
|
|
5560
|
+
durations,
|
|
5561
|
+
"drain",
|
|
5562
|
+
() => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
5563
|
+
);
|
|
4275
5564
|
if (!settled) {
|
|
4276
5565
|
logActivity(state, {
|
|
4277
5566
|
type: "info",
|
|
@@ -4280,13 +5569,15 @@ async function cleanup(state, opts = {}) {
|
|
|
4280
5569
|
if (state.interactive) displayStatus(state);
|
|
4281
5570
|
}
|
|
4282
5571
|
}
|
|
4283
|
-
await notifyOffline(state);
|
|
5572
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
4284
5573
|
if (state.connection) {
|
|
4285
|
-
state.connection
|
|
5574
|
+
const connection = state.connection;
|
|
5575
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
4286
5576
|
state.connection = null;
|
|
4287
5577
|
}
|
|
4288
5578
|
if (state.opencodeProcess) {
|
|
4289
|
-
|
|
5579
|
+
const opencodeProcess = state.opencodeProcess;
|
|
5580
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
4290
5581
|
if (state.interactive) {
|
|
4291
5582
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
4292
5583
|
displayStatus(state);
|
|
@@ -4295,17 +5586,35 @@ async function cleanup(state, opts = {}) {
|
|
|
4295
5586
|
}
|
|
4296
5587
|
state.opencodeProcess = null;
|
|
4297
5588
|
}
|
|
5589
|
+
return durations;
|
|
4298
5590
|
}
|
|
4299
5591
|
async function run(options) {
|
|
4300
5592
|
const interactive = isInteractive(options.json);
|
|
5593
|
+
let logLevel;
|
|
5594
|
+
let fileSyncDirectories;
|
|
5595
|
+
try {
|
|
5596
|
+
logLevel = resolveLogLevel(options);
|
|
5597
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
|
|
5598
|
+
} catch (error2) {
|
|
5599
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
5600
|
+
if (options.json) {
|
|
5601
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
5602
|
+
} else {
|
|
5603
|
+
printError(message);
|
|
5604
|
+
}
|
|
5605
|
+
await shutdownTelemetry();
|
|
5606
|
+
process.exit(1);
|
|
5607
|
+
return;
|
|
5608
|
+
}
|
|
4301
5609
|
const state = {
|
|
4302
|
-
agentId: options.agent || "",
|
|
5610
|
+
agentId: options.runner || options.agent || "",
|
|
4303
5611
|
agentName: null,
|
|
4304
5612
|
port: options.port ?? 4096,
|
|
4305
5613
|
conversationFilter: options.conversation ?? null,
|
|
4306
5614
|
idleTimeout: options.idleTimeout ?? null,
|
|
4307
5615
|
json: options.json ?? false,
|
|
4308
5616
|
interactive,
|
|
5617
|
+
logLevel,
|
|
4309
5618
|
connected: false,
|
|
4310
5619
|
opencodeConnected: false,
|
|
4311
5620
|
opencodeVersion: null,
|
|
@@ -4320,24 +5629,66 @@ async function run(options) {
|
|
|
4320
5629
|
sessionCleanupTimers: [],
|
|
4321
5630
|
authHeader: ""
|
|
4322
5631
|
};
|
|
5632
|
+
if (fileSyncDirectories.length > 0) {
|
|
5633
|
+
log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
|
|
5634
|
+
} else {
|
|
5635
|
+
log2(state, "File sync is disabled (no --enable-file-sync-to given)", "debug");
|
|
5636
|
+
}
|
|
5637
|
+
if (!options.runner && options.agent) {
|
|
5638
|
+
telemetry.info(
|
|
5639
|
+
EventTypes.DEPRECATED_AGENT_FLAG_USED,
|
|
5640
|
+
"Deprecated --agent flag used instead of --runner",
|
|
5641
|
+
{ command: "run" },
|
|
5642
|
+
state.agentId
|
|
5643
|
+
);
|
|
5644
|
+
const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
|
|
5645
|
+
log2(state, agentFlagNotice, "warn");
|
|
5646
|
+
if (state.interactive && !state.json) {
|
|
5647
|
+
logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
|
|
5648
|
+
}
|
|
5649
|
+
}
|
|
4323
5650
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4324
5651
|
log2(
|
|
4325
5652
|
state,
|
|
4326
|
-
"
|
|
4327
|
-
|
|
5653
|
+
"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.",
|
|
5654
|
+
"warn"
|
|
4328
5655
|
);
|
|
4329
5656
|
}
|
|
4330
5657
|
const handleSignal = async () => {
|
|
4331
5658
|
if (state.shuttingDown) return;
|
|
4332
5659
|
state.shuttingDown = true;
|
|
5660
|
+
const shutdownStartedAt = Date.now();
|
|
4333
5661
|
if (state.interactive) {
|
|
4334
5662
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
4335
5663
|
displayStatus(state);
|
|
4336
5664
|
} else {
|
|
4337
5665
|
log2(state, "Shutting down...");
|
|
4338
5666
|
}
|
|
4339
|
-
await cleanup(state, { graceful: true });
|
|
4340
|
-
|
|
5667
|
+
const durations = await cleanup(state, { graceful: true });
|
|
5668
|
+
const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
|
|
5669
|
+
await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
|
|
5670
|
+
let timer;
|
|
5671
|
+
const flushed = shutdownTelemetry().then(
|
|
5672
|
+
() => true,
|
|
5673
|
+
(error2) => {
|
|
5674
|
+
log2(
|
|
5675
|
+
state,
|
|
5676
|
+
`Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
5677
|
+
"warn"
|
|
5678
|
+
);
|
|
5679
|
+
return true;
|
|
5680
|
+
}
|
|
5681
|
+
);
|
|
5682
|
+
const timedOut = new Promise((resolve3) => {
|
|
5683
|
+
timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
|
|
5684
|
+
});
|
|
5685
|
+
if (!await Promise.race([flushed, timedOut])) {
|
|
5686
|
+
log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
|
|
5687
|
+
}
|
|
5688
|
+
clearTimeout(timer);
|
|
5689
|
+
});
|
|
5690
|
+
const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
|
|
5691
|
+
log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
|
|
4341
5692
|
process.exit(0);
|
|
4342
5693
|
};
|
|
4343
5694
|
process.on("SIGINT", handleSignal);
|
|
@@ -4348,7 +5699,9 @@ async function run(options) {
|
|
|
4348
5699
|
if (!interactive) {
|
|
4349
5700
|
printError("Authentication required");
|
|
4350
5701
|
blank();
|
|
4351
|
-
console.log(
|
|
5702
|
+
console.log(
|
|
5703
|
+
chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
|
|
5704
|
+
);
|
|
4352
5705
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
4353
5706
|
blank();
|
|
4354
5707
|
process.exit(1);
|
|
@@ -4362,26 +5715,51 @@ async function run(options) {
|
|
|
4362
5715
|
);
|
|
4363
5716
|
}
|
|
4364
5717
|
state.authHeader = getAuthHeader(credentials2);
|
|
5718
|
+
if (credentials2.notice) {
|
|
5719
|
+
log2(state, credentials2.notice, "warn");
|
|
5720
|
+
if (state.interactive && !state.json) {
|
|
5721
|
+
logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5724
|
+
if (credentials2.keySource === "agent_key") {
|
|
5725
|
+
telemetry.info(
|
|
5726
|
+
EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
|
|
5727
|
+
"Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
|
|
5728
|
+
{ command: "run" },
|
|
5729
|
+
state.agentId
|
|
5730
|
+
);
|
|
5731
|
+
const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
|
|
5732
|
+
log2(state, agentKeyNotice, "warn");
|
|
5733
|
+
if (state.interactive && !state.json) {
|
|
5734
|
+
logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
|
|
5735
|
+
}
|
|
5736
|
+
}
|
|
4365
5737
|
if (!state.agentId) {
|
|
4366
5738
|
if (credentials2.authType === "agent_key") {
|
|
4367
5739
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
4368
5740
|
if (resolved.agent_id) {
|
|
4369
5741
|
state.agentId = resolved.agent_id;
|
|
4370
|
-
log2(state, `Resolved
|
|
5742
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
4371
5743
|
if (state.interactive && !state.json) {
|
|
4372
5744
|
logActivity(state, {
|
|
4373
5745
|
type: "info",
|
|
4374
|
-
message: `
|
|
5746
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
4375
5747
|
});
|
|
4376
5748
|
}
|
|
4377
5749
|
} else {
|
|
4378
|
-
printError(resolved.error || "Failed to resolve
|
|
5750
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
4379
5751
|
process.exit(1);
|
|
4380
5752
|
}
|
|
4381
5753
|
} else {
|
|
4382
|
-
printError(
|
|
5754
|
+
printError(
|
|
5755
|
+
"--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
|
|
5756
|
+
);
|
|
4383
5757
|
blank();
|
|
4384
|
-
console.log(
|
|
5758
|
+
console.log(
|
|
5759
|
+
chalk6.dim(
|
|
5760
|
+
"Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
|
|
5761
|
+
)
|
|
5762
|
+
);
|
|
4385
5763
|
blank();
|
|
4386
5764
|
process.exit(1);
|
|
4387
5765
|
}
|
|
@@ -4403,7 +5781,7 @@ async function run(options) {
|
|
|
4403
5781
|
console.log(chalk6.bold("Evident Run"));
|
|
4404
5782
|
console.log(chalk6.dim("-".repeat(40)));
|
|
4405
5783
|
}
|
|
4406
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
5784
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
4407
5785
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4408
5786
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
4409
5787
|
spinner?.fail("Authentication failed");
|
|
@@ -4415,15 +5793,30 @@ async function run(options) {
|
|
|
4415
5793
|
"Login successful! Retrying..."
|
|
4416
5794
|
);
|
|
4417
5795
|
state.authHeader = getAuthHeader(credentials2);
|
|
4418
|
-
spinner?.start("Validating
|
|
5796
|
+
spinner?.start("Validating runner...");
|
|
4419
5797
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4420
5798
|
}
|
|
4421
5799
|
if (!validation.valid) {
|
|
4422
|
-
spinner?.fail(`
|
|
5800
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
4423
5801
|
throw new Error(validation.error);
|
|
4424
5802
|
}
|
|
4425
|
-
spinner?.succeed(`
|
|
5803
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
4426
5804
|
state.agentName = validation.agent.name;
|
|
5805
|
+
const microvmId = process.env.MICROVM_ID?.trim();
|
|
5806
|
+
if (microvmId) {
|
|
5807
|
+
const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
|
|
5808
|
+
if (reported.ok) {
|
|
5809
|
+
log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
|
|
5810
|
+
} else {
|
|
5811
|
+
const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
|
|
5812
|
+
log2(state, message, "warn");
|
|
5813
|
+
if (state.interactive && !state.json) {
|
|
5814
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
} else {
|
|
5818
|
+
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
5819
|
+
}
|
|
4427
5820
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
4428
5821
|
try {
|
|
4429
5822
|
const oc = await ensureOpenCodeRunning({
|
|
@@ -4440,9 +5833,24 @@ async function run(options) {
|
|
|
4440
5833
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4441
5834
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4442
5835
|
if (versionWarning) {
|
|
4443
|
-
log2(state, versionWarning,
|
|
5836
|
+
log2(state, versionWarning, "warn");
|
|
4444
5837
|
if (state.interactive && !state.json) {
|
|
4445
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
5838
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
|
|
5842
|
+
if (noProviderWarning) {
|
|
5843
|
+
log2(state, noProviderWarning, "warn");
|
|
5844
|
+
if (state.interactive && !state.json) {
|
|
5845
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
5846
|
+
blank();
|
|
5847
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
5848
|
+
console.log(
|
|
5849
|
+
chalk6.dim(
|
|
5850
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
5851
|
+
)
|
|
5852
|
+
);
|
|
5853
|
+
blank();
|
|
4446
5854
|
}
|
|
4447
5855
|
}
|
|
4448
5856
|
} catch (error2) {
|
|
@@ -4457,11 +5865,21 @@ async function run(options) {
|
|
|
4457
5865
|
getAuthHeader: () => state.authHeader,
|
|
4458
5866
|
conversationFilter: state.conversationFilter,
|
|
4459
5867
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
5868
|
+
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
5869
|
+
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
5870
|
+
fileSyncDirectories,
|
|
5871
|
+
homeDir: homedir2(),
|
|
5872
|
+
log: (entry) => (
|
|
5873
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
5874
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
5875
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
5876
|
+
logActivity(state, {
|
|
5877
|
+
type: entry.level === "error" ? "error" : "info",
|
|
5878
|
+
level: entry.level,
|
|
5879
|
+
message: entry.message,
|
|
5880
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
5881
|
+
})
|
|
5882
|
+
)
|
|
4465
5883
|
});
|
|
4466
5884
|
state.channelDriver = channelDriver;
|
|
4467
5885
|
const connection = new RunnerConnection({
|
|
@@ -4475,7 +5893,7 @@ async function run(options) {
|
|
|
4475
5893
|
state.agentId = agentId;
|
|
4476
5894
|
logActivity(state, {
|
|
4477
5895
|
type: "info",
|
|
4478
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
5896
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
4479
5897
|
});
|
|
4480
5898
|
emitAgentConnected(state.agentId, {
|
|
4481
5899
|
port: state.port,
|
|
@@ -4532,6 +5950,12 @@ async function run(options) {
|
|
|
4532
5950
|
onDrainPing: () => {
|
|
4533
5951
|
if (!state.running) return;
|
|
4534
5952
|
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
5953
|
+
void channelDriver.syncPendingFiles().catch(
|
|
5954
|
+
(error2) => logActivity(state, {
|
|
5955
|
+
type: "error",
|
|
5956
|
+
error: `Runner file sync failed on ping: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
5957
|
+
})
|
|
5958
|
+
);
|
|
4535
5959
|
channelDriver.drainPending().then((processed) => {
|
|
4536
5960
|
if (processed > 0) {
|
|
4537
5961
|
state.messageCount += processed;
|
|
@@ -4590,7 +6014,7 @@ async function run(options) {
|
|
|
4590
6014
|
}
|
|
4591
6015
|
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
4592
6016
|
command: "run",
|
|
4593
|
-
agentId: options.agent
|
|
6017
|
+
agentId: options.runner || options.agent
|
|
4594
6018
|
});
|
|
4595
6019
|
await shutdownTelemetry();
|
|
4596
6020
|
process.exit(1);
|
|
@@ -4615,7 +6039,13 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4615
6039
|
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
6040
|
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
6041
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4618
|
-
program.command("run").description("Connect to Evident and process messages").option("
|
|
6042
|
+
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(
|
|
6043
|
+
"-a, --agent [id]",
|
|
6044
|
+
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
6045
|
+
).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
6046
|
+
"--log-level <level>",
|
|
6047
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
6048
|
+
).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
6049
|
"--session-cleanup-max-age <duration>",
|
|
4620
6050
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4621
6051
|
).option(
|
|
@@ -4624,11 +6054,20 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4624
6054
|
).option(
|
|
4625
6055
|
"--session-cleanup-interval <duration>",
|
|
4626
6056
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
6057
|
+
).option(
|
|
6058
|
+
"--enable-file-sync-to <dir>",
|
|
6059
|
+
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
6060
|
+
(value, previous) => previous.concat([value]),
|
|
6061
|
+
[]
|
|
4627
6062
|
).action(
|
|
4628
6063
|
(options) => {
|
|
4629
6064
|
run({
|
|
4630
6065
|
agent: options.agent,
|
|
6066
|
+
runner: options.runner,
|
|
4631
6067
|
port: parseInt(options.port, 10),
|
|
6068
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
6069
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
6070
|
+
logLevel: options.logLevel,
|
|
4632
6071
|
verbose: options.verbose,
|
|
4633
6072
|
conversation: options.conversation,
|
|
4634
6073
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
@@ -4636,7 +6075,10 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4636
6075
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4637
6076
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4638
6077
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4639
|
-
sessionCleanupInterval: options.sessionCleanupInterval
|
|
6078
|
+
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
6079
|
+
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
6080
|
+
// resolveFileSyncDirectories.
|
|
6081
|
+
enableFileSyncTo: options.enableFileSyncTo
|
|
4640
6082
|
});
|
|
4641
6083
|
}
|
|
4642
6084
|
);
|