@evident-ai/cli 3.0.1-dev.fffc02d → 3.1.1-dev.186f6ef
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 +25 -17
- package/dist/index.js +1265 -157
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -285,14 +285,14 @@ function blank() {
|
|
|
285
285
|
console.log();
|
|
286
286
|
}
|
|
287
287
|
function waitForEnter(prompt = "Press Enter to continue...") {
|
|
288
|
-
return new Promise((
|
|
288
|
+
return new Promise((resolve2) => {
|
|
289
289
|
process.stdout.write(chalk.dim(prompt));
|
|
290
290
|
const handler = () => {
|
|
291
291
|
process.stdin.removeListener("data", handler);
|
|
292
292
|
process.stdin.setRawMode?.(false);
|
|
293
293
|
process.stdin.pause();
|
|
294
294
|
console.log();
|
|
295
|
-
|
|
295
|
+
resolve2();
|
|
296
296
|
};
|
|
297
297
|
if (process.stdin.isTTY) {
|
|
298
298
|
process.stdin.setRawMode?.(true);
|
|
@@ -302,7 +302,7 @@ function waitForEnter(prompt = "Press Enter to continue...") {
|
|
|
302
302
|
});
|
|
303
303
|
}
|
|
304
304
|
function sleep(ms) {
|
|
305
|
-
return new Promise((
|
|
305
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
// src/commands/login.ts
|
|
@@ -376,19 +376,19 @@ async function tokenLogin() {
|
|
|
376
376
|
console.log("Visit your Evident dashboard to generate a CLI token.");
|
|
377
377
|
blank();
|
|
378
378
|
process.stdout.write("Paste token: ");
|
|
379
|
-
const token = await new Promise((
|
|
379
|
+
const token = await new Promise((resolve2) => {
|
|
380
380
|
let data = "";
|
|
381
381
|
process.stdin.setEncoding("utf8");
|
|
382
382
|
process.stdin.on("data", (chunk) => {
|
|
383
383
|
data += chunk;
|
|
384
384
|
});
|
|
385
385
|
process.stdin.on("end", () => {
|
|
386
|
-
|
|
386
|
+
resolve2(data.trim());
|
|
387
387
|
});
|
|
388
388
|
if (process.stdin.isTTY) {
|
|
389
389
|
process.stdin.once("data", (chunk) => {
|
|
390
390
|
process.stdin.pause();
|
|
391
|
-
|
|
391
|
+
resolve2(chunk.toString().trim());
|
|
392
392
|
});
|
|
393
393
|
process.stdin.resume();
|
|
394
394
|
}
|
|
@@ -645,14 +645,27 @@ var EventTypes = {
|
|
|
645
645
|
// CLI lifecycle
|
|
646
646
|
CLI_STARTED: "cli.started",
|
|
647
647
|
CLI_COMMAND: "cli.command",
|
|
648
|
-
CLI_ERROR: "cli.error"
|
|
648
|
+
CLI_ERROR: "cli.error",
|
|
649
|
+
// Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
|
|
650
|
+
// names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
|
|
651
|
+
DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
|
|
652
|
+
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
649
653
|
};
|
|
650
654
|
|
|
651
655
|
// src/lib/auth.ts
|
|
652
656
|
async function getAuthCredentials() {
|
|
657
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
653
658
|
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
659
|
+
if (runnerKey) {
|
|
660
|
+
return {
|
|
661
|
+
token: runnerKey,
|
|
662
|
+
authType: "agent_key",
|
|
663
|
+
keySource: "runner_key",
|
|
664
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
665
|
+
};
|
|
666
|
+
}
|
|
654
667
|
if (agentKey) {
|
|
655
|
-
return { token: agentKey, authType: "agent_key" };
|
|
668
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
656
669
|
}
|
|
657
670
|
const userToken = process.env.EVIDENT_TOKEN;
|
|
658
671
|
if (userToken) {
|
|
@@ -706,7 +719,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
706
719
|
if (health.healthy) {
|
|
707
720
|
return health;
|
|
708
721
|
}
|
|
709
|
-
await new Promise((
|
|
722
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
710
723
|
}
|
|
711
724
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
712
725
|
}
|
|
@@ -1078,6 +1091,84 @@ async function getSessionMessages(port, sessionId) {
|
|
|
1078
1091
|
return null;
|
|
1079
1092
|
}
|
|
1080
1093
|
}
|
|
1094
|
+
function isSessionActivelyGenerating(messages) {
|
|
1095
|
+
if (!messages || messages.length === 0) return false;
|
|
1096
|
+
const last = messages[messages.length - 1];
|
|
1097
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1098
|
+
return completedOf(last) == null;
|
|
1099
|
+
}
|
|
1100
|
+
function sessionLastActivityMs(session) {
|
|
1101
|
+
const candidates = [
|
|
1102
|
+
session.time?.updated,
|
|
1103
|
+
session.time?.created,
|
|
1104
|
+
session.time_updated,
|
|
1105
|
+
session.time_created,
|
|
1106
|
+
session.updated,
|
|
1107
|
+
session.created
|
|
1108
|
+
];
|
|
1109
|
+
for (const c of candidates) {
|
|
1110
|
+
if (typeof c === "number" && Number.isFinite(c)) return c;
|
|
1111
|
+
}
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
async function listSessions(port) {
|
|
1115
|
+
try {
|
|
1116
|
+
const res = await fetch(`${opencodeBase(port)}/session`);
|
|
1117
|
+
if (!res.ok) return null;
|
|
1118
|
+
const body = await res.json();
|
|
1119
|
+
return Array.isArray(body) ? body : null;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async function deleteSession(port, id) {
|
|
1125
|
+
try {
|
|
1126
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
|
|
1127
|
+
return res.status >= 200 && res.status < 300;
|
|
1128
|
+
} catch {
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
async function sessionExists(port, id) {
|
|
1133
|
+
try {
|
|
1134
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`);
|
|
1135
|
+
if (res.status >= 200 && res.status < 300) return true;
|
|
1136
|
+
if (res.status === 404) return false;
|
|
1137
|
+
return null;
|
|
1138
|
+
} catch {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async function getSessionStatuses(port) {
|
|
1143
|
+
try {
|
|
1144
|
+
const res = await fetch(`${opencodeBase(port)}/session/status`);
|
|
1145
|
+
if (!res.ok) {
|
|
1146
|
+
console.error(
|
|
1147
|
+
`[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
|
|
1148
|
+
);
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
const body = await res.json();
|
|
1152
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
1153
|
+
console.error(
|
|
1154
|
+
`[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
|
|
1155
|
+
);
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
return body;
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
console.error(
|
|
1161
|
+
`[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1162
|
+
);
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function isSessionOngoing(port, id) {
|
|
1167
|
+
const map = await getSessionStatuses(port);
|
|
1168
|
+
if (map == null) return null;
|
|
1169
|
+
const entry = map[id];
|
|
1170
|
+
return entry != null && entry.type !== "idle";
|
|
1171
|
+
}
|
|
1081
1172
|
async function createOpenCodeSession(port, directory) {
|
|
1082
1173
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1083
1174
|
if (directory && directory.trim()) {
|
|
@@ -1095,17 +1186,113 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1095
1186
|
const data = await response.json();
|
|
1096
1187
|
return data.id;
|
|
1097
1188
|
}
|
|
1189
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1190
|
+
try {
|
|
1191
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1192
|
+
if (!res.ok) {
|
|
1193
|
+
console.error(
|
|
1194
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1195
|
+
);
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
const body = await res.json();
|
|
1199
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1200
|
+
if (!providers) {
|
|
1201
|
+
console.error(
|
|
1202
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1203
|
+
);
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1207
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1208
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1209
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1210
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1211
|
+
if (!provider && !providerId) {
|
|
1212
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1213
|
+
if (defaultProviderIds.length === 1) {
|
|
1214
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (!provider || !provider.models) return null;
|
|
1218
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1219
|
+
const def = defaults2[provider.id];
|
|
1220
|
+
if (typeof def === "string") modelId = def;
|
|
1221
|
+
}
|
|
1222
|
+
if (!modelId) {
|
|
1223
|
+
if (providerId) {
|
|
1224
|
+
const keys = Object.keys(provider.models);
|
|
1225
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1226
|
+
}
|
|
1227
|
+
if (!modelId) return null;
|
|
1228
|
+
}
|
|
1229
|
+
const entry = provider.models[modelId];
|
|
1230
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1231
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
console.error(
|
|
1234
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1235
|
+
);
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
async function buildFileParts(attachments, capable) {
|
|
1240
|
+
const outcomes = [];
|
|
1241
|
+
const parts = [];
|
|
1242
|
+
const capabilityUnknown = capable === null;
|
|
1243
|
+
if (capable !== true) {
|
|
1244
|
+
for (const a of attachments.inputs) {
|
|
1245
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1246
|
+
}
|
|
1247
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1248
|
+
}
|
|
1249
|
+
for (const a of attachments.inputs) {
|
|
1250
|
+
let dataUrl = null;
|
|
1251
|
+
try {
|
|
1252
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
console.error(
|
|
1255
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1256
|
+
);
|
|
1257
|
+
dataUrl = null;
|
|
1258
|
+
}
|
|
1259
|
+
if (dataUrl == null) {
|
|
1260
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
parts.push({
|
|
1264
|
+
type: "file",
|
|
1265
|
+
mime: a.mime,
|
|
1266
|
+
url: dataUrl,
|
|
1267
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1268
|
+
});
|
|
1269
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1270
|
+
}
|
|
1271
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1272
|
+
}
|
|
1098
1273
|
function messageText(m) {
|
|
1099
1274
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1100
1275
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1101
1276
|
}
|
|
1102
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1277
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1103
1278
|
const before = await getSessionMessages(port, sessionId);
|
|
1104
1279
|
const knownUserIds = new Set(
|
|
1105
1280
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1106
1281
|
);
|
|
1282
|
+
const parts = [{ type: "text", text: content }];
|
|
1283
|
+
let pendingOutcomes = null;
|
|
1284
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1285
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1286
|
+
const {
|
|
1287
|
+
parts: fileParts,
|
|
1288
|
+
outcomes,
|
|
1289
|
+
capabilityUnknown
|
|
1290
|
+
} = await buildFileParts(attachments, capable);
|
|
1291
|
+
parts.push(...fileParts);
|
|
1292
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1293
|
+
}
|
|
1107
1294
|
const body = {
|
|
1108
|
-
parts
|
|
1295
|
+
parts
|
|
1109
1296
|
};
|
|
1110
1297
|
if (options?.agent) {
|
|
1111
1298
|
body.agent = options.agent;
|
|
@@ -1144,10 +1331,13 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1144
1331
|
best = { id, created };
|
|
1145
1332
|
}
|
|
1146
1333
|
}
|
|
1147
|
-
if (best)
|
|
1334
|
+
if (best) {
|
|
1335
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1336
|
+
return best.id;
|
|
1337
|
+
}
|
|
1148
1338
|
}
|
|
1149
1339
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1150
|
-
await new Promise((
|
|
1340
|
+
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
1151
1341
|
}
|
|
1152
1342
|
}
|
|
1153
1343
|
return null;
|
|
@@ -1193,6 +1383,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
|
|
|
1193
1383
|
}
|
|
1194
1384
|
return lastOk ?? last;
|
|
1195
1385
|
}
|
|
1386
|
+
function messageUsage(messages, userMessageId) {
|
|
1387
|
+
if (!messages || messages.length === 0) return null;
|
|
1388
|
+
const byParentAll = messages.filter(
|
|
1389
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1390
|
+
);
|
|
1391
|
+
const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
|
|
1392
|
+
const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
|
|
1393
|
+
let correlated;
|
|
1394
|
+
if (byParent.length > 0) {
|
|
1395
|
+
correlated = byParent;
|
|
1396
|
+
} else {
|
|
1397
|
+
const reply = findAssistantReplyAfter(messages, userMessageId);
|
|
1398
|
+
correlated = reply ? [reply] : [];
|
|
1399
|
+
}
|
|
1400
|
+
if (correlated.length === 0) return null;
|
|
1401
|
+
let sawAnyUsage = false;
|
|
1402
|
+
let inputSum = 0;
|
|
1403
|
+
let outputSum = 0;
|
|
1404
|
+
let reasoningSum = 0;
|
|
1405
|
+
let cacheReadSum = 0;
|
|
1406
|
+
let cacheWriteSum = 0;
|
|
1407
|
+
let costSum = 0;
|
|
1408
|
+
let sawCost = false;
|
|
1409
|
+
let modelId = null;
|
|
1410
|
+
let providerId = null;
|
|
1411
|
+
for (const m of correlated) {
|
|
1412
|
+
const info = m.info;
|
|
1413
|
+
if (!info) continue;
|
|
1414
|
+
const tokens = info.tokens;
|
|
1415
|
+
if (tokens) {
|
|
1416
|
+
sawAnyUsage = true;
|
|
1417
|
+
inputSum += tokens.input ?? 0;
|
|
1418
|
+
outputSum += tokens.output ?? 0;
|
|
1419
|
+
reasoningSum += tokens.reasoning ?? 0;
|
|
1420
|
+
cacheReadSum += tokens.cache?.read ?? 0;
|
|
1421
|
+
cacheWriteSum += tokens.cache?.write ?? 0;
|
|
1422
|
+
}
|
|
1423
|
+
if (typeof info.cost === "number") {
|
|
1424
|
+
sawAnyUsage = true;
|
|
1425
|
+
sawCost = true;
|
|
1426
|
+
costSum += info.cost;
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof info.modelID === "string") {
|
|
1429
|
+
sawAnyUsage = true;
|
|
1430
|
+
modelId = info.modelID;
|
|
1431
|
+
}
|
|
1432
|
+
if (typeof info.providerID === "string") {
|
|
1433
|
+
sawAnyUsage = true;
|
|
1434
|
+
providerId = info.providerID;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (!sawAnyUsage) return null;
|
|
1438
|
+
return {
|
|
1439
|
+
usage_provider_id: providerId,
|
|
1440
|
+
usage_model_id: modelId,
|
|
1441
|
+
usage_tokens_input: inputSum,
|
|
1442
|
+
usage_tokens_output: outputSum,
|
|
1443
|
+
usage_tokens_reasoning: reasoningSum,
|
|
1444
|
+
usage_tokens_cache_read: cacheReadSum,
|
|
1445
|
+
usage_tokens_cache_write: cacheWriteSum,
|
|
1446
|
+
// NULL means "OpenCode never reported a cost" (never inferred from
|
|
1447
|
+
// tokens) — distinct from a genuine 0-cost turn, which would set
|
|
1448
|
+
// `sawCost` true with `costSum === 0`.
|
|
1449
|
+
usage_cost_usd: sawCost ? costSum : null
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1196
1452
|
function messageRunState(messages, userMessageId) {
|
|
1197
1453
|
if (!messages || messages.length === 0) return "unknown";
|
|
1198
1454
|
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
@@ -1204,6 +1460,11 @@ function messageRunState(messages, userMessageId) {
|
|
|
1204
1460
|
if (isAssistantInFlight(reply)) return "running";
|
|
1205
1461
|
return errorOf(reply) != null ? "failed" : "done";
|
|
1206
1462
|
}
|
|
1463
|
+
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
1464
|
+
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
1465
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1466
|
+
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1467
|
+
}
|
|
1207
1468
|
function messageError(messages, userMessageId) {
|
|
1208
1469
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1209
1470
|
const error2 = errorOf(reply);
|
|
@@ -1224,6 +1485,110 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1224
1485
|
);
|
|
1225
1486
|
}
|
|
1226
1487
|
|
|
1488
|
+
// src/lib/opencode/session-cleanup.ts
|
|
1489
|
+
var DURATION_UNIT_MS = {
|
|
1490
|
+
s: 1e3,
|
|
1491
|
+
m: 60 * 1e3,
|
|
1492
|
+
h: 60 * 60 * 1e3,
|
|
1493
|
+
d: 24 * 60 * 60 * 1e3
|
|
1494
|
+
};
|
|
1495
|
+
function parseDurationMs(input) {
|
|
1496
|
+
const trimmed = input.trim();
|
|
1497
|
+
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
|
1498
|
+
if (!match) {
|
|
1499
|
+
throw new Error(
|
|
1500
|
+
`Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
const value = Number(match[1]);
|
|
1504
|
+
if (value <= 0) {
|
|
1505
|
+
throw new Error(`Invalid duration "${input}": must be a positive value.`);
|
|
1506
|
+
}
|
|
1507
|
+
return value * DURATION_UNIT_MS[match[2]];
|
|
1508
|
+
}
|
|
1509
|
+
function selectSessionsToDelete(sessions, opts) {
|
|
1510
|
+
const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
|
|
1511
|
+
if (maxAgeMs === void 0 && maxCount === void 0) return [];
|
|
1512
|
+
const ageEligible = (s) => {
|
|
1513
|
+
if (maxAgeMs === void 0) return false;
|
|
1514
|
+
if (s.lastActivityMs === null) return true;
|
|
1515
|
+
return nowMs - s.lastActivityMs > maxAgeMs;
|
|
1516
|
+
};
|
|
1517
|
+
const countEligibleIds = /* @__PURE__ */ new Set();
|
|
1518
|
+
if (maxCount !== void 0) {
|
|
1519
|
+
const byActivityDesc = [...sessions].sort(
|
|
1520
|
+
(a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
|
|
1521
|
+
);
|
|
1522
|
+
for (const s of byActivityDesc.slice(maxCount)) {
|
|
1523
|
+
countEligibleIds.add(s.id);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const toDelete = [];
|
|
1527
|
+
for (const s of sessions) {
|
|
1528
|
+
if (protectedIds.has(s.id)) continue;
|
|
1529
|
+
if (ageEligible(s) || countEligibleIds.has(s.id)) {
|
|
1530
|
+
toDelete.push(s.id);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return toDelete;
|
|
1534
|
+
}
|
|
1535
|
+
var DEFAULT_INTERVAL = "1h";
|
|
1536
|
+
function resolve(flag, envValue, fallback) {
|
|
1537
|
+
return flag ?? envValue ?? fallback;
|
|
1538
|
+
}
|
|
1539
|
+
function parseMaxCount(input) {
|
|
1540
|
+
const trimmed = input.trim();
|
|
1541
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1542
|
+
throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
|
|
1543
|
+
}
|
|
1544
|
+
const value = Number(trimmed);
|
|
1545
|
+
if (value <= 0) {
|
|
1546
|
+
throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
|
|
1547
|
+
}
|
|
1548
|
+
return value;
|
|
1549
|
+
}
|
|
1550
|
+
function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
1551
|
+
const warnings = [];
|
|
1552
|
+
const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
|
|
1553
|
+
const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
|
|
1554
|
+
const intervalRaw = resolve(
|
|
1555
|
+
flags.interval,
|
|
1556
|
+
env.EVIDENT_SESSION_CLEANUP_INTERVAL,
|
|
1557
|
+
DEFAULT_INTERVAL
|
|
1558
|
+
);
|
|
1559
|
+
let maxAgeMs;
|
|
1560
|
+
if (maxAgeRaw !== void 0) {
|
|
1561
|
+
try {
|
|
1562
|
+
maxAgeMs = parseDurationMs(maxAgeRaw);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
warnings.push(
|
|
1565
|
+
`Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
let maxCount;
|
|
1570
|
+
if (maxCountRaw !== void 0) {
|
|
1571
|
+
try {
|
|
1572
|
+
maxCount = parseMaxCount(maxCountRaw);
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
warnings.push(
|
|
1575
|
+
`Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
let intervalMs;
|
|
1580
|
+
try {
|
|
1581
|
+
intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
warnings.push(
|
|
1584
|
+
`Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
|
|
1585
|
+
);
|
|
1586
|
+
intervalMs = parseDurationMs(DEFAULT_INTERVAL);
|
|
1587
|
+
}
|
|
1588
|
+
const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
|
|
1589
|
+
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1227
1592
|
// src/lib/tunnel/connection.ts
|
|
1228
1593
|
import WebSocket2 from "ws";
|
|
1229
1594
|
|
|
@@ -1314,12 +1679,12 @@ var StreamForwarder = class {
|
|
|
1314
1679
|
let endBody;
|
|
1315
1680
|
if (has_body) {
|
|
1316
1681
|
const chunks = [];
|
|
1317
|
-
bodyPromise = new Promise((
|
|
1682
|
+
bodyPromise = new Promise((resolve2) => {
|
|
1318
1683
|
pushBody = (buf) => {
|
|
1319
1684
|
chunks.push(buf);
|
|
1320
1685
|
};
|
|
1321
1686
|
endBody = () => {
|
|
1322
|
-
|
|
1687
|
+
resolve2(Buffer.concat(chunks));
|
|
1323
1688
|
};
|
|
1324
1689
|
});
|
|
1325
1690
|
}
|
|
@@ -1437,7 +1802,7 @@ function connectTunnel(options) {
|
|
|
1437
1802
|
} = options;
|
|
1438
1803
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1439
1804
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1440
|
-
return new Promise((
|
|
1805
|
+
return new Promise((resolve2, reject) => {
|
|
1441
1806
|
const ws = new WebSocket2(url, {
|
|
1442
1807
|
headers: {
|
|
1443
1808
|
Authorization: authHeader
|
|
@@ -1502,7 +1867,7 @@ function connectTunnel(options) {
|
|
|
1502
1867
|
clearTimeout(connectionTimeout);
|
|
1503
1868
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1504
1869
|
onConnected?.(connectedAgentId);
|
|
1505
|
-
|
|
1870
|
+
resolve2({
|
|
1506
1871
|
ws,
|
|
1507
1872
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1508
1873
|
});
|
|
@@ -1624,6 +1989,17 @@ function messageIdOf(m) {
|
|
|
1624
1989
|
const infoId = m.info?.id;
|
|
1625
1990
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1626
1991
|
}
|
|
1992
|
+
function cleanImageMime(contentType) {
|
|
1993
|
+
if (!contentType) return null;
|
|
1994
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
1995
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
1996
|
+
}
|
|
1997
|
+
var LOG_LEVELS = {
|
|
1998
|
+
debug: 0,
|
|
1999
|
+
info: 1,
|
|
2000
|
+
warn: 2,
|
|
2001
|
+
error: 3
|
|
2002
|
+
};
|
|
1627
2003
|
var DEFAULT_RETRY_POLICY = {
|
|
1628
2004
|
maxAttempts: 6,
|
|
1629
2005
|
baseDelayMs: 500,
|
|
@@ -1632,6 +2008,9 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1632
2008
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1633
2009
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1634
2010
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
2011
|
+
var HEARTBEAT_MS = 6e4;
|
|
2012
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
2013
|
+
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1635
2014
|
var ChannelAuthError = class extends Error {
|
|
1636
2015
|
constructor(message) {
|
|
1637
2016
|
super(message);
|
|
@@ -1728,6 +2107,15 @@ var ChannelDriver = class {
|
|
|
1728
2107
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1729
2108
|
*/
|
|
1730
2109
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
2110
|
+
/**
|
|
2111
|
+
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
2112
|
+
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
2113
|
+
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
2114
|
+
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
2115
|
+
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
2116
|
+
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
2117
|
+
*/
|
|
2118
|
+
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
1731
2119
|
/**
|
|
1732
2120
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1733
2121
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -1742,6 +2130,15 @@ var ChannelDriver = class {
|
|
|
1742
2130
|
* so the NEXT tick may retry exactly once more).
|
|
1743
2131
|
*/
|
|
1744
2132
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2133
|
+
/**
|
|
2134
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2135
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2136
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2137
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2138
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2139
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2140
|
+
*/
|
|
2141
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1745
2142
|
/**
|
|
1746
2143
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1747
2144
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1759,6 +2156,16 @@ var ChannelDriver = class {
|
|
|
1759
2156
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1760
2157
|
*/
|
|
1761
2158
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2159
|
+
/**
|
|
2160
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2161
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
2162
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
2163
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
2164
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2165
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2166
|
+
* no watcher) can resolve the title.
|
|
2167
|
+
*/
|
|
2168
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1762
2169
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1763
2170
|
draining = false;
|
|
1764
2171
|
/**
|
|
@@ -1796,9 +2203,6 @@ var ChannelDriver = class {
|
|
|
1796
2203
|
get opencodeBase() {
|
|
1797
2204
|
return `http://127.0.0.1:${this.port}`;
|
|
1798
2205
|
}
|
|
1799
|
-
// -------------------------------------------------------------------------
|
|
1800
|
-
// Public API
|
|
1801
|
-
// -------------------------------------------------------------------------
|
|
1802
2206
|
/**
|
|
1803
2207
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1804
2208
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -1854,6 +2258,28 @@ var ChannelDriver = class {
|
|
|
1854
2258
|
}
|
|
1855
2259
|
return false;
|
|
1856
2260
|
}
|
|
2261
|
+
/**
|
|
2262
|
+
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2263
|
+
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
2264
|
+
* `watchers` entry whose `inFlight` set is non-empty — the same predicate
|
|
2265
|
+
* `hasInFlightWatchers()` uses, lifted to return the ids.
|
|
2266
|
+
*
|
|
2267
|
+
* Deliberately does NOT include `this.sessions` (the permanent, never-pruned
|
|
2268
|
+
* conversation→session cache). Protecting every bound-but-idle session there
|
|
2269
|
+
* would shield nearly every session and defeat cleanup — AND it is unnecessary:
|
|
2270
|
+
* `ensureSession` is self-healing (it recreates a session whose id no longer
|
|
2271
|
+
* exists), so deleting an idle bound session is harmless — the conversation's
|
|
2272
|
+
* next turn transparently rebinds a fresh one. The only thing worth protecting
|
|
2273
|
+
* is a session with a turn ACTIVELY in flight right now: tearing that down
|
|
2274
|
+
* mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
|
|
2275
|
+
*/
|
|
2276
|
+
protectedSessionIds() {
|
|
2277
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2278
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
2279
|
+
if (watcher.inFlight.size > 0) ids.add(sessionId);
|
|
2280
|
+
}
|
|
2281
|
+
return ids;
|
|
2282
|
+
}
|
|
1857
2283
|
/**
|
|
1858
2284
|
* Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
|
|
1859
2285
|
* this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
|
|
@@ -1918,9 +2344,7 @@ var ChannelDriver = class {
|
|
|
1918
2344
|
if (!stillLive) return;
|
|
1919
2345
|
}
|
|
1920
2346
|
}
|
|
1921
|
-
// -------------------------------------------------------------------------
|
|
1922
2347
|
// Conversation processing (WI-3 — async dispatch)
|
|
1923
|
-
// -------------------------------------------------------------------------
|
|
1924
2348
|
/**
|
|
1925
2349
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1926
2350
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -1952,13 +2376,24 @@ var ChannelDriver = class {
|
|
|
1952
2376
|
conversation_id: conv.id,
|
|
1953
2377
|
message_id: message.id
|
|
1954
2378
|
});
|
|
2379
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
1955
2380
|
opencodeMessageId = await this.dispatchLocked(
|
|
1956
2381
|
sessionId,
|
|
1957
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2382
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
1958
2383
|
);
|
|
1959
2384
|
} catch (err) {
|
|
1960
2385
|
if (err instanceof ChannelAuthError) throw err;
|
|
1961
2386
|
this.dispatched.delete(message.id);
|
|
2387
|
+
if (await sessionExists(this.port, sessionId) === false) {
|
|
2388
|
+
this.sessions.delete(conv.id);
|
|
2389
|
+
this.log({
|
|
2390
|
+
level: "warn",
|
|
2391
|
+
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.`,
|
|
2392
|
+
conversation_id: conv.id,
|
|
2393
|
+
message_id: message.id
|
|
2394
|
+
});
|
|
2395
|
+
break;
|
|
2396
|
+
}
|
|
1962
2397
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1963
2398
|
});
|
|
1964
2399
|
this.log({
|
|
@@ -1971,7 +2406,7 @@ var ChannelDriver = class {
|
|
|
1971
2406
|
}
|
|
1972
2407
|
if (opencodeMessageId === null) {
|
|
1973
2408
|
this.log({
|
|
1974
|
-
level: "
|
|
2409
|
+
level: "warn",
|
|
1975
2410
|
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`,
|
|
1976
2411
|
conversation_id: conv.id,
|
|
1977
2412
|
message_id: message.id
|
|
@@ -1985,7 +2420,7 @@ var ChannelDriver = class {
|
|
|
1985
2420
|
}
|
|
1986
2421
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
1987
2422
|
this.log({
|
|
1988
|
-
level: "
|
|
2423
|
+
level: "warn",
|
|
1989
2424
|
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).`,
|
|
1990
2425
|
conversation_id: conv.id
|
|
1991
2426
|
});
|
|
@@ -1994,16 +2429,33 @@ var ChannelDriver = class {
|
|
|
1994
2429
|
return dispatched;
|
|
1995
2430
|
}
|
|
1996
2431
|
async ensureSession(conv) {
|
|
1997
|
-
const
|
|
1998
|
-
if (
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2432
|
+
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2433
|
+
if (bound) {
|
|
2434
|
+
const exists = await sessionExists(this.port, bound);
|
|
2435
|
+
if (exists === false) {
|
|
2436
|
+
this.log({
|
|
2437
|
+
level: "debug",
|
|
2438
|
+
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.`,
|
|
2439
|
+
conversation_id: conv.id
|
|
2440
|
+
});
|
|
2441
|
+
this.sessions.delete(conv.id);
|
|
2442
|
+
return this.createAndBindSession(conv.id);
|
|
2443
|
+
}
|
|
2444
|
+
this.sessions.set(conv.id, bound);
|
|
2445
|
+
return bound;
|
|
2002
2446
|
}
|
|
2447
|
+
return this.createAndBindSession(conv.id);
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
2451
|
+
* best-effort persist it server-side. Shared by the first-ever bind and the
|
|
2452
|
+
* self-heal recreate path in `ensureSession`.
|
|
2453
|
+
*/
|
|
2454
|
+
async createAndBindSession(conversationId) {
|
|
2003
2455
|
const directory = await this.resolveOpenCodeDirectory();
|
|
2004
2456
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
2005
|
-
this.sessions.set(
|
|
2006
|
-
await this.persistSession(
|
|
2457
|
+
this.sessions.set(conversationId, sessionId);
|
|
2458
|
+
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
2007
2459
|
});
|
|
2008
2460
|
return sessionId;
|
|
2009
2461
|
}
|
|
@@ -2017,15 +2469,13 @@ var ChannelDriver = class {
|
|
|
2017
2469
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2018
2470
|
if (!this.opencodeDirectory) {
|
|
2019
2471
|
this.log({
|
|
2020
|
-
level: "
|
|
2472
|
+
level: "warn",
|
|
2021
2473
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2022
2474
|
});
|
|
2023
2475
|
}
|
|
2024
2476
|
return this.opencodeDirectory;
|
|
2025
2477
|
}
|
|
2026
|
-
// -------------------------------------------------------------------------
|
|
2027
2478
|
// Per-session watcher (WI-3)
|
|
2028
|
-
// -------------------------------------------------------------------------
|
|
2029
2479
|
/**
|
|
2030
2480
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2031
2481
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2045,6 +2495,103 @@ var ChannelDriver = class {
|
|
|
2045
2495
|
);
|
|
2046
2496
|
return run2;
|
|
2047
2497
|
}
|
|
2498
|
+
// Inbound image attachments (#255, WI-8)
|
|
2499
|
+
/**
|
|
2500
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2501
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2502
|
+
*
|
|
2503
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2504
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2505
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2506
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2507
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2508
|
+
* existing callback surface when any image was skipped/failed.
|
|
2509
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2510
|
+
* reports outcomes back via `onOutcomes`.
|
|
2511
|
+
*/
|
|
2512
|
+
buildSendAttachments(conv, message) {
|
|
2513
|
+
const refs = message.attachments;
|
|
2514
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2515
|
+
return {
|
|
2516
|
+
inputs: refs.map((a, index) => ({
|
|
2517
|
+
index,
|
|
2518
|
+
mime: a.mime,
|
|
2519
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2520
|
+
})),
|
|
2521
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2522
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2527
|
+
* (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2528
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2529
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2530
|
+
*
|
|
2531
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2532
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2533
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2534
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2535
|
+
* Failures are logged with context (no silent swallow).
|
|
2536
|
+
*/
|
|
2537
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2538
|
+
try {
|
|
2539
|
+
const res = await this.fetchImpl(
|
|
2540
|
+
`${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2541
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2542
|
+
);
|
|
2543
|
+
if (!res.ok) {
|
|
2544
|
+
this.log({
|
|
2545
|
+
level: "error",
|
|
2546
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2547
|
+
message_id: messageId
|
|
2548
|
+
});
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
const buf = await res.arrayBuffer();
|
|
2552
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2553
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2554
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2555
|
+
} catch (err) {
|
|
2556
|
+
this.log({
|
|
2557
|
+
level: "error",
|
|
2558
|
+
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)}`,
|
|
2559
|
+
message_id: messageId
|
|
2560
|
+
});
|
|
2561
|
+
return null;
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2566
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2567
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2568
|
+
*
|
|
2569
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2570
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2571
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2572
|
+
* reaches the channel.
|
|
2573
|
+
*
|
|
2574
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2575
|
+
*/
|
|
2576
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2577
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2578
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2579
|
+
if (skipped === 0 && failed === 0) return;
|
|
2580
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2581
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2582
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2583
|
+
this.log({
|
|
2584
|
+
level: "info",
|
|
2585
|
+
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`,
|
|
2586
|
+
conversation_id: conversationId,
|
|
2587
|
+
message_id: messageId
|
|
2588
|
+
});
|
|
2589
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2590
|
+
skipped,
|
|
2591
|
+
failed,
|
|
2592
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2048
2595
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2049
2596
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2050
2597
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2054,7 +2601,9 @@ var ChannelDriver = class {
|
|
|
2054
2601
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2055
2602
|
loop: null,
|
|
2056
2603
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2057
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2604
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2605
|
+
lastGoodPollAt: this.now(),
|
|
2606
|
+
hadUsablePoll: false
|
|
2058
2607
|
};
|
|
2059
2608
|
this.watchers.set(sessionId, watcher);
|
|
2060
2609
|
}
|
|
@@ -2064,20 +2613,37 @@ var ChannelDriver = class {
|
|
|
2064
2613
|
opencodeMessageId,
|
|
2065
2614
|
message,
|
|
2066
2615
|
dispatchedAt: now,
|
|
2616
|
+
processingAnchorMs: now,
|
|
2067
2617
|
deadline: now + this.pausedMaxWaitMs,
|
|
2068
2618
|
started: false,
|
|
2069
2619
|
done: false,
|
|
2070
|
-
stuckReported: false
|
|
2620
|
+
stuckReported: false,
|
|
2621
|
+
lastAliveAt: 0,
|
|
2622
|
+
aliveInFlight: false,
|
|
2623
|
+
awaitingHumanLatched: false,
|
|
2624
|
+
pausedOnQuestion: false,
|
|
2625
|
+
pausedOnPermission: false,
|
|
2626
|
+
pausedClearConfirmed: false,
|
|
2627
|
+
pausedInFlight: false,
|
|
2628
|
+
deliveryDeadlineAnchored: false
|
|
2071
2629
|
});
|
|
2072
2630
|
}
|
|
2073
2631
|
/**
|
|
2074
2632
|
* Register a RE-ADOPTED `processing` message with its session watcher
|
|
2075
2633
|
* (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
|
|
2076
2634
|
* `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
|
|
2077
|
-
* `now
|
|
2078
|
-
* (10 min after `processed_at
|
|
2079
|
-
*
|
|
2080
|
-
*
|
|
2635
|
+
* `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
|
|
2636
|
+
* fresh dispatch would (10 min after `processed_at`, not 10 min from now).
|
|
2637
|
+
*
|
|
2638
|
+
* This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
|
|
2639
|
+
* give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
|
|
2640
|
+
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
2641
|
+
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
2642
|
+
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
2643
|
+
* handed to the cron. The old "the `deadline` must settle before the ~15-min
|
|
2644
|
+
* cron or they double-drive" reasoning is superseded: liveness now settles the
|
|
2645
|
+
* actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
|
|
2646
|
+
* (only the appear-guard uses it).
|
|
2081
2647
|
*
|
|
2082
2648
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
2083
2649
|
* `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
|
|
@@ -2095,7 +2661,9 @@ var ChannelDriver = class {
|
|
|
2095
2661
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2096
2662
|
loop: null,
|
|
2097
2663
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2098
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2664
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2665
|
+
lastGoodPollAt: this.now(),
|
|
2666
|
+
hadUsablePoll: false
|
|
2099
2667
|
};
|
|
2100
2668
|
this.watchers.set(sessionId, watcher);
|
|
2101
2669
|
}
|
|
@@ -2104,6 +2672,10 @@ var ChannelDriver = class {
|
|
|
2104
2672
|
opencodeMessageId,
|
|
2105
2673
|
message,
|
|
2106
2674
|
dispatchedAt: this.now(),
|
|
2675
|
+
// Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
|
|
2676
|
+
// value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
|
|
2677
|
+
// reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
|
|
2678
|
+
processingAnchorMs: processedAtMs,
|
|
2107
2679
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
2108
2680
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
2109
2681
|
started: true,
|
|
@@ -2113,7 +2685,20 @@ var ChannelDriver = class {
|
|
|
2113
2685
|
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2114
2686
|
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2115
2687
|
// (#210/#220 observability).
|
|
2116
|
-
stuckReported: false
|
|
2688
|
+
stuckReported: false,
|
|
2689
|
+
// Task 5.2: a re-adopted actively-running row re-attaches into the SAME
|
|
2690
|
+
// watcher and so hits the SAME actively-running heartbeat branch in
|
|
2691
|
+
// `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
|
|
2692
|
+
// re-adopted and is confirming this row alive" via that `alive` heartbeat,
|
|
2693
|
+
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2694
|
+
lastAliveAt: 0,
|
|
2695
|
+
aliveInFlight: false,
|
|
2696
|
+
awaitingHumanLatched: false,
|
|
2697
|
+
pausedOnQuestion: false,
|
|
2698
|
+
pausedOnPermission: false,
|
|
2699
|
+
pausedClearConfirmed: false,
|
|
2700
|
+
pausedInFlight: false,
|
|
2701
|
+
deliveryDeadlineAnchored: false
|
|
2117
2702
|
});
|
|
2118
2703
|
}
|
|
2119
2704
|
/**
|
|
@@ -2164,12 +2749,30 @@ var ChannelDriver = class {
|
|
|
2164
2749
|
messages = Array.isArray(body) ? body : null;
|
|
2165
2750
|
}
|
|
2166
2751
|
} catch {
|
|
2167
|
-
continue;
|
|
2168
2752
|
}
|
|
2753
|
+
if (messages != null && messages.length > 0) {
|
|
2754
|
+
watcher.lastGoodPollAt = this.now();
|
|
2755
|
+
watcher.hadUsablePoll = true;
|
|
2756
|
+
} else {
|
|
2757
|
+
const emptyButReachable = messages != null;
|
|
2758
|
+
const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
|
|
2759
|
+
if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
|
|
2169
2764
|
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
2170
|
-
await this.serviceInFlightMessage(
|
|
2765
|
+
await this.serviceInFlightMessage(
|
|
2766
|
+
sessionId,
|
|
2767
|
+
watcher,
|
|
2768
|
+
inFlight,
|
|
2769
|
+
messages,
|
|
2770
|
+
openQuestions,
|
|
2771
|
+
openPermissions,
|
|
2772
|
+
questionsPolledOk,
|
|
2773
|
+
permissionsPolledOk
|
|
2774
|
+
);
|
|
2171
2775
|
}
|
|
2172
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
2173
2776
|
}
|
|
2174
2777
|
} catch (err) {
|
|
2175
2778
|
if (err instanceof ChannelAuthError) {
|
|
@@ -2191,28 +2794,55 @@ var ChannelDriver = class {
|
|
|
2191
2794
|
});
|
|
2192
2795
|
}
|
|
2193
2796
|
}
|
|
2797
|
+
/**
|
|
2798
|
+
* On FIRST observing a terminal (done/failed) state, ensure the delivery
|
|
2799
|
+
* (markDone/markFailed) transient-retry path has a real window. A long
|
|
2800
|
+
* ACTIVELY-running turn is kept past its original `deadline`, so by completion
|
|
2801
|
+
* `now >= deadline` already holds and the retry bound below would fire on the
|
|
2802
|
+
* first transient PATCH failure — dropping the message before its reply lands
|
|
2803
|
+
* (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
|
|
2804
|
+
* to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
|
|
2805
|
+
* or past now, so a still-ample window is left untouched.
|
|
2806
|
+
*/
|
|
2807
|
+
anchorDeliveryDeadline(inFlight) {
|
|
2808
|
+
if (inFlight.deliveryDeadlineAnchored) return;
|
|
2809
|
+
inFlight.deliveryDeadlineAnchored = true;
|
|
2810
|
+
if (this.now() >= inFlight.deadline) {
|
|
2811
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2194
2814
|
/**
|
|
2195
2815
|
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
2196
2816
|
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
2197
2817
|
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
2198
2818
|
* in-flight set on completion or timeout.
|
|
2199
2819
|
*/
|
|
2200
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
2820
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
|
|
2201
2821
|
const conv = watcher.conv;
|
|
2202
2822
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
2823
|
+
const id = inFlight.evidentMessageId;
|
|
2824
|
+
if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
|
|
2825
|
+
else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
|
|
2826
|
+
if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
|
|
2827
|
+
else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
|
|
2828
|
+
const observedOpen = openQuestions.has(id) || openPermissions.has(id);
|
|
2829
|
+
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2830
|
+
const awaitingHuman = observedOpen || latchedPaused;
|
|
2203
2831
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2832
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2204
2833
|
let claimed;
|
|
2205
2834
|
try {
|
|
2206
2835
|
claimed = await this.markProcessing(
|
|
2207
2836
|
conv.id,
|
|
2208
2837
|
inFlight.evidentMessageId,
|
|
2209
2838
|
sessionId,
|
|
2210
|
-
inFlight.opencodeMessageId
|
|
2839
|
+
inFlight.opencodeMessageId,
|
|
2840
|
+
title
|
|
2211
2841
|
);
|
|
2212
2842
|
} catch (err) {
|
|
2213
2843
|
if (err instanceof ChannelAuthError) throw err;
|
|
2214
2844
|
this.log({
|
|
2215
|
-
level: "
|
|
2845
|
+
level: "warn",
|
|
2216
2846
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2217
2847
|
conversation_id: conv.id,
|
|
2218
2848
|
message_id: inFlight.evidentMessageId
|
|
@@ -2222,7 +2852,7 @@ var ChannelDriver = class {
|
|
|
2222
2852
|
inFlight.started = true;
|
|
2223
2853
|
if (!claimed) {
|
|
2224
2854
|
this.log({
|
|
2225
|
-
level: "
|
|
2855
|
+
level: "debug",
|
|
2226
2856
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2227
2857
|
conversation_id: conv.id,
|
|
2228
2858
|
message_id: inFlight.evidentMessageId
|
|
@@ -2230,6 +2860,7 @@ var ChannelDriver = class {
|
|
|
2230
2860
|
}
|
|
2231
2861
|
}
|
|
2232
2862
|
if (state === "done") {
|
|
2863
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2233
2864
|
if (!inFlight.done) {
|
|
2234
2865
|
this.log({
|
|
2235
2866
|
level: "info",
|
|
@@ -2237,18 +2868,22 @@ var ChannelDriver = class {
|
|
|
2237
2868
|
conversation_id: conv.id,
|
|
2238
2869
|
message_id: inFlight.evidentMessageId
|
|
2239
2870
|
});
|
|
2871
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2872
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2240
2873
|
try {
|
|
2241
2874
|
await this.markDone(
|
|
2242
2875
|
conv.id,
|
|
2243
2876
|
inFlight.evidentMessageId,
|
|
2244
2877
|
sessionId,
|
|
2245
|
-
inFlight.opencodeMessageId
|
|
2878
|
+
inFlight.opencodeMessageId,
|
|
2879
|
+
title,
|
|
2880
|
+
usage
|
|
2246
2881
|
);
|
|
2247
2882
|
} catch (err) {
|
|
2248
2883
|
if (err instanceof ChannelAuthError) throw err;
|
|
2249
2884
|
if (err instanceof ChannelTerminalError) {
|
|
2250
2885
|
this.log({
|
|
2251
|
-
level: "
|
|
2886
|
+
level: "warn",
|
|
2252
2887
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2253
2888
|
conversation_id: conv.id,
|
|
2254
2889
|
message_id: inFlight.evidentMessageId
|
|
@@ -2258,7 +2893,7 @@ var ChannelDriver = class {
|
|
|
2258
2893
|
}
|
|
2259
2894
|
if (this.now() >= inFlight.deadline) {
|
|
2260
2895
|
this.log({
|
|
2261
|
-
level: "
|
|
2896
|
+
level: "warn",
|
|
2262
2897
|
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)}`,
|
|
2263
2898
|
conversation_id: conv.id,
|
|
2264
2899
|
message_id: inFlight.evidentMessageId
|
|
@@ -2267,7 +2902,7 @@ var ChannelDriver = class {
|
|
|
2267
2902
|
return;
|
|
2268
2903
|
}
|
|
2269
2904
|
this.log({
|
|
2270
|
-
level: "
|
|
2905
|
+
level: "warn",
|
|
2271
2906
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2272
2907
|
conversation_id: conv.id,
|
|
2273
2908
|
message_id: inFlight.evidentMessageId
|
|
@@ -2280,6 +2915,7 @@ var ChannelDriver = class {
|
|
|
2280
2915
|
return;
|
|
2281
2916
|
}
|
|
2282
2917
|
if (state === "failed") {
|
|
2918
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2283
2919
|
if (!inFlight.done) {
|
|
2284
2920
|
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2285
2921
|
this.log({
|
|
@@ -2288,13 +2924,14 @@ var ChannelDriver = class {
|
|
|
2288
2924
|
conversation_id: conv.id,
|
|
2289
2925
|
message_id: inFlight.evidentMessageId
|
|
2290
2926
|
});
|
|
2927
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2291
2928
|
try {
|
|
2292
|
-
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
2929
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
|
|
2293
2930
|
} catch (err) {
|
|
2294
2931
|
if (err instanceof ChannelAuthError) throw err;
|
|
2295
2932
|
if (err instanceof ChannelTerminalError) {
|
|
2296
2933
|
this.log({
|
|
2297
|
-
level: "
|
|
2934
|
+
level: "warn",
|
|
2298
2935
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2299
2936
|
conversation_id: conv.id,
|
|
2300
2937
|
message_id: inFlight.evidentMessageId
|
|
@@ -2304,7 +2941,7 @@ var ChannelDriver = class {
|
|
|
2304
2941
|
}
|
|
2305
2942
|
if (this.now() >= inFlight.deadline) {
|
|
2306
2943
|
this.log({
|
|
2307
|
-
level: "
|
|
2944
|
+
level: "warn",
|
|
2308
2945
|
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)}`,
|
|
2309
2946
|
conversation_id: conv.id,
|
|
2310
2947
|
message_id: inFlight.evidentMessageId
|
|
@@ -2313,7 +2950,7 @@ var ChannelDriver = class {
|
|
|
2313
2950
|
return;
|
|
2314
2951
|
}
|
|
2315
2952
|
this.log({
|
|
2316
|
-
level: "
|
|
2953
|
+
level: "warn",
|
|
2317
2954
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2318
2955
|
conversation_id: conv.id,
|
|
2319
2956
|
message_id: inFlight.evidentMessageId
|
|
@@ -2333,9 +2970,53 @@ var ChannelDriver = class {
|
|
|
2333
2970
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
2334
2971
|
});
|
|
2335
2972
|
}
|
|
2336
|
-
|
|
2973
|
+
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2974
|
+
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2337
2975
|
this.log({
|
|
2338
|
-
level: "
|
|
2976
|
+
level: "warn",
|
|
2977
|
+
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`,
|
|
2978
|
+
conversation_id: conv.id,
|
|
2979
|
+
message_id: inFlight.evidentMessageId
|
|
2980
|
+
});
|
|
2981
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2982
|
+
watched_for_ms: this.now() - inFlight.processingAnchorMs
|
|
2983
|
+
});
|
|
2984
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2988
|
+
inFlight.aliveInFlight = true;
|
|
2989
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
2990
|
+
inFlight.aliveInFlight = false;
|
|
2991
|
+
if (ok) inFlight.lastAliveAt = this.now();
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
if (awaitingHuman) {
|
|
2995
|
+
if (!inFlight.awaitingHumanLatched) {
|
|
2996
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2997
|
+
inFlight.awaitingHumanLatched = true;
|
|
2998
|
+
}
|
|
2999
|
+
if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
|
|
3000
|
+
inFlight.pausedInFlight = true;
|
|
3001
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
|
|
3002
|
+
inFlight.pausedInFlight = false;
|
|
3003
|
+
if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
} else if (inFlight.awaitingHumanLatched) {
|
|
3007
|
+
inFlight.awaitingHumanLatched = false;
|
|
3008
|
+
inFlight.pausedOnQuestion = false;
|
|
3009
|
+
inFlight.pausedOnPermission = false;
|
|
3010
|
+
inFlight.pausedClearConfirmed = false;
|
|
3011
|
+
}
|
|
3012
|
+
const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
|
|
3013
|
+
const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
|
|
3014
|
+
(sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
|
|
3015
|
+
);
|
|
3016
|
+
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
3017
|
+
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
3018
|
+
this.log({
|
|
3019
|
+
level: "debug",
|
|
2339
3020
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2340
3021
|
conversation_id: conv.id,
|
|
2341
3022
|
message_id: inFlight.evidentMessageId
|
|
@@ -2346,9 +3027,7 @@ var ChannelDriver = class {
|
|
|
2346
3027
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2347
3028
|
}
|
|
2348
3029
|
}
|
|
2349
|
-
// -------------------------------------------------------------------------
|
|
2350
3030
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2351
|
-
// -------------------------------------------------------------------------
|
|
2352
3031
|
/**
|
|
2353
3032
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2354
3033
|
*
|
|
@@ -2365,15 +3044,20 @@ var ChannelDriver = class {
|
|
|
2365
3044
|
*/
|
|
2366
3045
|
async readoptProcessing() {
|
|
2367
3046
|
const rows = await this.getProcessingMessages();
|
|
2368
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
3047
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2369
3048
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2370
|
-
for (const id of [
|
|
3049
|
+
for (const id of [
|
|
3050
|
+
...this.dontRedispatch,
|
|
3051
|
+
...this.doneUndeliverable,
|
|
3052
|
+
...this.readoptPollUnresolvedSignalled
|
|
3053
|
+
]) {
|
|
2371
3054
|
if (!stillProcessing.has(id)) {
|
|
2372
3055
|
const cleared = this.dontRedispatch.delete(id);
|
|
2373
3056
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
3057
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2374
3058
|
if (cleared || clearedUndeliverable) {
|
|
2375
3059
|
this.log({
|
|
2376
|
-
level: "
|
|
3060
|
+
level: "debug",
|
|
2377
3061
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2378
3062
|
message_id: id
|
|
2379
3063
|
});
|
|
@@ -2386,7 +3070,7 @@ var ChannelDriver = class {
|
|
|
2386
3070
|
for (const row of rows) {
|
|
2387
3071
|
if (!row.opencode_session_id) {
|
|
2388
3072
|
this.log({
|
|
2389
|
-
level: "
|
|
3073
|
+
level: "warn",
|
|
2390
3074
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2391
3075
|
conversation_id: row.conversation_id,
|
|
2392
3076
|
message_id: row.id
|
|
@@ -2403,7 +3087,7 @@ var ChannelDriver = class {
|
|
|
2403
3087
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2404
3088
|
if (!res.ok) {
|
|
2405
3089
|
this.log({
|
|
2406
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2407
3091
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2408
3092
|
});
|
|
2409
3093
|
continue;
|
|
@@ -2411,7 +3095,7 @@ var ChannelDriver = class {
|
|
|
2411
3095
|
const body = await res.json();
|
|
2412
3096
|
if (!Array.isArray(body)) {
|
|
2413
3097
|
this.log({
|
|
2414
|
-
level: "
|
|
3098
|
+
level: "warn",
|
|
2415
3099
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2416
3100
|
});
|
|
2417
3101
|
continue;
|
|
@@ -2419,13 +3103,15 @@ var ChannelDriver = class {
|
|
|
2419
3103
|
messages = body;
|
|
2420
3104
|
} catch (err) {
|
|
2421
3105
|
this.log({
|
|
2422
|
-
level: "
|
|
3106
|
+
level: "warn",
|
|
2423
3107
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2424
3108
|
});
|
|
2425
3109
|
continue;
|
|
2426
3110
|
}
|
|
3111
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
3112
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2427
3113
|
for (const row of sessionRows) {
|
|
2428
|
-
await this.readoptOne(sessionId, row, messages);
|
|
3114
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2429
3115
|
}
|
|
2430
3116
|
}
|
|
2431
3117
|
}
|
|
@@ -2447,10 +3133,10 @@ var ChannelDriver = class {
|
|
|
2447
3133
|
*
|
|
2448
3134
|
* Only `ChannelAuthError` propagates.
|
|
2449
3135
|
*/
|
|
2450
|
-
async readoptOne(sessionId, row, messages) {
|
|
3136
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2451
3137
|
if (this.isTracked(sessionId, row.id)) {
|
|
2452
3138
|
this.log({
|
|
2453
|
-
level: "
|
|
3139
|
+
level: "debug",
|
|
2454
3140
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2455
3141
|
conversation_id: row.conversation_id,
|
|
2456
3142
|
message_id: row.id
|
|
@@ -2462,7 +3148,7 @@ var ChannelDriver = class {
|
|
|
2462
3148
|
if (state === "done") {
|
|
2463
3149
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2464
3150
|
this.log({
|
|
2465
|
-
level: "
|
|
3151
|
+
level: "debug",
|
|
2466
3152
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2467
3153
|
conversation_id: row.conversation_id,
|
|
2468
3154
|
message_id: row.id
|
|
@@ -2476,21 +3162,24 @@ var ChannelDriver = class {
|
|
|
2476
3162
|
message_id: row.id
|
|
2477
3163
|
});
|
|
2478
3164
|
try {
|
|
2479
|
-
await this.
|
|
3165
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3166
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3167
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
2480
3168
|
} catch (err) {
|
|
2481
3169
|
if (err instanceof ChannelAuthError) throw err;
|
|
2482
3170
|
if (err instanceof ChannelTerminalError) {
|
|
2483
3171
|
this.doneUndeliverable.add(row.id);
|
|
2484
3172
|
this.log({
|
|
2485
|
-
level: "
|
|
3173
|
+
level: "warn",
|
|
2486
3174
|
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}`,
|
|
2487
3175
|
conversation_id: row.conversation_id,
|
|
2488
3176
|
message_id: row.id
|
|
2489
3177
|
});
|
|
3178
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2490
3179
|
return;
|
|
2491
3180
|
}
|
|
2492
3181
|
this.log({
|
|
2493
|
-
level: "
|
|
3182
|
+
level: "warn",
|
|
2494
3183
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2495
3184
|
conversation_id: row.conversation_id,
|
|
2496
3185
|
message_id: row.id
|
|
@@ -2498,10 +3187,12 @@ var ChannelDriver = class {
|
|
|
2498
3187
|
return;
|
|
2499
3188
|
}
|
|
2500
3189
|
this.dontRedispatch.delete(row.id);
|
|
3190
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2501
3191
|
return;
|
|
2502
3192
|
}
|
|
2503
3193
|
if (state === "failed") {
|
|
2504
3194
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3195
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
2505
3196
|
this.log({
|
|
2506
3197
|
level: "error",
|
|
2507
3198
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2509,21 +3200,22 @@ var ChannelDriver = class {
|
|
|
2509
3200
|
message_id: row.id
|
|
2510
3201
|
});
|
|
2511
3202
|
try {
|
|
2512
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
3203
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
2513
3204
|
} catch (err) {
|
|
2514
3205
|
if (err instanceof ChannelAuthError) throw err;
|
|
2515
3206
|
if (err instanceof ChannelTerminalError) {
|
|
2516
3207
|
this.doneUndeliverable.add(row.id);
|
|
2517
3208
|
this.log({
|
|
2518
|
-
level: "
|
|
3209
|
+
level: "warn",
|
|
2519
3210
|
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}`,
|
|
2520
3211
|
conversation_id: row.conversation_id,
|
|
2521
3212
|
message_id: row.id
|
|
2522
3213
|
});
|
|
3214
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2523
3215
|
return;
|
|
2524
3216
|
}
|
|
2525
3217
|
this.log({
|
|
2526
|
-
level: "
|
|
3218
|
+
level: "warn",
|
|
2527
3219
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2528
3220
|
conversation_id: row.conversation_id,
|
|
2529
3221
|
message_id: row.id
|
|
@@ -2531,17 +3223,83 @@ var ChannelDriver = class {
|
|
|
2531
3223
|
return;
|
|
2532
3224
|
}
|
|
2533
3225
|
this.dontRedispatch.delete(row.id);
|
|
3226
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2534
3227
|
return;
|
|
2535
3228
|
}
|
|
2536
3229
|
if (this.dontRedispatch.has(row.id)) {
|
|
2537
3230
|
this.log({
|
|
2538
|
-
level: "
|
|
3231
|
+
level: "debug",
|
|
2539
3232
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2540
3233
|
conversation_id: row.conversation_id,
|
|
2541
3234
|
message_id: row.id
|
|
2542
3235
|
});
|
|
2543
3236
|
return;
|
|
2544
3237
|
}
|
|
3238
|
+
let statusReadableOngoing = null;
|
|
3239
|
+
if (state === "running" && ocId) {
|
|
3240
|
+
const reply = findLastAssistantReplyFor(messages, ocId);
|
|
3241
|
+
const shape = this.replyCompletionShape(reply);
|
|
3242
|
+
const ongoing = sessionOngoing;
|
|
3243
|
+
statusReadableOngoing = ongoing;
|
|
3244
|
+
if (ongoing === false) {
|
|
3245
|
+
this.log({
|
|
3246
|
+
level: "info",
|
|
3247
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status (absent/idle) \u2014 re-dispatching from scratch (status-gated recovery)`,
|
|
3248
|
+
conversation_id: row.conversation_id,
|
|
3249
|
+
message_id: row.id
|
|
3250
|
+
});
|
|
3251
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
if (ongoing === true) {
|
|
3255
|
+
this.log({
|
|
3256
|
+
level: "debug",
|
|
3257
|
+
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)`,
|
|
3258
|
+
conversation_id: row.conversation_id,
|
|
3259
|
+
message_id: row.id
|
|
3260
|
+
});
|
|
3261
|
+
} else {
|
|
3262
|
+
if (shape === "b1") {
|
|
3263
|
+
this.log({
|
|
3264
|
+
level: "debug",
|
|
3265
|
+
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`,
|
|
3266
|
+
conversation_id: row.conversation_id,
|
|
3267
|
+
message_id: row.id
|
|
3268
|
+
});
|
|
3269
|
+
if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
|
|
3270
|
+
this.readoptPollUnresolvedSignalled.add(row.id);
|
|
3271
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
|
|
3272
|
+
}
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
this.log({
|
|
3276
|
+
level: "debug",
|
|
3277
|
+
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`,
|
|
3278
|
+
conversation_id: row.conversation_id,
|
|
3279
|
+
message_id: row.id
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
|
|
3284
|
+
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
3285
|
+
if (descendantAlive === true) {
|
|
3286
|
+
this.log({
|
|
3287
|
+
level: "debug",
|
|
3288
|
+
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)`,
|
|
3289
|
+
conversation_id: row.conversation_id,
|
|
3290
|
+
message_id: row.id
|
|
3291
|
+
});
|
|
3292
|
+
} else {
|
|
3293
|
+
this.log({
|
|
3294
|
+
level: "info",
|
|
3295
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned on recovery (root ${sessionId.slice(0, 8)}), no live descendant runner \u2014 re-dispatching from scratch${descendantAlive === null ? " (descendant liveness indeterminate; a restart guarantees no live runner, so this does NOT block the re-dispatch)" : ""}`,
|
|
3296
|
+
conversation_id: row.conversation_id,
|
|
3297
|
+
message_id: row.id
|
|
3298
|
+
});
|
|
3299
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
2545
3303
|
if ((state === "running" || state === "queued") && ocId) {
|
|
2546
3304
|
const conv = this.convForRow(sessionId, row);
|
|
2547
3305
|
const message = this.queuedMessageForRow(row);
|
|
@@ -2550,11 +3308,12 @@ var ChannelDriver = class {
|
|
|
2550
3308
|
this.readopted.add(row.id);
|
|
2551
3309
|
this.ensureWatcherRunning(sessionId);
|
|
2552
3310
|
this.log({
|
|
2553
|
-
level: "
|
|
3311
|
+
level: "debug",
|
|
2554
3312
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2555
3313
|
conversation_id: row.conversation_id,
|
|
2556
3314
|
message_id: row.id
|
|
2557
3315
|
});
|
|
3316
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2558
3317
|
return;
|
|
2559
3318
|
}
|
|
2560
3319
|
await this.forceReadoptRun(sessionId, row);
|
|
@@ -2583,7 +3342,7 @@ var ChannelDriver = class {
|
|
|
2583
3342
|
async forceReadoptRun(sessionId, row) {
|
|
2584
3343
|
if (this.stopped) {
|
|
2585
3344
|
this.log({
|
|
2586
|
-
level: "
|
|
3345
|
+
level: "debug",
|
|
2587
3346
|
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`,
|
|
2588
3347
|
conversation_id: row.conversation_id,
|
|
2589
3348
|
message_id: row.id
|
|
@@ -2592,7 +3351,7 @@ var ChannelDriver = class {
|
|
|
2592
3351
|
}
|
|
2593
3352
|
if (this.awaitingReadopt.has(row.id)) {
|
|
2594
3353
|
this.log({
|
|
2595
|
-
level: "
|
|
3354
|
+
level: "debug",
|
|
2596
3355
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2597
3356
|
conversation_id: row.conversation_id,
|
|
2598
3357
|
message_id: row.id
|
|
@@ -2602,11 +3361,12 @@ var ChannelDriver = class {
|
|
|
2602
3361
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2603
3362
|
this.dontRedispatch.add(row.id);
|
|
2604
3363
|
this.log({
|
|
2605
|
-
level: "
|
|
3364
|
+
level: "debug",
|
|
2606
3365
|
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)`,
|
|
2607
3366
|
conversation_id: row.conversation_id,
|
|
2608
3367
|
message_id: row.id
|
|
2609
3368
|
});
|
|
3369
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2610
3370
|
return;
|
|
2611
3371
|
}
|
|
2612
3372
|
const options = {
|
|
@@ -2620,40 +3380,44 @@ var ChannelDriver = class {
|
|
|
2620
3380
|
message_id: row.id
|
|
2621
3381
|
});
|
|
2622
3382
|
this.awaitingReadopt.add(row.id);
|
|
3383
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3384
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3385
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
2623
3386
|
let ocId;
|
|
2624
3387
|
try {
|
|
2625
3388
|
ocId = await this.dispatchLocked(
|
|
2626
3389
|
sessionId,
|
|
2627
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3390
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
2628
3391
|
);
|
|
2629
3392
|
} catch (err) {
|
|
2630
3393
|
this.awaitingReadopt.delete(row.id);
|
|
2631
3394
|
if (err instanceof ChannelAuthError) throw err;
|
|
2632
3395
|
this.log({
|
|
2633
|
-
level: "
|
|
3396
|
+
level: "warn",
|
|
2634
3397
|
message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2635
3398
|
conversation_id: row.conversation_id,
|
|
2636
3399
|
message_id: row.id
|
|
2637
3400
|
});
|
|
3401
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2638
3402
|
return;
|
|
2639
3403
|
}
|
|
2640
3404
|
if (ocId === null) {
|
|
2641
3405
|
this.awaitingReadopt.delete(row.id);
|
|
2642
3406
|
this.log({
|
|
2643
|
-
level: "
|
|
3407
|
+
level: "warn",
|
|
2644
3408
|
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`,
|
|
2645
3409
|
conversation_id: row.conversation_id,
|
|
2646
3410
|
message_id: row.id
|
|
2647
3411
|
});
|
|
3412
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2648
3413
|
return;
|
|
2649
3414
|
}
|
|
2650
|
-
|
|
2651
|
-
const message = this.queuedMessageForRow(row);
|
|
2652
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3415
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
2653
3416
|
this.dispatched.add(row.id);
|
|
2654
3417
|
this.readopted.add(row.id);
|
|
2655
3418
|
this.awaitingReadopt.delete(row.id);
|
|
2656
3419
|
this.ensureWatcherRunning(sessionId);
|
|
3420
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2657
3421
|
}
|
|
2658
3422
|
/**
|
|
2659
3423
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -2702,7 +3466,8 @@ var ChannelDriver = class {
|
|
|
2702
3466
|
opencode_agent: row.opencode_agent,
|
|
2703
3467
|
opencode_model: row.opencode_model,
|
|
2704
3468
|
source_message_id: row.source_message_id,
|
|
2705
|
-
slack_user_id: row.slack_user_id
|
|
3469
|
+
slack_user_id: row.slack_user_id,
|
|
3470
|
+
attachments: row.attachments ?? null
|
|
2706
3471
|
};
|
|
2707
3472
|
}
|
|
2708
3473
|
/**
|
|
@@ -2723,7 +3488,7 @@ var ChannelDriver = class {
|
|
|
2723
3488
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
2724
3489
|
this.dontRedispatch.add(evidentMessageId);
|
|
2725
3490
|
this.log({
|
|
2726
|
-
level: "
|
|
3491
|
+
level: "debug",
|
|
2727
3492
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
2728
3493
|
conversation_id: watcher.conv.id,
|
|
2729
3494
|
message_id: evidentMessageId
|
|
@@ -2745,21 +3510,41 @@ var ChannelDriver = class {
|
|
|
2745
3510
|
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2746
3511
|
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2747
3512
|
* oldest running message.
|
|
3513
|
+
*
|
|
3514
|
+
* Returns the set of in-flight Evident message ids that are paused awaiting a
|
|
3515
|
+
* human — an outstanding (still-open) question/permission is attributed to them.
|
|
3516
|
+
* `serviceInFlightMessage` uses this to keep an actively-running turn watched
|
|
3517
|
+
* forever (ADR-0047) while still bounding a turn merely blocked on a person who
|
|
3518
|
+
* may never answer. Attribution here covers ALL open interactions, not just
|
|
3519
|
+
* NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
|
|
3520
|
+
* even after it was already surfaced to the channel.
|
|
2748
3521
|
*/
|
|
2749
3522
|
async pollInteractions(sessionId, watcher, messages) {
|
|
3523
|
+
const openQuestions = /* @__PURE__ */ new Set();
|
|
3524
|
+
const openPermissions = /* @__PURE__ */ new Set();
|
|
3525
|
+
let questionsPolledOk = true;
|
|
3526
|
+
let permissionsPolledOk = true;
|
|
2750
3527
|
let questions = [];
|
|
2751
3528
|
try {
|
|
2752
3529
|
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2753
3530
|
if (res.ok) {
|
|
2754
3531
|
const body = await res.json();
|
|
2755
|
-
|
|
3532
|
+
if (Array.isArray(body)) {
|
|
3533
|
+
questions = body;
|
|
3534
|
+
} else {
|
|
3535
|
+
questionsPolledOk = false;
|
|
3536
|
+
}
|
|
3537
|
+
} else {
|
|
3538
|
+
questionsPolledOk = false;
|
|
2756
3539
|
}
|
|
2757
3540
|
} catch {
|
|
3541
|
+
questionsPolledOk = false;
|
|
2758
3542
|
}
|
|
2759
3543
|
for (const q of questions) {
|
|
2760
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2761
3544
|
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2762
3545
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
3546
|
+
if (paused) openQuestions.add(paused.evidentMessageId);
|
|
3547
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2763
3548
|
const reported = await this.reportInteraction(
|
|
2764
3549
|
watcher.conv.id,
|
|
2765
3550
|
"question",
|
|
@@ -2773,14 +3558,22 @@ var ChannelDriver = class {
|
|
|
2773
3558
|
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2774
3559
|
if (res.ok) {
|
|
2775
3560
|
const body = await res.json();
|
|
2776
|
-
|
|
3561
|
+
if (Array.isArray(body)) {
|
|
3562
|
+
permissions = body;
|
|
3563
|
+
} else {
|
|
3564
|
+
permissionsPolledOk = false;
|
|
3565
|
+
}
|
|
3566
|
+
} else {
|
|
3567
|
+
permissionsPolledOk = false;
|
|
2777
3568
|
}
|
|
2778
3569
|
} catch {
|
|
3570
|
+
permissionsPolledOk = false;
|
|
2779
3571
|
}
|
|
2780
3572
|
for (const p of permissions) {
|
|
2781
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2782
3573
|
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2783
3574
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
3575
|
+
if (paused) openPermissions.add(paused.evidentMessageId);
|
|
3576
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2784
3577
|
const reported = await this.reportInteraction(
|
|
2785
3578
|
watcher.conv.id,
|
|
2786
3579
|
"permission",
|
|
@@ -2789,6 +3582,7 @@ var ChannelDriver = class {
|
|
|
2789
3582
|
);
|
|
2790
3583
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2791
3584
|
}
|
|
3585
|
+
return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
|
|
2792
3586
|
}
|
|
2793
3587
|
/**
|
|
2794
3588
|
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
@@ -2834,6 +3628,128 @@ var ChannelDriver = class {
|
|
|
2834
3628
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
2835
3629
|
return parent;
|
|
2836
3630
|
}
|
|
3631
|
+
/**
|
|
3632
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3633
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3634
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3635
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3636
|
+
* Best-effort:
|
|
3637
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3638
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3639
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3640
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3641
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3642
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3643
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3644
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3645
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3646
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3647
|
+
*/
|
|
3648
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3649
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3650
|
+
if (cached != null) return cached;
|
|
3651
|
+
try {
|
|
3652
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3653
|
+
if (res.ok) {
|
|
3654
|
+
const body = await res.json();
|
|
3655
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3656
|
+
if (title.length > 0) {
|
|
3657
|
+
this.sessionTitles.set(sessionId, title);
|
|
3658
|
+
return title;
|
|
3659
|
+
}
|
|
3660
|
+
return null;
|
|
3661
|
+
}
|
|
3662
|
+
this.log({
|
|
3663
|
+
level: "debug",
|
|
3664
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3665
|
+
conversation_id: conversationId
|
|
3666
|
+
});
|
|
3667
|
+
} catch (err) {
|
|
3668
|
+
this.log({
|
|
3669
|
+
level: "debug",
|
|
3670
|
+
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)}`,
|
|
3671
|
+
conversation_id: conversationId
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
return null;
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3678
|
+
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
3679
|
+
*
|
|
3680
|
+
* The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
|
|
3681
|
+
* runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
|
|
3682
|
+
* completed `finish: "tool-calls"` root reply encountered during re-adoption is
|
|
3683
|
+
* idle by OpenCode's own definition and is re-dispatched. This method exists only
|
|
3684
|
+
* so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
|
|
3685
|
+
* provably in flight at the exact moment of recovery.
|
|
3686
|
+
*
|
|
3687
|
+
* "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
|
|
3688
|
+
* ACTIVELY generating — its LAST message is an assistant still mid-generation
|
|
3689
|
+
* (`completed == null`, via `isSessionActivelyGenerating`). An
|
|
3690
|
+
* INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
|
|
3691
|
+
* completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
|
|
3692
|
+
* is generating once the runner is gone), so it does NOT veto. (This is
|
|
3693
|
+
* deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
|
|
3694
|
+
* shapes and would falsely veto — re-hanging the very turn this path recovers.)
|
|
3695
|
+
*
|
|
3696
|
+
* Return contract (encoded so WI-3 need not re-derive it):
|
|
3697
|
+
* - `true` → a descendant is provably, actively generating (veto re-dispatch).
|
|
3698
|
+
* - `false` → descendants exist but none is actively generating (the restart
|
|
3699
|
+
* case), OR no descendant is found at all.
|
|
3700
|
+
* - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
|
|
3701
|
+
*
|
|
3702
|
+
* ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
|
|
3703
|
+
* as `false` and does NOT veto — a restart guarantees no live runner, so an
|
|
3704
|
+
* indeterminate cross-check almost always means "couldn't reach a child that no
|
|
3705
|
+
* longer exists". The inversion lives in the caller; this method just reports
|
|
3706
|
+
* true/false/null faithfully.
|
|
3707
|
+
*
|
|
3708
|
+
* VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
|
|
3709
|
+
* (already proven by the existing child-session interaction tests, via
|
|
3710
|
+
* `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
|
|
3711
|
+
* terminal state. We do NOT depend on any session-level `busy`/`idle` field —
|
|
3712
|
+
* there is none on `GET /session/:id`; OpenCode's busy state is in-memory
|
|
3713
|
+
* `SessionStatus` only.
|
|
3714
|
+
*/
|
|
3715
|
+
async isAnyDescendantSessionAlive(rootSessionId) {
|
|
3716
|
+
const sessions = await listSessions(this.port);
|
|
3717
|
+
if (!sessions) {
|
|
3718
|
+
this.log({
|
|
3719
|
+
level: "warn",
|
|
3720
|
+
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3721
|
+
});
|
|
3722
|
+
return null;
|
|
3723
|
+
}
|
|
3724
|
+
for (const candidate of sessions) {
|
|
3725
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
3726
|
+
if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
|
|
3727
|
+
const childMsgs = await getSessionMessages(this.port, candidate.id);
|
|
3728
|
+
if (isSessionActivelyGenerating(childMsgs)) {
|
|
3729
|
+
return true;
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
return false;
|
|
3733
|
+
}
|
|
3734
|
+
/**
|
|
3735
|
+
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3736
|
+
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
3737
|
+
* - `b1` — the reply itself is still in flight (`time.completed == null`) —
|
|
3738
|
+
* the aborted-in-flight production bug after a restart.
|
|
3739
|
+
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
3740
|
+
* (the sub-agent preamble — #253's shape).
|
|
3741
|
+
* - `other` — any other shape (defensive; a running row is normally b1 or b2).
|
|
3742
|
+
* Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
|
|
3743
|
+
* shape) directly rather than re-importing the module-private `completedOf`/
|
|
3744
|
+
* `finishOf` — this is a display label only, not a correctness predicate.
|
|
3745
|
+
*/
|
|
3746
|
+
replyCompletionShape(reply) {
|
|
3747
|
+
if (!reply) return "other";
|
|
3748
|
+
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
3749
|
+
if (completed == null) return "b1";
|
|
3750
|
+
const finish = reply.info?.finish ?? reply.finish;
|
|
3751
|
+
return finish === "tool-calls" ? "b2" : "other";
|
|
3752
|
+
}
|
|
2837
3753
|
/**
|
|
2838
3754
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2839
3755
|
*
|
|
@@ -2884,12 +3800,10 @@ var ChannelDriver = class {
|
|
|
2884
3800
|
}
|
|
2885
3801
|
return inFlight.sort(byOldest)[0];
|
|
2886
3802
|
}
|
|
2887
|
-
// -------------------------------------------------------------------------
|
|
2888
3803
|
// Evident API calls (combinedAuth thread routes)
|
|
2889
|
-
// -------------------------------------------------------------------------
|
|
2890
3804
|
async getPendingConversations() {
|
|
2891
3805
|
const res = await this.fetchImpl(
|
|
2892
|
-
`${this.apiUrl}/
|
|
3806
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
|
|
2893
3807
|
{
|
|
2894
3808
|
headers: { Authorization: this.getAuthHeader() }
|
|
2895
3809
|
}
|
|
@@ -2907,7 +3821,7 @@ var ChannelDriver = class {
|
|
|
2907
3821
|
}
|
|
2908
3822
|
async getPendingMessages(conversationId) {
|
|
2909
3823
|
const res = await this.fetchImpl(
|
|
2910
|
-
`${this.apiUrl}/
|
|
3824
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
2911
3825
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2912
3826
|
);
|
|
2913
3827
|
this.assertAuth(res, "fetching pending messages");
|
|
@@ -2931,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
2931
3845
|
*/
|
|
2932
3846
|
async getProcessingMessages() {
|
|
2933
3847
|
const res = await this.fetchImpl(
|
|
2934
|
-
`${this.apiUrl}/
|
|
3848
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
|
|
2935
3849
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2936
3850
|
);
|
|
2937
3851
|
this.assertAuth(res, "fetching processing messages");
|
|
@@ -2966,16 +3880,17 @@ var ChannelDriver = class {
|
|
|
2966
3880
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2967
3881
|
* retry vehicle for the swap-to-running.
|
|
2968
3882
|
*/
|
|
2969
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3883
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
2970
3884
|
const res = await this.fetchImpl(
|
|
2971
|
-
`${this.apiUrl}/
|
|
3885
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2972
3886
|
{
|
|
2973
3887
|
method: "PATCH",
|
|
2974
3888
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2975
3889
|
body: JSON.stringify({
|
|
2976
3890
|
status: "processing",
|
|
2977
3891
|
opencode_session_id: sessionId,
|
|
2978
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3892
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3893
|
+
...title ? { title } : {}
|
|
2979
3894
|
})
|
|
2980
3895
|
}
|
|
2981
3896
|
);
|
|
@@ -3014,16 +3929,18 @@ var ChannelDriver = class {
|
|
|
3014
3929
|
* watcher retries next tick within the
|
|
3015
3930
|
* deadline, Finding 4).
|
|
3016
3931
|
*/
|
|
3017
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3932
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3018
3933
|
const res = await this.fetchImpl(
|
|
3019
|
-
`${this.apiUrl}/
|
|
3934
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3020
3935
|
{
|
|
3021
3936
|
method: "PATCH",
|
|
3022
3937
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3023
3938
|
body: JSON.stringify({
|
|
3024
3939
|
status: "done",
|
|
3025
3940
|
opencode_session_id: sessionId,
|
|
3026
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3941
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3942
|
+
...title ? { title } : {},
|
|
3943
|
+
...usage ? usage : {}
|
|
3027
3944
|
})
|
|
3028
3945
|
}
|
|
3029
3946
|
);
|
|
@@ -3041,14 +3958,15 @@ var ChannelDriver = class {
|
|
|
3041
3958
|
* OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
|
|
3042
3959
|
* failure reason reaches the channel.
|
|
3043
3960
|
*/
|
|
3044
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
3961
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
3045
3962
|
const body = { status: "failed" };
|
|
3046
3963
|
if (sessionId !== void 0) body.opencode_session_id = sessionId;
|
|
3047
3964
|
if (error2 !== void 0) body.error = error2;
|
|
3965
|
+
if (usage) Object.assign(body, usage);
|
|
3048
3966
|
await this.callWithRetry(
|
|
3049
3967
|
"marking message as failed",
|
|
3050
3968
|
() => this.fetchImpl(
|
|
3051
|
-
`${this.apiUrl}/
|
|
3969
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3052
3970
|
{
|
|
3053
3971
|
method: "PATCH",
|
|
3054
3972
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3065,11 +3983,17 @@ var ChannelDriver = class {
|
|
|
3065
3983
|
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
3066
3984
|
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
3067
3985
|
* context (no silent catch, per development-workflow).
|
|
3986
|
+
*
|
|
3987
|
+
* Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
|
|
3988
|
+
* telemetry), but the `paused` liveness-clear uses it to know whether to
|
|
3989
|
+
* RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
|
|
3990
|
+
* stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
|
|
3991
|
+
* leaves liveness").
|
|
3068
3992
|
*/
|
|
3069
3993
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3070
3994
|
try {
|
|
3071
3995
|
const res = await this.fetchImpl(
|
|
3072
|
-
`${this.apiUrl}/
|
|
3996
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
3073
3997
|
{
|
|
3074
3998
|
method: "POST",
|
|
3075
3999
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3078,24 +4002,27 @@ var ChannelDriver = class {
|
|
|
3078
4002
|
);
|
|
3079
4003
|
if (!res.ok) {
|
|
3080
4004
|
this.log({
|
|
3081
|
-
level: "
|
|
4005
|
+
level: "warn",
|
|
3082
4006
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3083
4007
|
conversation_id: conversationId,
|
|
3084
4008
|
message_id: messageId
|
|
3085
4009
|
});
|
|
4010
|
+
return false;
|
|
3086
4011
|
}
|
|
4012
|
+
return true;
|
|
3087
4013
|
} catch (err) {
|
|
3088
4014
|
this.log({
|
|
3089
|
-
level: "
|
|
4015
|
+
level: "warn",
|
|
3090
4016
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3091
4017
|
conversation_id: conversationId,
|
|
3092
4018
|
message_id: messageId
|
|
3093
4019
|
});
|
|
4020
|
+
return false;
|
|
3094
4021
|
}
|
|
3095
4022
|
}
|
|
3096
4023
|
async persistSession(conversationId, sessionId) {
|
|
3097
4024
|
const res = await this.fetchImpl(
|
|
3098
|
-
`${this.apiUrl}/
|
|
4025
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
|
|
3099
4026
|
{
|
|
3100
4027
|
method: "PATCH",
|
|
3101
4028
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3121,7 +4048,7 @@ var ChannelDriver = class {
|
|
|
3121
4048
|
await this.callWithRetry(
|
|
3122
4049
|
"reporting interactive event",
|
|
3123
4050
|
() => this.fetchImpl(
|
|
3124
|
-
`${this.apiUrl}/
|
|
4051
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
3125
4052
|
{
|
|
3126
4053
|
method: "POST",
|
|
3127
4054
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3147,9 +4074,7 @@ var ChannelDriver = class {
|
|
|
3147
4074
|
return false;
|
|
3148
4075
|
}
|
|
3149
4076
|
}
|
|
3150
|
-
// -------------------------------------------------------------------------
|
|
3151
4077
|
// Retry wrapper
|
|
3152
|
-
// -------------------------------------------------------------------------
|
|
3153
4078
|
/**
|
|
3154
4079
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3155
4080
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3348,7 +4273,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3348
4273
|
if (!response.ok) {
|
|
3349
4274
|
const serverMessage = await readErrorMessage(response);
|
|
3350
4275
|
return {
|
|
3351
|
-
error: `Failed to resolve
|
|
4276
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3352
4277
|
};
|
|
3353
4278
|
}
|
|
3354
4279
|
const data = await response.json();
|
|
@@ -3356,17 +4281,17 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3356
4281
|
return { agent_id: data.agent_id };
|
|
3357
4282
|
}
|
|
3358
4283
|
return {
|
|
3359
|
-
error: "Cannot resolve
|
|
4284
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3360
4285
|
};
|
|
3361
4286
|
} catch (error2) {
|
|
3362
4287
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3363
|
-
return { error: `Failed to resolve
|
|
4288
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3364
4289
|
}
|
|
3365
4290
|
}
|
|
3366
4291
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3367
4292
|
const apiUrl = getApiUrlConfig();
|
|
3368
4293
|
try {
|
|
3369
|
-
const response = await fetch(`${apiUrl}/
|
|
4294
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
3370
4295
|
method: "POST",
|
|
3371
4296
|
headers: { Authorization: authHeader }
|
|
3372
4297
|
});
|
|
@@ -3385,7 +4310,7 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
3385
4310
|
async function getAgentInfo(agentId, authHeader) {
|
|
3386
4311
|
const apiUrl = getApiUrlConfig();
|
|
3387
4312
|
try {
|
|
3388
|
-
const response = await fetch(`${apiUrl}/
|
|
4313
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
3389
4314
|
headers: { Authorization: authHeader }
|
|
3390
4315
|
});
|
|
3391
4316
|
if (response.status === 401) {
|
|
@@ -3396,12 +4321,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3396
4321
|
const serverMessage = await readErrorMessage(response);
|
|
3397
4322
|
return {
|
|
3398
4323
|
valid: false,
|
|
3399
|
-
error: serverMessage ?? "You do not have access to this
|
|
4324
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3400
4325
|
};
|
|
3401
4326
|
}
|
|
3402
4327
|
if (response.status === 404) {
|
|
3403
4328
|
const serverMessage = await readErrorMessage(response);
|
|
3404
|
-
return { valid: false, error: serverMessage ?? `
|
|
4329
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3405
4330
|
}
|
|
3406
4331
|
if (!response.ok) {
|
|
3407
4332
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3414,13 +4339,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3414
4339
|
if (agent.agent_type !== "local") {
|
|
3415
4340
|
return {
|
|
3416
4341
|
valid: false,
|
|
3417
|
-
error: `
|
|
4342
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3418
4343
|
};
|
|
3419
4344
|
}
|
|
3420
4345
|
return { valid: true, agent };
|
|
3421
4346
|
} catch (error2) {
|
|
3422
4347
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3423
|
-
return { valid: false, error: `Failed to validate
|
|
4348
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3424
4349
|
}
|
|
3425
4350
|
}
|
|
3426
4351
|
|
|
@@ -3429,23 +4354,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
3429
4354
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3430
4355
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3431
4356
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3432
|
-
function
|
|
4357
|
+
function resolveLogLevel(options) {
|
|
4358
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4359
|
+
const validate = (value, source) => {
|
|
4360
|
+
const normalized = value.trim().toLowerCase();
|
|
4361
|
+
if (!accepted.includes(normalized)) {
|
|
4362
|
+
throw new Error(
|
|
4363
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4364
|
+
);
|
|
4365
|
+
}
|
|
4366
|
+
return normalized;
|
|
4367
|
+
};
|
|
4368
|
+
if (options.logLevel !== void 0) {
|
|
4369
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4370
|
+
}
|
|
4371
|
+
if (options.verbose) {
|
|
4372
|
+
return "debug";
|
|
4373
|
+
}
|
|
4374
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4375
|
+
if (env !== void 0 && env !== "") {
|
|
4376
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4377
|
+
}
|
|
4378
|
+
return "info";
|
|
4379
|
+
}
|
|
4380
|
+
function meetsThreshold(state, level) {
|
|
4381
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4382
|
+
}
|
|
4383
|
+
function log2(state, message, level = "info") {
|
|
4384
|
+
if (!meetsThreshold(state, level)) return;
|
|
3433
4385
|
if (state.json) {
|
|
3434
4386
|
console.log(
|
|
3435
4387
|
JSON.stringify({
|
|
3436
4388
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3437
|
-
level
|
|
4389
|
+
level,
|
|
3438
4390
|
message
|
|
3439
4391
|
})
|
|
3440
4392
|
);
|
|
3441
4393
|
} else if (!state.interactive) {
|
|
3442
|
-
const prefix =
|
|
4394
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3443
4395
|
console.log(`${prefix} ${message}`);
|
|
3444
4396
|
}
|
|
3445
4397
|
}
|
|
3446
4398
|
function logActivity(state, entry) {
|
|
4399
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4400
|
+
if (!meetsThreshold(state, level)) return;
|
|
3447
4401
|
const fullEntry = {
|
|
3448
4402
|
...entry,
|
|
4403
|
+
level,
|
|
3449
4404
|
timestamp: /* @__PURE__ */ new Date()
|
|
3450
4405
|
};
|
|
3451
4406
|
state.activityLog.push(fullEntry);
|
|
@@ -3454,9 +4409,9 @@ function logActivity(state, entry) {
|
|
|
3454
4409
|
}
|
|
3455
4410
|
if (!state.interactive) {
|
|
3456
4411
|
if (entry.type === "error") {
|
|
3457
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3458
|
-
} else if (entry.
|
|
3459
|
-
log2(state, entry.message);
|
|
4412
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4413
|
+
} else if (entry.message) {
|
|
4414
|
+
log2(state, entry.message, level);
|
|
3460
4415
|
}
|
|
3461
4416
|
}
|
|
3462
4417
|
}
|
|
@@ -3583,7 +4538,7 @@ async function driveChannels(state, driver) {
|
|
|
3583
4538
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
3584
4539
|
if (state.interactive) displayStatus(state);
|
|
3585
4540
|
}
|
|
3586
|
-
await new Promise((
|
|
4541
|
+
await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
|
|
3587
4542
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
3588
4543
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
3589
4544
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -3594,6 +4549,81 @@ async function driveChannels(state, driver) {
|
|
|
3594
4549
|
}
|
|
3595
4550
|
}
|
|
3596
4551
|
}
|
|
4552
|
+
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
4553
|
+
async function runSweep(state, driver, config2) {
|
|
4554
|
+
const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
|
|
4555
|
+
try {
|
|
4556
|
+
const sessions = await listSessions(state.port);
|
|
4557
|
+
if (sessions === null) {
|
|
4558
|
+
logActivity(state, {
|
|
4559
|
+
type: "info",
|
|
4560
|
+
message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
|
|
4561
|
+
});
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
const toDelete = selectSessionsToDelete(
|
|
4565
|
+
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
4566
|
+
{
|
|
4567
|
+
maxAgeMs: config2.maxAgeMs,
|
|
4568
|
+
maxCount: config2.maxCount,
|
|
4569
|
+
nowMs: Date.now(),
|
|
4570
|
+
protectedIds: driver.protectedSessionIds()
|
|
4571
|
+
}
|
|
4572
|
+
);
|
|
4573
|
+
const protectedNow = driver.protectedSessionIds();
|
|
4574
|
+
let deleted = 0;
|
|
4575
|
+
let failed = 0;
|
|
4576
|
+
let skippedNewlyActive = 0;
|
|
4577
|
+
for (const id of toDelete) {
|
|
4578
|
+
if (protectedNow.has(id)) {
|
|
4579
|
+
skippedNewlyActive++;
|
|
4580
|
+
logActivity(state, {
|
|
4581
|
+
type: "info",
|
|
4582
|
+
message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
|
|
4583
|
+
});
|
|
4584
|
+
continue;
|
|
4585
|
+
}
|
|
4586
|
+
if (await deleteSession(state.port, id)) deleted++;
|
|
4587
|
+
else failed++;
|
|
4588
|
+
}
|
|
4589
|
+
const failedNote = failed > 0 ? `, failed ${failed}` : "";
|
|
4590
|
+
const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
|
|
4591
|
+
logActivity(state, {
|
|
4592
|
+
type: "info",
|
|
4593
|
+
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
4594
|
+
});
|
|
4595
|
+
} catch (error2) {
|
|
4596
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4597
|
+
logActivity(state, {
|
|
4598
|
+
type: "error",
|
|
4599
|
+
error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
function scheduleSessionCleanup(state, driver, options) {
|
|
4604
|
+
const config2 = resolveSessionCleanupConfig(
|
|
4605
|
+
{
|
|
4606
|
+
maxAge: options.sessionCleanupMaxAge,
|
|
4607
|
+
maxCount: options.sessionCleanupMaxCount,
|
|
4608
|
+
interval: options.sessionCleanupInterval
|
|
4609
|
+
},
|
|
4610
|
+
process.env
|
|
4611
|
+
);
|
|
4612
|
+
for (const warning2 of config2.warnings) {
|
|
4613
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4614
|
+
}
|
|
4615
|
+
if (!config2.enabled) return;
|
|
4616
|
+
logActivity(state, {
|
|
4617
|
+
type: "info",
|
|
4618
|
+
message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
|
|
4619
|
+
});
|
|
4620
|
+
const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
|
|
4621
|
+
const firstSweep = setTimeout(
|
|
4622
|
+
() => void runSweep(state, driver, config2),
|
|
4623
|
+
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
4624
|
+
);
|
|
4625
|
+
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
4626
|
+
}
|
|
3597
4627
|
async function notifyOffline(state) {
|
|
3598
4628
|
if (!state.agentId || !state.authHeader) return;
|
|
3599
4629
|
if (!state.connected) {
|
|
@@ -3602,7 +4632,7 @@ async function notifyOffline(state) {
|
|
|
3602
4632
|
}
|
|
3603
4633
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
3604
4634
|
if (result.ok) {
|
|
3605
|
-
log2(state, "Notified Evident the
|
|
4635
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
3606
4636
|
} else {
|
|
3607
4637
|
logActivity(state, {
|
|
3608
4638
|
type: "error",
|
|
@@ -3613,6 +4643,11 @@ async function notifyOffline(state) {
|
|
|
3613
4643
|
}
|
|
3614
4644
|
async function cleanup(state, opts = {}) {
|
|
3615
4645
|
state.running = false;
|
|
4646
|
+
for (const timer of state.sessionCleanupTimers) {
|
|
4647
|
+
clearInterval(timer);
|
|
4648
|
+
clearTimeout(timer);
|
|
4649
|
+
}
|
|
4650
|
+
state.sessionCleanupTimers = [];
|
|
3616
4651
|
if (opts.graceful && state.channelDriver) {
|
|
3617
4652
|
state.channelDriver.stop();
|
|
3618
4653
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -3647,14 +4682,29 @@ async function cleanup(state, opts = {}) {
|
|
|
3647
4682
|
}
|
|
3648
4683
|
async function run(options) {
|
|
3649
4684
|
const interactive = isInteractive(options.json);
|
|
4685
|
+
let logLevel;
|
|
4686
|
+
try {
|
|
4687
|
+
logLevel = resolveLogLevel(options);
|
|
4688
|
+
} catch (error2) {
|
|
4689
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4690
|
+
if (options.json) {
|
|
4691
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4692
|
+
} else {
|
|
4693
|
+
printError(message);
|
|
4694
|
+
}
|
|
4695
|
+
await shutdownTelemetry();
|
|
4696
|
+
process.exit(1);
|
|
4697
|
+
return;
|
|
4698
|
+
}
|
|
3650
4699
|
const state = {
|
|
3651
|
-
agentId: options.agent || "",
|
|
4700
|
+
agentId: options.runner || options.agent || "",
|
|
3652
4701
|
agentName: null,
|
|
3653
4702
|
port: options.port ?? 4096,
|
|
3654
4703
|
conversationFilter: options.conversation ?? null,
|
|
3655
4704
|
idleTimeout: options.idleTimeout ?? null,
|
|
3656
4705
|
json: options.json ?? false,
|
|
3657
4706
|
interactive,
|
|
4707
|
+
logLevel,
|
|
3658
4708
|
connected: false,
|
|
3659
4709
|
opencodeConnected: false,
|
|
3660
4710
|
opencodeVersion: null,
|
|
@@ -3666,13 +4716,22 @@ async function run(options) {
|
|
|
3666
4716
|
activityLog: [],
|
|
3667
4717
|
messageCount: 0,
|
|
3668
4718
|
lastProxiedActivityAt: null,
|
|
4719
|
+
sessionCleanupTimers: [],
|
|
3669
4720
|
authHeader: ""
|
|
3670
4721
|
};
|
|
4722
|
+
if (!options.runner && options.agent) {
|
|
4723
|
+
telemetry.info(
|
|
4724
|
+
EventTypes.DEPRECATED_AGENT_FLAG_USED,
|
|
4725
|
+
"Deprecated --agent flag used instead of --runner",
|
|
4726
|
+
{ command: "run" },
|
|
4727
|
+
state.agentId
|
|
4728
|
+
);
|
|
4729
|
+
}
|
|
3671
4730
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
3672
4731
|
log2(
|
|
3673
4732
|
state,
|
|
3674
|
-
"
|
|
3675
|
-
|
|
4733
|
+
"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.",
|
|
4734
|
+
"warn"
|
|
3676
4735
|
);
|
|
3677
4736
|
}
|
|
3678
4737
|
const handleSignal = async () => {
|
|
@@ -3696,7 +4755,9 @@ async function run(options) {
|
|
|
3696
4755
|
if (!interactive) {
|
|
3697
4756
|
printError("Authentication required");
|
|
3698
4757
|
blank();
|
|
3699
|
-
console.log(
|
|
4758
|
+
console.log(
|
|
4759
|
+
chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
|
|
4760
|
+
);
|
|
3700
4761
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
3701
4762
|
blank();
|
|
3702
4763
|
process.exit(1);
|
|
@@ -3710,26 +4771,46 @@ async function run(options) {
|
|
|
3710
4771
|
);
|
|
3711
4772
|
}
|
|
3712
4773
|
state.authHeader = getAuthHeader(credentials2);
|
|
4774
|
+
if (credentials2.notice) {
|
|
4775
|
+
log2(state, credentials2.notice, "warn");
|
|
4776
|
+
if (state.interactive && !state.json) {
|
|
4777
|
+
logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
if (credentials2.keySource === "agent_key") {
|
|
4781
|
+
telemetry.info(
|
|
4782
|
+
EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
|
|
4783
|
+
"Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
|
|
4784
|
+
{ command: "run" },
|
|
4785
|
+
state.agentId
|
|
4786
|
+
);
|
|
4787
|
+
}
|
|
3713
4788
|
if (!state.agentId) {
|
|
3714
4789
|
if (credentials2.authType === "agent_key") {
|
|
3715
4790
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
3716
4791
|
if (resolved.agent_id) {
|
|
3717
4792
|
state.agentId = resolved.agent_id;
|
|
3718
|
-
log2(state, `Resolved
|
|
4793
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
3719
4794
|
if (state.interactive && !state.json) {
|
|
3720
4795
|
logActivity(state, {
|
|
3721
4796
|
type: "info",
|
|
3722
|
-
message: `
|
|
4797
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
3723
4798
|
});
|
|
3724
4799
|
}
|
|
3725
4800
|
} else {
|
|
3726
|
-
printError(resolved.error || "Failed to resolve
|
|
4801
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
3727
4802
|
process.exit(1);
|
|
3728
4803
|
}
|
|
3729
4804
|
} else {
|
|
3730
|
-
printError(
|
|
4805
|
+
printError(
|
|
4806
|
+
"--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
|
|
4807
|
+
);
|
|
3731
4808
|
blank();
|
|
3732
|
-
console.log(
|
|
4809
|
+
console.log(
|
|
4810
|
+
chalk6.dim(
|
|
4811
|
+
"Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
|
|
4812
|
+
)
|
|
4813
|
+
);
|
|
3733
4814
|
blank();
|
|
3734
4815
|
process.exit(1);
|
|
3735
4816
|
}
|
|
@@ -3751,7 +4832,7 @@ async function run(options) {
|
|
|
3751
4832
|
console.log(chalk6.bold("Evident Run"));
|
|
3752
4833
|
console.log(chalk6.dim("-".repeat(40)));
|
|
3753
4834
|
}
|
|
3754
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
4835
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
3755
4836
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
3756
4837
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
3757
4838
|
spinner?.fail("Authentication failed");
|
|
@@ -3763,14 +4844,14 @@ async function run(options) {
|
|
|
3763
4844
|
"Login successful! Retrying..."
|
|
3764
4845
|
);
|
|
3765
4846
|
state.authHeader = getAuthHeader(credentials2);
|
|
3766
|
-
spinner?.start("Validating
|
|
4847
|
+
spinner?.start("Validating runner...");
|
|
3767
4848
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
3768
4849
|
}
|
|
3769
4850
|
if (!validation.valid) {
|
|
3770
|
-
spinner?.fail(`
|
|
4851
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
3771
4852
|
throw new Error(validation.error);
|
|
3772
4853
|
}
|
|
3773
|
-
spinner?.succeed(`
|
|
4854
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
3774
4855
|
state.agentName = validation.agent.name;
|
|
3775
4856
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
3776
4857
|
try {
|
|
@@ -3788,9 +4869,9 @@ async function run(options) {
|
|
|
3788
4869
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
3789
4870
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
3790
4871
|
if (versionWarning) {
|
|
3791
|
-
log2(state, versionWarning,
|
|
4872
|
+
log2(state, versionWarning, "warn");
|
|
3792
4873
|
if (state.interactive && !state.json) {
|
|
3793
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4874
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
3794
4875
|
}
|
|
3795
4876
|
}
|
|
3796
4877
|
} catch (error2) {
|
|
@@ -3805,11 +4886,17 @@ async function run(options) {
|
|
|
3805
4886
|
getAuthHeader: () => state.authHeader,
|
|
3806
4887
|
conversationFilter: state.conversationFilter,
|
|
3807
4888
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
3808
|
-
log: (entry) =>
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
4889
|
+
log: (entry) => (
|
|
4890
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4891
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4892
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4893
|
+
logActivity(state, {
|
|
4894
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4895
|
+
level: entry.level,
|
|
4896
|
+
message: entry.message,
|
|
4897
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4898
|
+
})
|
|
4899
|
+
)
|
|
3813
4900
|
});
|
|
3814
4901
|
state.channelDriver = channelDriver;
|
|
3815
4902
|
const connection = new RunnerConnection({
|
|
@@ -3823,7 +4910,7 @@ async function run(options) {
|
|
|
3823
4910
|
state.agentId = agentId;
|
|
3824
4911
|
logActivity(state, {
|
|
3825
4912
|
type: "info",
|
|
3826
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
4913
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
3827
4914
|
});
|
|
3828
4915
|
emitAgentConnected(state.agentId, {
|
|
3829
4916
|
port: state.port,
|
|
@@ -3908,6 +4995,7 @@ async function run(options) {
|
|
|
3908
4995
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
3909
4996
|
throw error2;
|
|
3910
4997
|
}
|
|
4998
|
+
scheduleSessionCleanup(state, channelDriver, options);
|
|
3911
4999
|
if (!interactive || state.json) {
|
|
3912
5000
|
log2(state, "Driving channel messages...");
|
|
3913
5001
|
}
|
|
@@ -3937,7 +5025,7 @@ async function run(options) {
|
|
|
3937
5025
|
}
|
|
3938
5026
|
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
3939
5027
|
command: "run",
|
|
3940
|
-
agentId: options.agent
|
|
5028
|
+
agentId: options.runner || options.agent
|
|
3941
5029
|
});
|
|
3942
5030
|
await shutdownTelemetry();
|
|
3943
5031
|
process.exit(1);
|
|
@@ -3962,15 +5050,35 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
3962
5050
|
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);
|
|
3963
5051
|
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 }));
|
|
3964
5052
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
3965
|
-
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "
|
|
5053
|
+
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Runner ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("--runner [id]", "Alias for --agent (preferred name; wins if both are given)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
5054
|
+
"--log-level <level>",
|
|
5055
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
5056
|
+
).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(
|
|
5057
|
+
"--session-cleanup-max-age <duration>",
|
|
5058
|
+
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
5059
|
+
).option(
|
|
5060
|
+
"--session-cleanup-max-count <n>",
|
|
5061
|
+
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
5062
|
+
).option(
|
|
5063
|
+
"--session-cleanup-interval <duration>",
|
|
5064
|
+
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
5065
|
+
).action(
|
|
3966
5066
|
(options) => {
|
|
3967
5067
|
run({
|
|
3968
5068
|
agent: options.agent,
|
|
5069
|
+
runner: options.runner,
|
|
3969
5070
|
port: parseInt(options.port, 10),
|
|
5071
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
5072
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
5073
|
+
logLevel: options.logLevel,
|
|
3970
5074
|
verbose: options.verbose,
|
|
3971
5075
|
conversation: options.conversation,
|
|
3972
5076
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
3973
|
-
json: options.json
|
|
5077
|
+
json: options.json,
|
|
5078
|
+
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
5079
|
+
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
5080
|
+
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
5081
|
+
sessionCleanupInterval: options.sessionCleanupInterval
|
|
3974
5082
|
});
|
|
3975
5083
|
}
|
|
3976
5084
|
);
|