@evident-ai/cli 3.0.1-dev.fffc02d → 3.1.1-dev.702ee74
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 +17 -15
- package/dist/index.js +1127 -136
- 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
|
}
|
|
@@ -706,7 +706,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
706
706
|
if (health.healthy) {
|
|
707
707
|
return health;
|
|
708
708
|
}
|
|
709
|
-
await new Promise((
|
|
709
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
710
710
|
}
|
|
711
711
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
712
712
|
}
|
|
@@ -1078,6 +1078,84 @@ async function getSessionMessages(port, sessionId) {
|
|
|
1078
1078
|
return null;
|
|
1079
1079
|
}
|
|
1080
1080
|
}
|
|
1081
|
+
function isSessionActivelyGenerating(messages) {
|
|
1082
|
+
if (!messages || messages.length === 0) return false;
|
|
1083
|
+
const last = messages[messages.length - 1];
|
|
1084
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1085
|
+
return completedOf(last) == null;
|
|
1086
|
+
}
|
|
1087
|
+
function sessionLastActivityMs(session) {
|
|
1088
|
+
const candidates = [
|
|
1089
|
+
session.time?.updated,
|
|
1090
|
+
session.time?.created,
|
|
1091
|
+
session.time_updated,
|
|
1092
|
+
session.time_created,
|
|
1093
|
+
session.updated,
|
|
1094
|
+
session.created
|
|
1095
|
+
];
|
|
1096
|
+
for (const c of candidates) {
|
|
1097
|
+
if (typeof c === "number" && Number.isFinite(c)) return c;
|
|
1098
|
+
}
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
async function listSessions(port) {
|
|
1102
|
+
try {
|
|
1103
|
+
const res = await fetch(`${opencodeBase(port)}/session`);
|
|
1104
|
+
if (!res.ok) return null;
|
|
1105
|
+
const body = await res.json();
|
|
1106
|
+
return Array.isArray(body) ? body : null;
|
|
1107
|
+
} catch {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
async function deleteSession(port, id) {
|
|
1112
|
+
try {
|
|
1113
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
|
|
1114
|
+
return res.status >= 200 && res.status < 300;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async function sessionExists(port, id) {
|
|
1120
|
+
try {
|
|
1121
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`);
|
|
1122
|
+
if (res.status >= 200 && res.status < 300) return true;
|
|
1123
|
+
if (res.status === 404) return false;
|
|
1124
|
+
return null;
|
|
1125
|
+
} catch {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
async function getSessionStatuses(port) {
|
|
1130
|
+
try {
|
|
1131
|
+
const res = await fetch(`${opencodeBase(port)}/session/status`);
|
|
1132
|
+
if (!res.ok) {
|
|
1133
|
+
console.error(
|
|
1134
|
+
`[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
|
|
1135
|
+
);
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
const body = await res.json();
|
|
1139
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
1140
|
+
console.error(
|
|
1141
|
+
`[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
|
|
1142
|
+
);
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
return body;
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
console.error(
|
|
1148
|
+
`[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1149
|
+
);
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function isSessionOngoing(port, id) {
|
|
1154
|
+
const map = await getSessionStatuses(port);
|
|
1155
|
+
if (map == null) return null;
|
|
1156
|
+
const entry = map[id];
|
|
1157
|
+
return entry != null && entry.type !== "idle";
|
|
1158
|
+
}
|
|
1081
1159
|
async function createOpenCodeSession(port, directory) {
|
|
1082
1160
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1083
1161
|
if (directory && directory.trim()) {
|
|
@@ -1095,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1095
1173
|
const data = await response.json();
|
|
1096
1174
|
return data.id;
|
|
1097
1175
|
}
|
|
1176
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1177
|
+
try {
|
|
1178
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1179
|
+
if (!res.ok) {
|
|
1180
|
+
console.error(
|
|
1181
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1182
|
+
);
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
const body = await res.json();
|
|
1186
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1187
|
+
if (!providers) {
|
|
1188
|
+
console.error(
|
|
1189
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1190
|
+
);
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1194
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1195
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1196
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1197
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1198
|
+
if (!provider && !providerId) {
|
|
1199
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1200
|
+
if (defaultProviderIds.length === 1) {
|
|
1201
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (!provider || !provider.models) return null;
|
|
1205
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1206
|
+
const def = defaults2[provider.id];
|
|
1207
|
+
if (typeof def === "string") modelId = def;
|
|
1208
|
+
}
|
|
1209
|
+
if (!modelId) {
|
|
1210
|
+
if (providerId) {
|
|
1211
|
+
const keys = Object.keys(provider.models);
|
|
1212
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1213
|
+
}
|
|
1214
|
+
if (!modelId) return null;
|
|
1215
|
+
}
|
|
1216
|
+
const entry = provider.models[modelId];
|
|
1217
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1218
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
console.error(
|
|
1221
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1222
|
+
);
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function buildFileParts(attachments, capable) {
|
|
1227
|
+
const outcomes = [];
|
|
1228
|
+
const parts = [];
|
|
1229
|
+
const capabilityUnknown = capable === null;
|
|
1230
|
+
if (capable !== true) {
|
|
1231
|
+
for (const a of attachments.inputs) {
|
|
1232
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1233
|
+
}
|
|
1234
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1235
|
+
}
|
|
1236
|
+
for (const a of attachments.inputs) {
|
|
1237
|
+
let dataUrl = null;
|
|
1238
|
+
try {
|
|
1239
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.error(
|
|
1242
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1243
|
+
);
|
|
1244
|
+
dataUrl = null;
|
|
1245
|
+
}
|
|
1246
|
+
if (dataUrl == null) {
|
|
1247
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
parts.push({
|
|
1251
|
+
type: "file",
|
|
1252
|
+
mime: a.mime,
|
|
1253
|
+
url: dataUrl,
|
|
1254
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1255
|
+
});
|
|
1256
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1257
|
+
}
|
|
1258
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1259
|
+
}
|
|
1098
1260
|
function messageText(m) {
|
|
1099
1261
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1100
1262
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1101
1263
|
}
|
|
1102
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1264
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1103
1265
|
const before = await getSessionMessages(port, sessionId);
|
|
1104
1266
|
const knownUserIds = new Set(
|
|
1105
1267
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1106
1268
|
);
|
|
1269
|
+
const parts = [{ type: "text", text: content }];
|
|
1270
|
+
let pendingOutcomes = null;
|
|
1271
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1272
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1273
|
+
const {
|
|
1274
|
+
parts: fileParts,
|
|
1275
|
+
outcomes,
|
|
1276
|
+
capabilityUnknown
|
|
1277
|
+
} = await buildFileParts(attachments, capable);
|
|
1278
|
+
parts.push(...fileParts);
|
|
1279
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1280
|
+
}
|
|
1107
1281
|
const body = {
|
|
1108
|
-
parts
|
|
1282
|
+
parts
|
|
1109
1283
|
};
|
|
1110
1284
|
if (options?.agent) {
|
|
1111
1285
|
body.agent = options.agent;
|
|
@@ -1144,10 +1318,13 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1144
1318
|
best = { id, created };
|
|
1145
1319
|
}
|
|
1146
1320
|
}
|
|
1147
|
-
if (best)
|
|
1321
|
+
if (best) {
|
|
1322
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1323
|
+
return best.id;
|
|
1324
|
+
}
|
|
1148
1325
|
}
|
|
1149
1326
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1150
|
-
await new Promise((
|
|
1327
|
+
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
1151
1328
|
}
|
|
1152
1329
|
}
|
|
1153
1330
|
return null;
|
|
@@ -1204,6 +1381,11 @@ function messageRunState(messages, userMessageId) {
|
|
|
1204
1381
|
if (isAssistantInFlight(reply)) return "running";
|
|
1205
1382
|
return errorOf(reply) != null ? "failed" : "done";
|
|
1206
1383
|
}
|
|
1384
|
+
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
1385
|
+
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
1386
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1387
|
+
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1388
|
+
}
|
|
1207
1389
|
function messageError(messages, userMessageId) {
|
|
1208
1390
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1209
1391
|
const error2 = errorOf(reply);
|
|
@@ -1224,6 +1406,110 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1224
1406
|
);
|
|
1225
1407
|
}
|
|
1226
1408
|
|
|
1409
|
+
// src/lib/opencode/session-cleanup.ts
|
|
1410
|
+
var DURATION_UNIT_MS = {
|
|
1411
|
+
s: 1e3,
|
|
1412
|
+
m: 60 * 1e3,
|
|
1413
|
+
h: 60 * 60 * 1e3,
|
|
1414
|
+
d: 24 * 60 * 60 * 1e3
|
|
1415
|
+
};
|
|
1416
|
+
function parseDurationMs(input) {
|
|
1417
|
+
const trimmed = input.trim();
|
|
1418
|
+
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
|
1419
|
+
if (!match) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
const value = Number(match[1]);
|
|
1425
|
+
if (value <= 0) {
|
|
1426
|
+
throw new Error(`Invalid duration "${input}": must be a positive value.`);
|
|
1427
|
+
}
|
|
1428
|
+
return value * DURATION_UNIT_MS[match[2]];
|
|
1429
|
+
}
|
|
1430
|
+
function selectSessionsToDelete(sessions, opts) {
|
|
1431
|
+
const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
|
|
1432
|
+
if (maxAgeMs === void 0 && maxCount === void 0) return [];
|
|
1433
|
+
const ageEligible = (s) => {
|
|
1434
|
+
if (maxAgeMs === void 0) return false;
|
|
1435
|
+
if (s.lastActivityMs === null) return true;
|
|
1436
|
+
return nowMs - s.lastActivityMs > maxAgeMs;
|
|
1437
|
+
};
|
|
1438
|
+
const countEligibleIds = /* @__PURE__ */ new Set();
|
|
1439
|
+
if (maxCount !== void 0) {
|
|
1440
|
+
const byActivityDesc = [...sessions].sort(
|
|
1441
|
+
(a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
|
|
1442
|
+
);
|
|
1443
|
+
for (const s of byActivityDesc.slice(maxCount)) {
|
|
1444
|
+
countEligibleIds.add(s.id);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const toDelete = [];
|
|
1448
|
+
for (const s of sessions) {
|
|
1449
|
+
if (protectedIds.has(s.id)) continue;
|
|
1450
|
+
if (ageEligible(s) || countEligibleIds.has(s.id)) {
|
|
1451
|
+
toDelete.push(s.id);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return toDelete;
|
|
1455
|
+
}
|
|
1456
|
+
var DEFAULT_INTERVAL = "1h";
|
|
1457
|
+
function resolve(flag, envValue, fallback) {
|
|
1458
|
+
return flag ?? envValue ?? fallback;
|
|
1459
|
+
}
|
|
1460
|
+
function parseMaxCount(input) {
|
|
1461
|
+
const trimmed = input.trim();
|
|
1462
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1463
|
+
throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
|
|
1464
|
+
}
|
|
1465
|
+
const value = Number(trimmed);
|
|
1466
|
+
if (value <= 0) {
|
|
1467
|
+
throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
|
|
1468
|
+
}
|
|
1469
|
+
return value;
|
|
1470
|
+
}
|
|
1471
|
+
function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
1472
|
+
const warnings = [];
|
|
1473
|
+
const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
|
|
1474
|
+
const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
|
|
1475
|
+
const intervalRaw = resolve(
|
|
1476
|
+
flags.interval,
|
|
1477
|
+
env.EVIDENT_SESSION_CLEANUP_INTERVAL,
|
|
1478
|
+
DEFAULT_INTERVAL
|
|
1479
|
+
);
|
|
1480
|
+
let maxAgeMs;
|
|
1481
|
+
if (maxAgeRaw !== void 0) {
|
|
1482
|
+
try {
|
|
1483
|
+
maxAgeMs = parseDurationMs(maxAgeRaw);
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
warnings.push(
|
|
1486
|
+
`Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
let maxCount;
|
|
1491
|
+
if (maxCountRaw !== void 0) {
|
|
1492
|
+
try {
|
|
1493
|
+
maxCount = parseMaxCount(maxCountRaw);
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
warnings.push(
|
|
1496
|
+
`Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
let intervalMs;
|
|
1501
|
+
try {
|
|
1502
|
+
intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
warnings.push(
|
|
1505
|
+
`Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
|
|
1506
|
+
);
|
|
1507
|
+
intervalMs = parseDurationMs(DEFAULT_INTERVAL);
|
|
1508
|
+
}
|
|
1509
|
+
const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
|
|
1510
|
+
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1227
1513
|
// src/lib/tunnel/connection.ts
|
|
1228
1514
|
import WebSocket2 from "ws";
|
|
1229
1515
|
|
|
@@ -1314,12 +1600,12 @@ var StreamForwarder = class {
|
|
|
1314
1600
|
let endBody;
|
|
1315
1601
|
if (has_body) {
|
|
1316
1602
|
const chunks = [];
|
|
1317
|
-
bodyPromise = new Promise((
|
|
1603
|
+
bodyPromise = new Promise((resolve2) => {
|
|
1318
1604
|
pushBody = (buf) => {
|
|
1319
1605
|
chunks.push(buf);
|
|
1320
1606
|
};
|
|
1321
1607
|
endBody = () => {
|
|
1322
|
-
|
|
1608
|
+
resolve2(Buffer.concat(chunks));
|
|
1323
1609
|
};
|
|
1324
1610
|
});
|
|
1325
1611
|
}
|
|
@@ -1437,7 +1723,7 @@ function connectTunnel(options) {
|
|
|
1437
1723
|
} = options;
|
|
1438
1724
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1439
1725
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1440
|
-
return new Promise((
|
|
1726
|
+
return new Promise((resolve2, reject) => {
|
|
1441
1727
|
const ws = new WebSocket2(url, {
|
|
1442
1728
|
headers: {
|
|
1443
1729
|
Authorization: authHeader
|
|
@@ -1502,7 +1788,7 @@ function connectTunnel(options) {
|
|
|
1502
1788
|
clearTimeout(connectionTimeout);
|
|
1503
1789
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1504
1790
|
onConnected?.(connectedAgentId);
|
|
1505
|
-
|
|
1791
|
+
resolve2({
|
|
1506
1792
|
ws,
|
|
1507
1793
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1508
1794
|
});
|
|
@@ -1624,6 +1910,17 @@ function messageIdOf(m) {
|
|
|
1624
1910
|
const infoId = m.info?.id;
|
|
1625
1911
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1626
1912
|
}
|
|
1913
|
+
function cleanImageMime(contentType) {
|
|
1914
|
+
if (!contentType) return null;
|
|
1915
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
1916
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
1917
|
+
}
|
|
1918
|
+
var LOG_LEVELS = {
|
|
1919
|
+
debug: 0,
|
|
1920
|
+
info: 1,
|
|
1921
|
+
warn: 2,
|
|
1922
|
+
error: 3
|
|
1923
|
+
};
|
|
1627
1924
|
var DEFAULT_RETRY_POLICY = {
|
|
1628
1925
|
maxAttempts: 6,
|
|
1629
1926
|
baseDelayMs: 500,
|
|
@@ -1632,6 +1929,9 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1632
1929
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1633
1930
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1634
1931
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1932
|
+
var HEARTBEAT_MS = 6e4;
|
|
1933
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1934
|
+
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1635
1935
|
var ChannelAuthError = class extends Error {
|
|
1636
1936
|
constructor(message) {
|
|
1637
1937
|
super(message);
|
|
@@ -1728,6 +2028,15 @@ var ChannelDriver = class {
|
|
|
1728
2028
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1729
2029
|
*/
|
|
1730
2030
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
2031
|
+
/**
|
|
2032
|
+
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
2033
|
+
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
2034
|
+
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
2035
|
+
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
2036
|
+
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
2037
|
+
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
2038
|
+
*/
|
|
2039
|
+
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
1731
2040
|
/**
|
|
1732
2041
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1733
2042
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -1742,6 +2051,15 @@ var ChannelDriver = class {
|
|
|
1742
2051
|
* so the NEXT tick may retry exactly once more).
|
|
1743
2052
|
*/
|
|
1744
2053
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2054
|
+
/**
|
|
2055
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2056
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2057
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2058
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2059
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2060
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2061
|
+
*/
|
|
2062
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1745
2063
|
/**
|
|
1746
2064
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1747
2065
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1759,6 +2077,16 @@ var ChannelDriver = class {
|
|
|
1759
2077
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1760
2078
|
*/
|
|
1761
2079
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2080
|
+
/**
|
|
2081
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2082
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
2083
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
2084
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
2085
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2086
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2087
|
+
* no watcher) can resolve the title.
|
|
2088
|
+
*/
|
|
2089
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1762
2090
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1763
2091
|
draining = false;
|
|
1764
2092
|
/**
|
|
@@ -1796,9 +2124,6 @@ var ChannelDriver = class {
|
|
|
1796
2124
|
get opencodeBase() {
|
|
1797
2125
|
return `http://127.0.0.1:${this.port}`;
|
|
1798
2126
|
}
|
|
1799
|
-
// -------------------------------------------------------------------------
|
|
1800
|
-
// Public API
|
|
1801
|
-
// -------------------------------------------------------------------------
|
|
1802
2127
|
/**
|
|
1803
2128
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1804
2129
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -1854,6 +2179,28 @@ var ChannelDriver = class {
|
|
|
1854
2179
|
}
|
|
1855
2180
|
return false;
|
|
1856
2181
|
}
|
|
2182
|
+
/**
|
|
2183
|
+
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2184
|
+
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
2185
|
+
* `watchers` entry whose `inFlight` set is non-empty — the same predicate
|
|
2186
|
+
* `hasInFlightWatchers()` uses, lifted to return the ids.
|
|
2187
|
+
*
|
|
2188
|
+
* Deliberately does NOT include `this.sessions` (the permanent, never-pruned
|
|
2189
|
+
* conversation→session cache). Protecting every bound-but-idle session there
|
|
2190
|
+
* would shield nearly every session and defeat cleanup — AND it is unnecessary:
|
|
2191
|
+
* `ensureSession` is self-healing (it recreates a session whose id no longer
|
|
2192
|
+
* exists), so deleting an idle bound session is harmless — the conversation's
|
|
2193
|
+
* next turn transparently rebinds a fresh one. The only thing worth protecting
|
|
2194
|
+
* is a session with a turn ACTIVELY in flight right now: tearing that down
|
|
2195
|
+
* mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
|
|
2196
|
+
*/
|
|
2197
|
+
protectedSessionIds() {
|
|
2198
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2199
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
2200
|
+
if (watcher.inFlight.size > 0) ids.add(sessionId);
|
|
2201
|
+
}
|
|
2202
|
+
return ids;
|
|
2203
|
+
}
|
|
1857
2204
|
/**
|
|
1858
2205
|
* Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
|
|
1859
2206
|
* this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
|
|
@@ -1918,9 +2265,7 @@ var ChannelDriver = class {
|
|
|
1918
2265
|
if (!stillLive) return;
|
|
1919
2266
|
}
|
|
1920
2267
|
}
|
|
1921
|
-
// -------------------------------------------------------------------------
|
|
1922
2268
|
// Conversation processing (WI-3 — async dispatch)
|
|
1923
|
-
// -------------------------------------------------------------------------
|
|
1924
2269
|
/**
|
|
1925
2270
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1926
2271
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -1952,13 +2297,24 @@ var ChannelDriver = class {
|
|
|
1952
2297
|
conversation_id: conv.id,
|
|
1953
2298
|
message_id: message.id
|
|
1954
2299
|
});
|
|
2300
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
1955
2301
|
opencodeMessageId = await this.dispatchLocked(
|
|
1956
2302
|
sessionId,
|
|
1957
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2303
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
1958
2304
|
);
|
|
1959
2305
|
} catch (err) {
|
|
1960
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
1961
2307
|
this.dispatched.delete(message.id);
|
|
2308
|
+
if (await sessionExists(this.port, sessionId) === false) {
|
|
2309
|
+
this.sessions.delete(conv.id);
|
|
2310
|
+
this.log({
|
|
2311
|
+
level: "warn",
|
|
2312
|
+
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.`,
|
|
2313
|
+
conversation_id: conv.id,
|
|
2314
|
+
message_id: message.id
|
|
2315
|
+
});
|
|
2316
|
+
break;
|
|
2317
|
+
}
|
|
1962
2318
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1963
2319
|
});
|
|
1964
2320
|
this.log({
|
|
@@ -1971,7 +2327,7 @@ var ChannelDriver = class {
|
|
|
1971
2327
|
}
|
|
1972
2328
|
if (opencodeMessageId === null) {
|
|
1973
2329
|
this.log({
|
|
1974
|
-
level: "
|
|
2330
|
+
level: "warn",
|
|
1975
2331
|
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
2332
|
conversation_id: conv.id,
|
|
1977
2333
|
message_id: message.id
|
|
@@ -1985,7 +2341,7 @@ var ChannelDriver = class {
|
|
|
1985
2341
|
}
|
|
1986
2342
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
1987
2343
|
this.log({
|
|
1988
|
-
level: "
|
|
2344
|
+
level: "warn",
|
|
1989
2345
|
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
2346
|
conversation_id: conv.id
|
|
1991
2347
|
});
|
|
@@ -1994,16 +2350,33 @@ var ChannelDriver = class {
|
|
|
1994
2350
|
return dispatched;
|
|
1995
2351
|
}
|
|
1996
2352
|
async ensureSession(conv) {
|
|
1997
|
-
const
|
|
1998
|
-
if (
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2353
|
+
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2354
|
+
if (bound) {
|
|
2355
|
+
const exists = await sessionExists(this.port, bound);
|
|
2356
|
+
if (exists === false) {
|
|
2357
|
+
this.log({
|
|
2358
|
+
level: "debug",
|
|
2359
|
+
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.`,
|
|
2360
|
+
conversation_id: conv.id
|
|
2361
|
+
});
|
|
2362
|
+
this.sessions.delete(conv.id);
|
|
2363
|
+
return this.createAndBindSession(conv.id);
|
|
2364
|
+
}
|
|
2365
|
+
this.sessions.set(conv.id, bound);
|
|
2366
|
+
return bound;
|
|
2002
2367
|
}
|
|
2368
|
+
return this.createAndBindSession(conv.id);
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
2372
|
+
* best-effort persist it server-side. Shared by the first-ever bind and the
|
|
2373
|
+
* self-heal recreate path in `ensureSession`.
|
|
2374
|
+
*/
|
|
2375
|
+
async createAndBindSession(conversationId) {
|
|
2003
2376
|
const directory = await this.resolveOpenCodeDirectory();
|
|
2004
2377
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
2005
|
-
this.sessions.set(
|
|
2006
|
-
await this.persistSession(
|
|
2378
|
+
this.sessions.set(conversationId, sessionId);
|
|
2379
|
+
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
2007
2380
|
});
|
|
2008
2381
|
return sessionId;
|
|
2009
2382
|
}
|
|
@@ -2017,15 +2390,13 @@ var ChannelDriver = class {
|
|
|
2017
2390
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2018
2391
|
if (!this.opencodeDirectory) {
|
|
2019
2392
|
this.log({
|
|
2020
|
-
level: "
|
|
2393
|
+
level: "warn",
|
|
2021
2394
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2022
2395
|
});
|
|
2023
2396
|
}
|
|
2024
2397
|
return this.opencodeDirectory;
|
|
2025
2398
|
}
|
|
2026
|
-
// -------------------------------------------------------------------------
|
|
2027
2399
|
// Per-session watcher (WI-3)
|
|
2028
|
-
// -------------------------------------------------------------------------
|
|
2029
2400
|
/**
|
|
2030
2401
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2031
2402
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2045,6 +2416,103 @@ var ChannelDriver = class {
|
|
|
2045
2416
|
);
|
|
2046
2417
|
return run2;
|
|
2047
2418
|
}
|
|
2419
|
+
// Inbound image attachments (#255, WI-8)
|
|
2420
|
+
/**
|
|
2421
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2422
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2423
|
+
*
|
|
2424
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2425
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2426
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2427
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2428
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2429
|
+
* existing callback surface when any image was skipped/failed.
|
|
2430
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2431
|
+
* reports outcomes back via `onOutcomes`.
|
|
2432
|
+
*/
|
|
2433
|
+
buildSendAttachments(conv, message) {
|
|
2434
|
+
const refs = message.attachments;
|
|
2435
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2436
|
+
return {
|
|
2437
|
+
inputs: refs.map((a, index) => ({
|
|
2438
|
+
index,
|
|
2439
|
+
mime: a.mime,
|
|
2440
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2441
|
+
})),
|
|
2442
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2443
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2448
|
+
* (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2449
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2450
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2451
|
+
*
|
|
2452
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2453
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2454
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2455
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2456
|
+
* Failures are logged with context (no silent swallow).
|
|
2457
|
+
*/
|
|
2458
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2459
|
+
try {
|
|
2460
|
+
const res = await this.fetchImpl(
|
|
2461
|
+
`${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2462
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2463
|
+
);
|
|
2464
|
+
if (!res.ok) {
|
|
2465
|
+
this.log({
|
|
2466
|
+
level: "error",
|
|
2467
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2468
|
+
message_id: messageId
|
|
2469
|
+
});
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
const buf = await res.arrayBuffer();
|
|
2473
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2474
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2475
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2476
|
+
} catch (err) {
|
|
2477
|
+
this.log({
|
|
2478
|
+
level: "error",
|
|
2479
|
+
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)}`,
|
|
2480
|
+
message_id: messageId
|
|
2481
|
+
});
|
|
2482
|
+
return null;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2487
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2488
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2489
|
+
*
|
|
2490
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2491
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2492
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2493
|
+
* reaches the channel.
|
|
2494
|
+
*
|
|
2495
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2496
|
+
*/
|
|
2497
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2498
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2499
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2500
|
+
if (skipped === 0 && failed === 0) return;
|
|
2501
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2502
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2503
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2504
|
+
this.log({
|
|
2505
|
+
level: "info",
|
|
2506
|
+
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`,
|
|
2507
|
+
conversation_id: conversationId,
|
|
2508
|
+
message_id: messageId
|
|
2509
|
+
});
|
|
2510
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2511
|
+
skipped,
|
|
2512
|
+
failed,
|
|
2513
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2048
2516
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2049
2517
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2050
2518
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2054,7 +2522,9 @@ var ChannelDriver = class {
|
|
|
2054
2522
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2055
2523
|
loop: null,
|
|
2056
2524
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2057
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2525
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2526
|
+
lastGoodPollAt: this.now(),
|
|
2527
|
+
hadUsablePoll: false
|
|
2058
2528
|
};
|
|
2059
2529
|
this.watchers.set(sessionId, watcher);
|
|
2060
2530
|
}
|
|
@@ -2064,20 +2534,37 @@ var ChannelDriver = class {
|
|
|
2064
2534
|
opencodeMessageId,
|
|
2065
2535
|
message,
|
|
2066
2536
|
dispatchedAt: now,
|
|
2537
|
+
processingAnchorMs: now,
|
|
2067
2538
|
deadline: now + this.pausedMaxWaitMs,
|
|
2068
2539
|
started: false,
|
|
2069
2540
|
done: false,
|
|
2070
|
-
stuckReported: false
|
|
2541
|
+
stuckReported: false,
|
|
2542
|
+
lastAliveAt: 0,
|
|
2543
|
+
aliveInFlight: false,
|
|
2544
|
+
awaitingHumanLatched: false,
|
|
2545
|
+
pausedOnQuestion: false,
|
|
2546
|
+
pausedOnPermission: false,
|
|
2547
|
+
pausedClearConfirmed: false,
|
|
2548
|
+
pausedInFlight: false,
|
|
2549
|
+
deliveryDeadlineAnchored: false
|
|
2071
2550
|
});
|
|
2072
2551
|
}
|
|
2073
2552
|
/**
|
|
2074
2553
|
* Register a RE-ADOPTED `processing` message with its session watcher
|
|
2075
2554
|
* (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
|
|
2076
2555
|
* `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
|
|
2077
|
-
* `now
|
|
2078
|
-
* (10 min after `processed_at
|
|
2079
|
-
*
|
|
2080
|
-
*
|
|
2556
|
+
* `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
|
|
2557
|
+
* fresh dispatch would (10 min after `processed_at`, not 10 min from now).
|
|
2558
|
+
*
|
|
2559
|
+
* This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
|
|
2560
|
+
* give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
|
|
2561
|
+
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
2562
|
+
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
2563
|
+
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
2564
|
+
* handed to the cron. The old "the `deadline` must settle before the ~15-min
|
|
2565
|
+
* cron or they double-drive" reasoning is superseded: liveness now settles the
|
|
2566
|
+
* actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
|
|
2567
|
+
* (only the appear-guard uses it).
|
|
2081
2568
|
*
|
|
2082
2569
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
2083
2570
|
* `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
|
|
@@ -2095,7 +2582,9 @@ var ChannelDriver = class {
|
|
|
2095
2582
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2096
2583
|
loop: null,
|
|
2097
2584
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2098
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2585
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2586
|
+
lastGoodPollAt: this.now(),
|
|
2587
|
+
hadUsablePoll: false
|
|
2099
2588
|
};
|
|
2100
2589
|
this.watchers.set(sessionId, watcher);
|
|
2101
2590
|
}
|
|
@@ -2104,6 +2593,10 @@ var ChannelDriver = class {
|
|
|
2104
2593
|
opencodeMessageId,
|
|
2105
2594
|
message,
|
|
2106
2595
|
dispatchedAt: this.now(),
|
|
2596
|
+
// Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
|
|
2597
|
+
// value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
|
|
2598
|
+
// reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
|
|
2599
|
+
processingAnchorMs: processedAtMs,
|
|
2107
2600
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
2108
2601
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
2109
2602
|
started: true,
|
|
@@ -2113,7 +2606,20 @@ var ChannelDriver = class {
|
|
|
2113
2606
|
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2114
2607
|
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2115
2608
|
// (#210/#220 observability).
|
|
2116
|
-
stuckReported: false
|
|
2609
|
+
stuckReported: false,
|
|
2610
|
+
// Task 5.2: a re-adopted actively-running row re-attaches into the SAME
|
|
2611
|
+
// watcher and so hits the SAME actively-running heartbeat branch in
|
|
2612
|
+
// `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
|
|
2613
|
+
// re-adopted and is confirming this row alive" via that `alive` heartbeat,
|
|
2614
|
+
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2615
|
+
lastAliveAt: 0,
|
|
2616
|
+
aliveInFlight: false,
|
|
2617
|
+
awaitingHumanLatched: false,
|
|
2618
|
+
pausedOnQuestion: false,
|
|
2619
|
+
pausedOnPermission: false,
|
|
2620
|
+
pausedClearConfirmed: false,
|
|
2621
|
+
pausedInFlight: false,
|
|
2622
|
+
deliveryDeadlineAnchored: false
|
|
2117
2623
|
});
|
|
2118
2624
|
}
|
|
2119
2625
|
/**
|
|
@@ -2164,12 +2670,30 @@ var ChannelDriver = class {
|
|
|
2164
2670
|
messages = Array.isArray(body) ? body : null;
|
|
2165
2671
|
}
|
|
2166
2672
|
} catch {
|
|
2167
|
-
continue;
|
|
2168
2673
|
}
|
|
2674
|
+
if (messages != null && messages.length > 0) {
|
|
2675
|
+
watcher.lastGoodPollAt = this.now();
|
|
2676
|
+
watcher.hadUsablePoll = true;
|
|
2677
|
+
} else {
|
|
2678
|
+
const emptyButReachable = messages != null;
|
|
2679
|
+
const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
|
|
2680
|
+
if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
|
|
2169
2685
|
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
2170
|
-
await this.serviceInFlightMessage(
|
|
2686
|
+
await this.serviceInFlightMessage(
|
|
2687
|
+
sessionId,
|
|
2688
|
+
watcher,
|
|
2689
|
+
inFlight,
|
|
2690
|
+
messages,
|
|
2691
|
+
openQuestions,
|
|
2692
|
+
openPermissions,
|
|
2693
|
+
questionsPolledOk,
|
|
2694
|
+
permissionsPolledOk
|
|
2695
|
+
);
|
|
2171
2696
|
}
|
|
2172
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
2173
2697
|
}
|
|
2174
2698
|
} catch (err) {
|
|
2175
2699
|
if (err instanceof ChannelAuthError) {
|
|
@@ -2191,28 +2715,55 @@ var ChannelDriver = class {
|
|
|
2191
2715
|
});
|
|
2192
2716
|
}
|
|
2193
2717
|
}
|
|
2718
|
+
/**
|
|
2719
|
+
* On FIRST observing a terminal (done/failed) state, ensure the delivery
|
|
2720
|
+
* (markDone/markFailed) transient-retry path has a real window. A long
|
|
2721
|
+
* ACTIVELY-running turn is kept past its original `deadline`, so by completion
|
|
2722
|
+
* `now >= deadline` already holds and the retry bound below would fire on the
|
|
2723
|
+
* first transient PATCH failure — dropping the message before its reply lands
|
|
2724
|
+
* (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
|
|
2725
|
+
* to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
|
|
2726
|
+
* or past now, so a still-ample window is left untouched.
|
|
2727
|
+
*/
|
|
2728
|
+
anchorDeliveryDeadline(inFlight) {
|
|
2729
|
+
if (inFlight.deliveryDeadlineAnchored) return;
|
|
2730
|
+
inFlight.deliveryDeadlineAnchored = true;
|
|
2731
|
+
if (this.now() >= inFlight.deadline) {
|
|
2732
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2194
2735
|
/**
|
|
2195
2736
|
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
2196
2737
|
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
2197
2738
|
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
2198
2739
|
* in-flight set on completion or timeout.
|
|
2199
2740
|
*/
|
|
2200
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
2741
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
|
|
2201
2742
|
const conv = watcher.conv;
|
|
2202
2743
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
2744
|
+
const id = inFlight.evidentMessageId;
|
|
2745
|
+
if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
|
|
2746
|
+
else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
|
|
2747
|
+
if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
|
|
2748
|
+
else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
|
|
2749
|
+
const observedOpen = openQuestions.has(id) || openPermissions.has(id);
|
|
2750
|
+
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2751
|
+
const awaitingHuman = observedOpen || latchedPaused;
|
|
2203
2752
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2753
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2204
2754
|
let claimed;
|
|
2205
2755
|
try {
|
|
2206
2756
|
claimed = await this.markProcessing(
|
|
2207
2757
|
conv.id,
|
|
2208
2758
|
inFlight.evidentMessageId,
|
|
2209
2759
|
sessionId,
|
|
2210
|
-
inFlight.opencodeMessageId
|
|
2760
|
+
inFlight.opencodeMessageId,
|
|
2761
|
+
title
|
|
2211
2762
|
);
|
|
2212
2763
|
} catch (err) {
|
|
2213
2764
|
if (err instanceof ChannelAuthError) throw err;
|
|
2214
2765
|
this.log({
|
|
2215
|
-
level: "
|
|
2766
|
+
level: "warn",
|
|
2216
2767
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2217
2768
|
conversation_id: conv.id,
|
|
2218
2769
|
message_id: inFlight.evidentMessageId
|
|
@@ -2222,7 +2773,7 @@ var ChannelDriver = class {
|
|
|
2222
2773
|
inFlight.started = true;
|
|
2223
2774
|
if (!claimed) {
|
|
2224
2775
|
this.log({
|
|
2225
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2226
2777
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2227
2778
|
conversation_id: conv.id,
|
|
2228
2779
|
message_id: inFlight.evidentMessageId
|
|
@@ -2230,6 +2781,7 @@ var ChannelDriver = class {
|
|
|
2230
2781
|
}
|
|
2231
2782
|
}
|
|
2232
2783
|
if (state === "done") {
|
|
2784
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2233
2785
|
if (!inFlight.done) {
|
|
2234
2786
|
this.log({
|
|
2235
2787
|
level: "info",
|
|
@@ -2237,18 +2789,20 @@ var ChannelDriver = class {
|
|
|
2237
2789
|
conversation_id: conv.id,
|
|
2238
2790
|
message_id: inFlight.evidentMessageId
|
|
2239
2791
|
});
|
|
2792
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2240
2793
|
try {
|
|
2241
2794
|
await this.markDone(
|
|
2242
2795
|
conv.id,
|
|
2243
2796
|
inFlight.evidentMessageId,
|
|
2244
2797
|
sessionId,
|
|
2245
|
-
inFlight.opencodeMessageId
|
|
2798
|
+
inFlight.opencodeMessageId,
|
|
2799
|
+
title
|
|
2246
2800
|
);
|
|
2247
2801
|
} catch (err) {
|
|
2248
2802
|
if (err instanceof ChannelAuthError) throw err;
|
|
2249
2803
|
if (err instanceof ChannelTerminalError) {
|
|
2250
2804
|
this.log({
|
|
2251
|
-
level: "
|
|
2805
|
+
level: "warn",
|
|
2252
2806
|
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
2807
|
conversation_id: conv.id,
|
|
2254
2808
|
message_id: inFlight.evidentMessageId
|
|
@@ -2258,7 +2812,7 @@ var ChannelDriver = class {
|
|
|
2258
2812
|
}
|
|
2259
2813
|
if (this.now() >= inFlight.deadline) {
|
|
2260
2814
|
this.log({
|
|
2261
|
-
level: "
|
|
2815
|
+
level: "warn",
|
|
2262
2816
|
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
2817
|
conversation_id: conv.id,
|
|
2264
2818
|
message_id: inFlight.evidentMessageId
|
|
@@ -2267,7 +2821,7 @@ var ChannelDriver = class {
|
|
|
2267
2821
|
return;
|
|
2268
2822
|
}
|
|
2269
2823
|
this.log({
|
|
2270
|
-
level: "
|
|
2824
|
+
level: "warn",
|
|
2271
2825
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2272
2826
|
conversation_id: conv.id,
|
|
2273
2827
|
message_id: inFlight.evidentMessageId
|
|
@@ -2280,6 +2834,7 @@ var ChannelDriver = class {
|
|
|
2280
2834
|
return;
|
|
2281
2835
|
}
|
|
2282
2836
|
if (state === "failed") {
|
|
2837
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2283
2838
|
if (!inFlight.done) {
|
|
2284
2839
|
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2285
2840
|
this.log({
|
|
@@ -2294,7 +2849,7 @@ var ChannelDriver = class {
|
|
|
2294
2849
|
if (err instanceof ChannelAuthError) throw err;
|
|
2295
2850
|
if (err instanceof ChannelTerminalError) {
|
|
2296
2851
|
this.log({
|
|
2297
|
-
level: "
|
|
2852
|
+
level: "warn",
|
|
2298
2853
|
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
2854
|
conversation_id: conv.id,
|
|
2300
2855
|
message_id: inFlight.evidentMessageId
|
|
@@ -2304,7 +2859,7 @@ var ChannelDriver = class {
|
|
|
2304
2859
|
}
|
|
2305
2860
|
if (this.now() >= inFlight.deadline) {
|
|
2306
2861
|
this.log({
|
|
2307
|
-
level: "
|
|
2862
|
+
level: "warn",
|
|
2308
2863
|
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
2864
|
conversation_id: conv.id,
|
|
2310
2865
|
message_id: inFlight.evidentMessageId
|
|
@@ -2313,7 +2868,7 @@ var ChannelDriver = class {
|
|
|
2313
2868
|
return;
|
|
2314
2869
|
}
|
|
2315
2870
|
this.log({
|
|
2316
|
-
level: "
|
|
2871
|
+
level: "warn",
|
|
2317
2872
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2318
2873
|
conversation_id: conv.id,
|
|
2319
2874
|
message_id: inFlight.evidentMessageId
|
|
@@ -2333,9 +2888,53 @@ var ChannelDriver = class {
|
|
|
2333
2888
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
2334
2889
|
});
|
|
2335
2890
|
}
|
|
2336
|
-
|
|
2891
|
+
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2892
|
+
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2337
2893
|
this.log({
|
|
2338
|
-
level: "
|
|
2894
|
+
level: "warn",
|
|
2895
|
+
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`,
|
|
2896
|
+
conversation_id: conv.id,
|
|
2897
|
+
message_id: inFlight.evidentMessageId
|
|
2898
|
+
});
|
|
2899
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2900
|
+
watched_for_ms: this.now() - inFlight.processingAnchorMs
|
|
2901
|
+
});
|
|
2902
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2906
|
+
inFlight.aliveInFlight = true;
|
|
2907
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
2908
|
+
inFlight.aliveInFlight = false;
|
|
2909
|
+
if (ok) inFlight.lastAliveAt = this.now();
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
if (awaitingHuman) {
|
|
2913
|
+
if (!inFlight.awaitingHumanLatched) {
|
|
2914
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2915
|
+
inFlight.awaitingHumanLatched = true;
|
|
2916
|
+
}
|
|
2917
|
+
if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
|
|
2918
|
+
inFlight.pausedInFlight = true;
|
|
2919
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
|
|
2920
|
+
inFlight.pausedInFlight = false;
|
|
2921
|
+
if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
} else if (inFlight.awaitingHumanLatched) {
|
|
2925
|
+
inFlight.awaitingHumanLatched = false;
|
|
2926
|
+
inFlight.pausedOnQuestion = false;
|
|
2927
|
+
inFlight.pausedOnPermission = false;
|
|
2928
|
+
inFlight.pausedClearConfirmed = false;
|
|
2929
|
+
}
|
|
2930
|
+
const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
|
|
2931
|
+
const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
|
|
2932
|
+
(sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
|
|
2933
|
+
);
|
|
2934
|
+
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2935
|
+
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2936
|
+
this.log({
|
|
2937
|
+
level: "debug",
|
|
2339
2938
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2340
2939
|
conversation_id: conv.id,
|
|
2341
2940
|
message_id: inFlight.evidentMessageId
|
|
@@ -2346,9 +2945,7 @@ var ChannelDriver = class {
|
|
|
2346
2945
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2347
2946
|
}
|
|
2348
2947
|
}
|
|
2349
|
-
// -------------------------------------------------------------------------
|
|
2350
2948
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2351
|
-
// -------------------------------------------------------------------------
|
|
2352
2949
|
/**
|
|
2353
2950
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2354
2951
|
*
|
|
@@ -2365,15 +2962,20 @@ var ChannelDriver = class {
|
|
|
2365
2962
|
*/
|
|
2366
2963
|
async readoptProcessing() {
|
|
2367
2964
|
const rows = await this.getProcessingMessages();
|
|
2368
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
2965
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2369
2966
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2370
|
-
for (const id of [
|
|
2967
|
+
for (const id of [
|
|
2968
|
+
...this.dontRedispatch,
|
|
2969
|
+
...this.doneUndeliverable,
|
|
2970
|
+
...this.readoptPollUnresolvedSignalled
|
|
2971
|
+
]) {
|
|
2371
2972
|
if (!stillProcessing.has(id)) {
|
|
2372
2973
|
const cleared = this.dontRedispatch.delete(id);
|
|
2373
2974
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
2975
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2374
2976
|
if (cleared || clearedUndeliverable) {
|
|
2375
2977
|
this.log({
|
|
2376
|
-
level: "
|
|
2978
|
+
level: "debug",
|
|
2377
2979
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2378
2980
|
message_id: id
|
|
2379
2981
|
});
|
|
@@ -2386,7 +2988,7 @@ var ChannelDriver = class {
|
|
|
2386
2988
|
for (const row of rows) {
|
|
2387
2989
|
if (!row.opencode_session_id) {
|
|
2388
2990
|
this.log({
|
|
2389
|
-
level: "
|
|
2991
|
+
level: "warn",
|
|
2390
2992
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2391
2993
|
conversation_id: row.conversation_id,
|
|
2392
2994
|
message_id: row.id
|
|
@@ -2403,7 +3005,7 @@ var ChannelDriver = class {
|
|
|
2403
3005
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2404
3006
|
if (!res.ok) {
|
|
2405
3007
|
this.log({
|
|
2406
|
-
level: "
|
|
3008
|
+
level: "warn",
|
|
2407
3009
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2408
3010
|
});
|
|
2409
3011
|
continue;
|
|
@@ -2411,7 +3013,7 @@ var ChannelDriver = class {
|
|
|
2411
3013
|
const body = await res.json();
|
|
2412
3014
|
if (!Array.isArray(body)) {
|
|
2413
3015
|
this.log({
|
|
2414
|
-
level: "
|
|
3016
|
+
level: "warn",
|
|
2415
3017
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2416
3018
|
});
|
|
2417
3019
|
continue;
|
|
@@ -2419,13 +3021,15 @@ var ChannelDriver = class {
|
|
|
2419
3021
|
messages = body;
|
|
2420
3022
|
} catch (err) {
|
|
2421
3023
|
this.log({
|
|
2422
|
-
level: "
|
|
3024
|
+
level: "warn",
|
|
2423
3025
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2424
3026
|
});
|
|
2425
3027
|
continue;
|
|
2426
3028
|
}
|
|
3029
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
3030
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2427
3031
|
for (const row of sessionRows) {
|
|
2428
|
-
await this.readoptOne(sessionId, row, messages);
|
|
3032
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2429
3033
|
}
|
|
2430
3034
|
}
|
|
2431
3035
|
}
|
|
@@ -2447,10 +3051,10 @@ var ChannelDriver = class {
|
|
|
2447
3051
|
*
|
|
2448
3052
|
* Only `ChannelAuthError` propagates.
|
|
2449
3053
|
*/
|
|
2450
|
-
async readoptOne(sessionId, row, messages) {
|
|
3054
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2451
3055
|
if (this.isTracked(sessionId, row.id)) {
|
|
2452
3056
|
this.log({
|
|
2453
|
-
level: "
|
|
3057
|
+
level: "debug",
|
|
2454
3058
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2455
3059
|
conversation_id: row.conversation_id,
|
|
2456
3060
|
message_id: row.id
|
|
@@ -2462,7 +3066,7 @@ var ChannelDriver = class {
|
|
|
2462
3066
|
if (state === "done") {
|
|
2463
3067
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2464
3068
|
this.log({
|
|
2465
|
-
level: "
|
|
3069
|
+
level: "debug",
|
|
2466
3070
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2467
3071
|
conversation_id: row.conversation_id,
|
|
2468
3072
|
message_id: row.id
|
|
@@ -2476,21 +3080,23 @@ var ChannelDriver = class {
|
|
|
2476
3080
|
message_id: row.id
|
|
2477
3081
|
});
|
|
2478
3082
|
try {
|
|
2479
|
-
await this.
|
|
3083
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2480
3085
|
} catch (err) {
|
|
2481
3086
|
if (err instanceof ChannelAuthError) throw err;
|
|
2482
3087
|
if (err instanceof ChannelTerminalError) {
|
|
2483
3088
|
this.doneUndeliverable.add(row.id);
|
|
2484
3089
|
this.log({
|
|
2485
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2486
3091
|
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
3092
|
conversation_id: row.conversation_id,
|
|
2488
3093
|
message_id: row.id
|
|
2489
3094
|
});
|
|
3095
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2490
3096
|
return;
|
|
2491
3097
|
}
|
|
2492
3098
|
this.log({
|
|
2493
|
-
level: "
|
|
3099
|
+
level: "warn",
|
|
2494
3100
|
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
3101
|
conversation_id: row.conversation_id,
|
|
2496
3102
|
message_id: row.id
|
|
@@ -2498,6 +3104,7 @@ var ChannelDriver = class {
|
|
|
2498
3104
|
return;
|
|
2499
3105
|
}
|
|
2500
3106
|
this.dontRedispatch.delete(row.id);
|
|
3107
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2501
3108
|
return;
|
|
2502
3109
|
}
|
|
2503
3110
|
if (state === "failed") {
|
|
@@ -2515,15 +3122,16 @@ var ChannelDriver = class {
|
|
|
2515
3122
|
if (err instanceof ChannelTerminalError) {
|
|
2516
3123
|
this.doneUndeliverable.add(row.id);
|
|
2517
3124
|
this.log({
|
|
2518
|
-
level: "
|
|
3125
|
+
level: "warn",
|
|
2519
3126
|
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
3127
|
conversation_id: row.conversation_id,
|
|
2521
3128
|
message_id: row.id
|
|
2522
3129
|
});
|
|
3130
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2523
3131
|
return;
|
|
2524
3132
|
}
|
|
2525
3133
|
this.log({
|
|
2526
|
-
level: "
|
|
3134
|
+
level: "warn",
|
|
2527
3135
|
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
3136
|
conversation_id: row.conversation_id,
|
|
2529
3137
|
message_id: row.id
|
|
@@ -2531,17 +3139,83 @@ var ChannelDriver = class {
|
|
|
2531
3139
|
return;
|
|
2532
3140
|
}
|
|
2533
3141
|
this.dontRedispatch.delete(row.id);
|
|
3142
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2534
3143
|
return;
|
|
2535
3144
|
}
|
|
2536
3145
|
if (this.dontRedispatch.has(row.id)) {
|
|
2537
3146
|
this.log({
|
|
2538
|
-
level: "
|
|
3147
|
+
level: "debug",
|
|
2539
3148
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2540
3149
|
conversation_id: row.conversation_id,
|
|
2541
3150
|
message_id: row.id
|
|
2542
3151
|
});
|
|
2543
3152
|
return;
|
|
2544
3153
|
}
|
|
3154
|
+
let statusReadableOngoing = null;
|
|
3155
|
+
if (state === "running" && ocId) {
|
|
3156
|
+
const reply = findLastAssistantReplyFor(messages, ocId);
|
|
3157
|
+
const shape = this.replyCompletionShape(reply);
|
|
3158
|
+
const ongoing = sessionOngoing;
|
|
3159
|
+
statusReadableOngoing = ongoing;
|
|
3160
|
+
if (ongoing === false) {
|
|
3161
|
+
this.log({
|
|
3162
|
+
level: "info",
|
|
3163
|
+
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)`,
|
|
3164
|
+
conversation_id: row.conversation_id,
|
|
3165
|
+
message_id: row.id
|
|
3166
|
+
});
|
|
3167
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
if (ongoing === true) {
|
|
3171
|
+
this.log({
|
|
3172
|
+
level: "debug",
|
|
3173
|
+
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)`,
|
|
3174
|
+
conversation_id: row.conversation_id,
|
|
3175
|
+
message_id: row.id
|
|
3176
|
+
});
|
|
3177
|
+
} else {
|
|
3178
|
+
if (shape === "b1") {
|
|
3179
|
+
this.log({
|
|
3180
|
+
level: "debug",
|
|
3181
|
+
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`,
|
|
3182
|
+
conversation_id: row.conversation_id,
|
|
3183
|
+
message_id: row.id
|
|
3184
|
+
});
|
|
3185
|
+
if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
|
|
3186
|
+
this.readoptPollUnresolvedSignalled.add(row.id);
|
|
3187
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
|
|
3188
|
+
}
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
this.log({
|
|
3192
|
+
level: "debug",
|
|
3193
|
+
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`,
|
|
3194
|
+
conversation_id: row.conversation_id,
|
|
3195
|
+
message_id: row.id
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
|
|
3200
|
+
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
3201
|
+
if (descendantAlive === true) {
|
|
3202
|
+
this.log({
|
|
3203
|
+
level: "debug",
|
|
3204
|
+
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)`,
|
|
3205
|
+
conversation_id: row.conversation_id,
|
|
3206
|
+
message_id: row.id
|
|
3207
|
+
});
|
|
3208
|
+
} else {
|
|
3209
|
+
this.log({
|
|
3210
|
+
level: "info",
|
|
3211
|
+
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)" : ""}`,
|
|
3212
|
+
conversation_id: row.conversation_id,
|
|
3213
|
+
message_id: row.id
|
|
3214
|
+
});
|
|
3215
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
2545
3219
|
if ((state === "running" || state === "queued") && ocId) {
|
|
2546
3220
|
const conv = this.convForRow(sessionId, row);
|
|
2547
3221
|
const message = this.queuedMessageForRow(row);
|
|
@@ -2550,11 +3224,12 @@ var ChannelDriver = class {
|
|
|
2550
3224
|
this.readopted.add(row.id);
|
|
2551
3225
|
this.ensureWatcherRunning(sessionId);
|
|
2552
3226
|
this.log({
|
|
2553
|
-
level: "
|
|
3227
|
+
level: "debug",
|
|
2554
3228
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2555
3229
|
conversation_id: row.conversation_id,
|
|
2556
3230
|
message_id: row.id
|
|
2557
3231
|
});
|
|
3232
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2558
3233
|
return;
|
|
2559
3234
|
}
|
|
2560
3235
|
await this.forceReadoptRun(sessionId, row);
|
|
@@ -2583,7 +3258,7 @@ var ChannelDriver = class {
|
|
|
2583
3258
|
async forceReadoptRun(sessionId, row) {
|
|
2584
3259
|
if (this.stopped) {
|
|
2585
3260
|
this.log({
|
|
2586
|
-
level: "
|
|
3261
|
+
level: "debug",
|
|
2587
3262
|
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
3263
|
conversation_id: row.conversation_id,
|
|
2589
3264
|
message_id: row.id
|
|
@@ -2592,7 +3267,7 @@ var ChannelDriver = class {
|
|
|
2592
3267
|
}
|
|
2593
3268
|
if (this.awaitingReadopt.has(row.id)) {
|
|
2594
3269
|
this.log({
|
|
2595
|
-
level: "
|
|
3270
|
+
level: "debug",
|
|
2596
3271
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2597
3272
|
conversation_id: row.conversation_id,
|
|
2598
3273
|
message_id: row.id
|
|
@@ -2602,11 +3277,12 @@ var ChannelDriver = class {
|
|
|
2602
3277
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2603
3278
|
this.dontRedispatch.add(row.id);
|
|
2604
3279
|
this.log({
|
|
2605
|
-
level: "
|
|
3280
|
+
level: "debug",
|
|
2606
3281
|
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
3282
|
conversation_id: row.conversation_id,
|
|
2608
3283
|
message_id: row.id
|
|
2609
3284
|
});
|
|
3285
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2610
3286
|
return;
|
|
2611
3287
|
}
|
|
2612
3288
|
const options = {
|
|
@@ -2620,40 +3296,44 @@ var ChannelDriver = class {
|
|
|
2620
3296
|
message_id: row.id
|
|
2621
3297
|
});
|
|
2622
3298
|
this.awaitingReadopt.add(row.id);
|
|
3299
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3300
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3301
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
2623
3302
|
let ocId;
|
|
2624
3303
|
try {
|
|
2625
3304
|
ocId = await this.dispatchLocked(
|
|
2626
3305
|
sessionId,
|
|
2627
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3306
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
2628
3307
|
);
|
|
2629
3308
|
} catch (err) {
|
|
2630
3309
|
this.awaitingReadopt.delete(row.id);
|
|
2631
3310
|
if (err instanceof ChannelAuthError) throw err;
|
|
2632
3311
|
this.log({
|
|
2633
|
-
level: "
|
|
3312
|
+
level: "warn",
|
|
2634
3313
|
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
3314
|
conversation_id: row.conversation_id,
|
|
2636
3315
|
message_id: row.id
|
|
2637
3316
|
});
|
|
3317
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2638
3318
|
return;
|
|
2639
3319
|
}
|
|
2640
3320
|
if (ocId === null) {
|
|
2641
3321
|
this.awaitingReadopt.delete(row.id);
|
|
2642
3322
|
this.log({
|
|
2643
|
-
level: "
|
|
3323
|
+
level: "warn",
|
|
2644
3324
|
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
3325
|
conversation_id: row.conversation_id,
|
|
2646
3326
|
message_id: row.id
|
|
2647
3327
|
});
|
|
3328
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2648
3329
|
return;
|
|
2649
3330
|
}
|
|
2650
|
-
|
|
2651
|
-
const message = this.queuedMessageForRow(row);
|
|
2652
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3331
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
2653
3332
|
this.dispatched.add(row.id);
|
|
2654
3333
|
this.readopted.add(row.id);
|
|
2655
3334
|
this.awaitingReadopt.delete(row.id);
|
|
2656
3335
|
this.ensureWatcherRunning(sessionId);
|
|
3336
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2657
3337
|
}
|
|
2658
3338
|
/**
|
|
2659
3339
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -2702,7 +3382,8 @@ var ChannelDriver = class {
|
|
|
2702
3382
|
opencode_agent: row.opencode_agent,
|
|
2703
3383
|
opencode_model: row.opencode_model,
|
|
2704
3384
|
source_message_id: row.source_message_id,
|
|
2705
|
-
slack_user_id: row.slack_user_id
|
|
3385
|
+
slack_user_id: row.slack_user_id,
|
|
3386
|
+
attachments: row.attachments ?? null
|
|
2706
3387
|
};
|
|
2707
3388
|
}
|
|
2708
3389
|
/**
|
|
@@ -2723,7 +3404,7 @@ var ChannelDriver = class {
|
|
|
2723
3404
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
2724
3405
|
this.dontRedispatch.add(evidentMessageId);
|
|
2725
3406
|
this.log({
|
|
2726
|
-
level: "
|
|
3407
|
+
level: "debug",
|
|
2727
3408
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
2728
3409
|
conversation_id: watcher.conv.id,
|
|
2729
3410
|
message_id: evidentMessageId
|
|
@@ -2745,21 +3426,41 @@ var ChannelDriver = class {
|
|
|
2745
3426
|
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2746
3427
|
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2747
3428
|
* oldest running message.
|
|
3429
|
+
*
|
|
3430
|
+
* Returns the set of in-flight Evident message ids that are paused awaiting a
|
|
3431
|
+
* human — an outstanding (still-open) question/permission is attributed to them.
|
|
3432
|
+
* `serviceInFlightMessage` uses this to keep an actively-running turn watched
|
|
3433
|
+
* forever (ADR-0047) while still bounding a turn merely blocked on a person who
|
|
3434
|
+
* may never answer. Attribution here covers ALL open interactions, not just
|
|
3435
|
+
* NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
|
|
3436
|
+
* even after it was already surfaced to the channel.
|
|
2748
3437
|
*/
|
|
2749
3438
|
async pollInteractions(sessionId, watcher, messages) {
|
|
3439
|
+
const openQuestions = /* @__PURE__ */ new Set();
|
|
3440
|
+
const openPermissions = /* @__PURE__ */ new Set();
|
|
3441
|
+
let questionsPolledOk = true;
|
|
3442
|
+
let permissionsPolledOk = true;
|
|
2750
3443
|
let questions = [];
|
|
2751
3444
|
try {
|
|
2752
3445
|
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2753
3446
|
if (res.ok) {
|
|
2754
3447
|
const body = await res.json();
|
|
2755
|
-
|
|
3448
|
+
if (Array.isArray(body)) {
|
|
3449
|
+
questions = body;
|
|
3450
|
+
} else {
|
|
3451
|
+
questionsPolledOk = false;
|
|
3452
|
+
}
|
|
3453
|
+
} else {
|
|
3454
|
+
questionsPolledOk = false;
|
|
2756
3455
|
}
|
|
2757
3456
|
} catch {
|
|
3457
|
+
questionsPolledOk = false;
|
|
2758
3458
|
}
|
|
2759
3459
|
for (const q of questions) {
|
|
2760
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2761
3460
|
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2762
3461
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
3462
|
+
if (paused) openQuestions.add(paused.evidentMessageId);
|
|
3463
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2763
3464
|
const reported = await this.reportInteraction(
|
|
2764
3465
|
watcher.conv.id,
|
|
2765
3466
|
"question",
|
|
@@ -2773,14 +3474,22 @@ var ChannelDriver = class {
|
|
|
2773
3474
|
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2774
3475
|
if (res.ok) {
|
|
2775
3476
|
const body = await res.json();
|
|
2776
|
-
|
|
3477
|
+
if (Array.isArray(body)) {
|
|
3478
|
+
permissions = body;
|
|
3479
|
+
} else {
|
|
3480
|
+
permissionsPolledOk = false;
|
|
3481
|
+
}
|
|
3482
|
+
} else {
|
|
3483
|
+
permissionsPolledOk = false;
|
|
2777
3484
|
}
|
|
2778
3485
|
} catch {
|
|
3486
|
+
permissionsPolledOk = false;
|
|
2779
3487
|
}
|
|
2780
3488
|
for (const p of permissions) {
|
|
2781
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2782
3489
|
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2783
3490
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
3491
|
+
if (paused) openPermissions.add(paused.evidentMessageId);
|
|
3492
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2784
3493
|
const reported = await this.reportInteraction(
|
|
2785
3494
|
watcher.conv.id,
|
|
2786
3495
|
"permission",
|
|
@@ -2789,6 +3498,7 @@ var ChannelDriver = class {
|
|
|
2789
3498
|
);
|
|
2790
3499
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2791
3500
|
}
|
|
3501
|
+
return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
|
|
2792
3502
|
}
|
|
2793
3503
|
/**
|
|
2794
3504
|
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
@@ -2834,6 +3544,128 @@ var ChannelDriver = class {
|
|
|
2834
3544
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
2835
3545
|
return parent;
|
|
2836
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3549
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3550
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3551
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3552
|
+
* Best-effort:
|
|
3553
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3554
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3555
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3556
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3557
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3558
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3559
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3560
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3561
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3562
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3563
|
+
*/
|
|
3564
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3565
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3566
|
+
if (cached != null) return cached;
|
|
3567
|
+
try {
|
|
3568
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3569
|
+
if (res.ok) {
|
|
3570
|
+
const body = await res.json();
|
|
3571
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3572
|
+
if (title.length > 0) {
|
|
3573
|
+
this.sessionTitles.set(sessionId, title);
|
|
3574
|
+
return title;
|
|
3575
|
+
}
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
this.log({
|
|
3579
|
+
level: "debug",
|
|
3580
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3581
|
+
conversation_id: conversationId
|
|
3582
|
+
});
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
this.log({
|
|
3585
|
+
level: "debug",
|
|
3586
|
+
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)}`,
|
|
3587
|
+
conversation_id: conversationId
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
return null;
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3594
|
+
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
3595
|
+
*
|
|
3596
|
+
* The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
|
|
3597
|
+
* runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
|
|
3598
|
+
* completed `finish: "tool-calls"` root reply encountered during re-adoption is
|
|
3599
|
+
* idle by OpenCode's own definition and is re-dispatched. This method exists only
|
|
3600
|
+
* so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
|
|
3601
|
+
* provably in flight at the exact moment of recovery.
|
|
3602
|
+
*
|
|
3603
|
+
* "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
|
|
3604
|
+
* ACTIVELY generating — its LAST message is an assistant still mid-generation
|
|
3605
|
+
* (`completed == null`, via `isSessionActivelyGenerating`). An
|
|
3606
|
+
* INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
|
|
3607
|
+
* completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
|
|
3608
|
+
* is generating once the runner is gone), so it does NOT veto. (This is
|
|
3609
|
+
* deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
|
|
3610
|
+
* shapes and would falsely veto — re-hanging the very turn this path recovers.)
|
|
3611
|
+
*
|
|
3612
|
+
* Return contract (encoded so WI-3 need not re-derive it):
|
|
3613
|
+
* - `true` → a descendant is provably, actively generating (veto re-dispatch).
|
|
3614
|
+
* - `false` → descendants exist but none is actively generating (the restart
|
|
3615
|
+
* case), OR no descendant is found at all.
|
|
3616
|
+
* - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
|
|
3617
|
+
*
|
|
3618
|
+
* ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
|
|
3619
|
+
* as `false` and does NOT veto — a restart guarantees no live runner, so an
|
|
3620
|
+
* indeterminate cross-check almost always means "couldn't reach a child that no
|
|
3621
|
+
* longer exists". The inversion lives in the caller; this method just reports
|
|
3622
|
+
* true/false/null faithfully.
|
|
3623
|
+
*
|
|
3624
|
+
* VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
|
|
3625
|
+
* (already proven by the existing child-session interaction tests, via
|
|
3626
|
+
* `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
|
|
3627
|
+
* terminal state. We do NOT depend on any session-level `busy`/`idle` field —
|
|
3628
|
+
* there is none on `GET /session/:id`; OpenCode's busy state is in-memory
|
|
3629
|
+
* `SessionStatus` only.
|
|
3630
|
+
*/
|
|
3631
|
+
async isAnyDescendantSessionAlive(rootSessionId) {
|
|
3632
|
+
const sessions = await listSessions(this.port);
|
|
3633
|
+
if (!sessions) {
|
|
3634
|
+
this.log({
|
|
3635
|
+
level: "warn",
|
|
3636
|
+
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3637
|
+
});
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
for (const candidate of sessions) {
|
|
3641
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
3642
|
+
if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
|
|
3643
|
+
const childMsgs = await getSessionMessages(this.port, candidate.id);
|
|
3644
|
+
if (isSessionActivelyGenerating(childMsgs)) {
|
|
3645
|
+
return true;
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
return false;
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3652
|
+
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
3653
|
+
* - `b1` — the reply itself is still in flight (`time.completed == null`) —
|
|
3654
|
+
* the aborted-in-flight production bug after a restart.
|
|
3655
|
+
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
3656
|
+
* (the sub-agent preamble — #253's shape).
|
|
3657
|
+
* - `other` — any other shape (defensive; a running row is normally b1 or b2).
|
|
3658
|
+
* Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
|
|
3659
|
+
* shape) directly rather than re-importing the module-private `completedOf`/
|
|
3660
|
+
* `finishOf` — this is a display label only, not a correctness predicate.
|
|
3661
|
+
*/
|
|
3662
|
+
replyCompletionShape(reply) {
|
|
3663
|
+
if (!reply) return "other";
|
|
3664
|
+
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
3665
|
+
if (completed == null) return "b1";
|
|
3666
|
+
const finish = reply.info?.finish ?? reply.finish;
|
|
3667
|
+
return finish === "tool-calls" ? "b2" : "other";
|
|
3668
|
+
}
|
|
2837
3669
|
/**
|
|
2838
3670
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2839
3671
|
*
|
|
@@ -2884,9 +3716,7 @@ var ChannelDriver = class {
|
|
|
2884
3716
|
}
|
|
2885
3717
|
return inFlight.sort(byOldest)[0];
|
|
2886
3718
|
}
|
|
2887
|
-
// -------------------------------------------------------------------------
|
|
2888
3719
|
// Evident API calls (combinedAuth thread routes)
|
|
2889
|
-
// -------------------------------------------------------------------------
|
|
2890
3720
|
async getPendingConversations() {
|
|
2891
3721
|
const res = await this.fetchImpl(
|
|
2892
3722
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -2966,7 +3796,7 @@ var ChannelDriver = class {
|
|
|
2966
3796
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2967
3797
|
* retry vehicle for the swap-to-running.
|
|
2968
3798
|
*/
|
|
2969
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3799
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
2970
3800
|
const res = await this.fetchImpl(
|
|
2971
3801
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2972
3802
|
{
|
|
@@ -2975,7 +3805,8 @@ var ChannelDriver = class {
|
|
|
2975
3805
|
body: JSON.stringify({
|
|
2976
3806
|
status: "processing",
|
|
2977
3807
|
opencode_session_id: sessionId,
|
|
2978
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3808
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
|
+
...title ? { title } : {}
|
|
2979
3810
|
})
|
|
2980
3811
|
}
|
|
2981
3812
|
);
|
|
@@ -3014,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
3014
3845
|
* watcher retries next tick within the
|
|
3015
3846
|
* deadline, Finding 4).
|
|
3016
3847
|
*/
|
|
3017
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3848
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3018
3849
|
const res = await this.fetchImpl(
|
|
3019
3850
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3020
3851
|
{
|
|
@@ -3023,7 +3854,8 @@ var ChannelDriver = class {
|
|
|
3023
3854
|
body: JSON.stringify({
|
|
3024
3855
|
status: "done",
|
|
3025
3856
|
opencode_session_id: sessionId,
|
|
3026
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3857
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
+
...title ? { title } : {}
|
|
3027
3859
|
})
|
|
3028
3860
|
}
|
|
3029
3861
|
);
|
|
@@ -3065,6 +3897,12 @@ var ChannelDriver = class {
|
|
|
3065
3897
|
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
3066
3898
|
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
3067
3899
|
* context (no silent catch, per development-workflow).
|
|
3900
|
+
*
|
|
3901
|
+
* Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
|
|
3902
|
+
* telemetry), but the `paused` liveness-clear uses it to know whether to
|
|
3903
|
+
* RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
|
|
3904
|
+
* stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
|
|
3905
|
+
* leaves liveness").
|
|
3068
3906
|
*/
|
|
3069
3907
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3070
3908
|
try {
|
|
@@ -3078,19 +3916,22 @@ var ChannelDriver = class {
|
|
|
3078
3916
|
);
|
|
3079
3917
|
if (!res.ok) {
|
|
3080
3918
|
this.log({
|
|
3081
|
-
level: "
|
|
3919
|
+
level: "warn",
|
|
3082
3920
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3083
3921
|
conversation_id: conversationId,
|
|
3084
3922
|
message_id: messageId
|
|
3085
3923
|
});
|
|
3924
|
+
return false;
|
|
3086
3925
|
}
|
|
3926
|
+
return true;
|
|
3087
3927
|
} catch (err) {
|
|
3088
3928
|
this.log({
|
|
3089
|
-
level: "
|
|
3929
|
+
level: "warn",
|
|
3090
3930
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3091
3931
|
conversation_id: conversationId,
|
|
3092
3932
|
message_id: messageId
|
|
3093
3933
|
});
|
|
3934
|
+
return false;
|
|
3094
3935
|
}
|
|
3095
3936
|
}
|
|
3096
3937
|
async persistSession(conversationId, sessionId) {
|
|
@@ -3147,9 +3988,7 @@ var ChannelDriver = class {
|
|
|
3147
3988
|
return false;
|
|
3148
3989
|
}
|
|
3149
3990
|
}
|
|
3150
|
-
// -------------------------------------------------------------------------
|
|
3151
3991
|
// Retry wrapper
|
|
3152
|
-
// -------------------------------------------------------------------------
|
|
3153
3992
|
/**
|
|
3154
3993
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3155
3994
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3348,7 +4187,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3348
4187
|
if (!response.ok) {
|
|
3349
4188
|
const serverMessage = await readErrorMessage(response);
|
|
3350
4189
|
return {
|
|
3351
|
-
error: `Failed to resolve
|
|
4190
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3352
4191
|
};
|
|
3353
4192
|
}
|
|
3354
4193
|
const data = await response.json();
|
|
@@ -3356,11 +4195,11 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3356
4195
|
return { agent_id: data.agent_id };
|
|
3357
4196
|
}
|
|
3358
4197
|
return {
|
|
3359
|
-
error: "Cannot resolve
|
|
4198
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3360
4199
|
};
|
|
3361
4200
|
} catch (error2) {
|
|
3362
4201
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3363
|
-
return { error: `Failed to resolve
|
|
4202
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3364
4203
|
}
|
|
3365
4204
|
}
|
|
3366
4205
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
@@ -3396,12 +4235,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3396
4235
|
const serverMessage = await readErrorMessage(response);
|
|
3397
4236
|
return {
|
|
3398
4237
|
valid: false,
|
|
3399
|
-
error: serverMessage ?? "You do not have access to this
|
|
4238
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3400
4239
|
};
|
|
3401
4240
|
}
|
|
3402
4241
|
if (response.status === 404) {
|
|
3403
4242
|
const serverMessage = await readErrorMessage(response);
|
|
3404
|
-
return { valid: false, error: serverMessage ?? `
|
|
4243
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3405
4244
|
}
|
|
3406
4245
|
if (!response.ok) {
|
|
3407
4246
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3414,13 +4253,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3414
4253
|
if (agent.agent_type !== "local") {
|
|
3415
4254
|
return {
|
|
3416
4255
|
valid: false,
|
|
3417
|
-
error: `
|
|
4256
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3418
4257
|
};
|
|
3419
4258
|
}
|
|
3420
4259
|
return { valid: true, agent };
|
|
3421
4260
|
} catch (error2) {
|
|
3422
4261
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3423
|
-
return { valid: false, error: `Failed to validate
|
|
4262
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3424
4263
|
}
|
|
3425
4264
|
}
|
|
3426
4265
|
|
|
@@ -3429,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
3429
4268
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3430
4269
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3431
4270
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3432
|
-
function
|
|
4271
|
+
function resolveLogLevel(options) {
|
|
4272
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4273
|
+
const validate = (value, source) => {
|
|
4274
|
+
const normalized = value.trim().toLowerCase();
|
|
4275
|
+
if (!accepted.includes(normalized)) {
|
|
4276
|
+
throw new Error(
|
|
4277
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
return normalized;
|
|
4281
|
+
};
|
|
4282
|
+
if (options.logLevel !== void 0) {
|
|
4283
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4284
|
+
}
|
|
4285
|
+
if (options.verbose) {
|
|
4286
|
+
return "debug";
|
|
4287
|
+
}
|
|
4288
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4289
|
+
if (env !== void 0 && env !== "") {
|
|
4290
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4291
|
+
}
|
|
4292
|
+
return "info";
|
|
4293
|
+
}
|
|
4294
|
+
function meetsThreshold(state, level) {
|
|
4295
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4296
|
+
}
|
|
4297
|
+
function log2(state, message, level = "info") {
|
|
4298
|
+
if (!meetsThreshold(state, level)) return;
|
|
3433
4299
|
if (state.json) {
|
|
3434
4300
|
console.log(
|
|
3435
4301
|
JSON.stringify({
|
|
3436
4302
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3437
|
-
level
|
|
4303
|
+
level,
|
|
3438
4304
|
message
|
|
3439
4305
|
})
|
|
3440
4306
|
);
|
|
3441
4307
|
} else if (!state.interactive) {
|
|
3442
|
-
const prefix =
|
|
4308
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3443
4309
|
console.log(`${prefix} ${message}`);
|
|
3444
4310
|
}
|
|
3445
4311
|
}
|
|
3446
4312
|
function logActivity(state, entry) {
|
|
4313
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
|
+
if (!meetsThreshold(state, level)) return;
|
|
3447
4315
|
const fullEntry = {
|
|
3448
4316
|
...entry,
|
|
4317
|
+
level,
|
|
3449
4318
|
timestamp: /* @__PURE__ */ new Date()
|
|
3450
4319
|
};
|
|
3451
4320
|
state.activityLog.push(fullEntry);
|
|
@@ -3454,9 +4323,9 @@ function logActivity(state, entry) {
|
|
|
3454
4323
|
}
|
|
3455
4324
|
if (!state.interactive) {
|
|
3456
4325
|
if (entry.type === "error") {
|
|
3457
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3458
|
-
} else if (entry.
|
|
3459
|
-
log2(state, entry.message);
|
|
4326
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4327
|
+
} else if (entry.message) {
|
|
4328
|
+
log2(state, entry.message, level);
|
|
3460
4329
|
}
|
|
3461
4330
|
}
|
|
3462
4331
|
}
|
|
@@ -3583,7 +4452,7 @@ async function driveChannels(state, driver) {
|
|
|
3583
4452
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
3584
4453
|
if (state.interactive) displayStatus(state);
|
|
3585
4454
|
}
|
|
3586
|
-
await new Promise((
|
|
4455
|
+
await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
|
|
3587
4456
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
3588
4457
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
3589
4458
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -3594,6 +4463,81 @@ async function driveChannels(state, driver) {
|
|
|
3594
4463
|
}
|
|
3595
4464
|
}
|
|
3596
4465
|
}
|
|
4466
|
+
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
4467
|
+
async function runSweep(state, driver, config2) {
|
|
4468
|
+
const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
|
|
4469
|
+
try {
|
|
4470
|
+
const sessions = await listSessions(state.port);
|
|
4471
|
+
if (sessions === null) {
|
|
4472
|
+
logActivity(state, {
|
|
4473
|
+
type: "info",
|
|
4474
|
+
message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
|
|
4475
|
+
});
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
const toDelete = selectSessionsToDelete(
|
|
4479
|
+
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
4480
|
+
{
|
|
4481
|
+
maxAgeMs: config2.maxAgeMs,
|
|
4482
|
+
maxCount: config2.maxCount,
|
|
4483
|
+
nowMs: Date.now(),
|
|
4484
|
+
protectedIds: driver.protectedSessionIds()
|
|
4485
|
+
}
|
|
4486
|
+
);
|
|
4487
|
+
const protectedNow = driver.protectedSessionIds();
|
|
4488
|
+
let deleted = 0;
|
|
4489
|
+
let failed = 0;
|
|
4490
|
+
let skippedNewlyActive = 0;
|
|
4491
|
+
for (const id of toDelete) {
|
|
4492
|
+
if (protectedNow.has(id)) {
|
|
4493
|
+
skippedNewlyActive++;
|
|
4494
|
+
logActivity(state, {
|
|
4495
|
+
type: "info",
|
|
4496
|
+
message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
|
|
4497
|
+
});
|
|
4498
|
+
continue;
|
|
4499
|
+
}
|
|
4500
|
+
if (await deleteSession(state.port, id)) deleted++;
|
|
4501
|
+
else failed++;
|
|
4502
|
+
}
|
|
4503
|
+
const failedNote = failed > 0 ? `, failed ${failed}` : "";
|
|
4504
|
+
const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
|
|
4505
|
+
logActivity(state, {
|
|
4506
|
+
type: "info",
|
|
4507
|
+
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
4508
|
+
});
|
|
4509
|
+
} catch (error2) {
|
|
4510
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4511
|
+
logActivity(state, {
|
|
4512
|
+
type: "error",
|
|
4513
|
+
error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
|
|
4514
|
+
});
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
function scheduleSessionCleanup(state, driver, options) {
|
|
4518
|
+
const config2 = resolveSessionCleanupConfig(
|
|
4519
|
+
{
|
|
4520
|
+
maxAge: options.sessionCleanupMaxAge,
|
|
4521
|
+
maxCount: options.sessionCleanupMaxCount,
|
|
4522
|
+
interval: options.sessionCleanupInterval
|
|
4523
|
+
},
|
|
4524
|
+
process.env
|
|
4525
|
+
);
|
|
4526
|
+
for (const warning2 of config2.warnings) {
|
|
4527
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4528
|
+
}
|
|
4529
|
+
if (!config2.enabled) return;
|
|
4530
|
+
logActivity(state, {
|
|
4531
|
+
type: "info",
|
|
4532
|
+
message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
|
|
4533
|
+
});
|
|
4534
|
+
const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
|
|
4535
|
+
const firstSweep = setTimeout(
|
|
4536
|
+
() => void runSweep(state, driver, config2),
|
|
4537
|
+
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
4538
|
+
);
|
|
4539
|
+
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
4540
|
+
}
|
|
3597
4541
|
async function notifyOffline(state) {
|
|
3598
4542
|
if (!state.agentId || !state.authHeader) return;
|
|
3599
4543
|
if (!state.connected) {
|
|
@@ -3602,7 +4546,7 @@ async function notifyOffline(state) {
|
|
|
3602
4546
|
}
|
|
3603
4547
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
3604
4548
|
if (result.ok) {
|
|
3605
|
-
log2(state, "Notified Evident the
|
|
4549
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
3606
4550
|
} else {
|
|
3607
4551
|
logActivity(state, {
|
|
3608
4552
|
type: "error",
|
|
@@ -3613,6 +4557,11 @@ async function notifyOffline(state) {
|
|
|
3613
4557
|
}
|
|
3614
4558
|
async function cleanup(state, opts = {}) {
|
|
3615
4559
|
state.running = false;
|
|
4560
|
+
for (const timer of state.sessionCleanupTimers) {
|
|
4561
|
+
clearInterval(timer);
|
|
4562
|
+
clearTimeout(timer);
|
|
4563
|
+
}
|
|
4564
|
+
state.sessionCleanupTimers = [];
|
|
3616
4565
|
if (opts.graceful && state.channelDriver) {
|
|
3617
4566
|
state.channelDriver.stop();
|
|
3618
4567
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -3647,6 +4596,20 @@ async function cleanup(state, opts = {}) {
|
|
|
3647
4596
|
}
|
|
3648
4597
|
async function run(options) {
|
|
3649
4598
|
const interactive = isInteractive(options.json);
|
|
4599
|
+
let logLevel;
|
|
4600
|
+
try {
|
|
4601
|
+
logLevel = resolveLogLevel(options);
|
|
4602
|
+
} catch (error2) {
|
|
4603
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4604
|
+
if (options.json) {
|
|
4605
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4606
|
+
} else {
|
|
4607
|
+
printError(message);
|
|
4608
|
+
}
|
|
4609
|
+
await shutdownTelemetry();
|
|
4610
|
+
process.exit(1);
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
3650
4613
|
const state = {
|
|
3651
4614
|
agentId: options.agent || "",
|
|
3652
4615
|
agentName: null,
|
|
@@ -3655,6 +4618,7 @@ async function run(options) {
|
|
|
3655
4618
|
idleTimeout: options.idleTimeout ?? null,
|
|
3656
4619
|
json: options.json ?? false,
|
|
3657
4620
|
interactive,
|
|
4621
|
+
logLevel,
|
|
3658
4622
|
connected: false,
|
|
3659
4623
|
opencodeConnected: false,
|
|
3660
4624
|
opencodeVersion: null,
|
|
@@ -3666,13 +4630,14 @@ async function run(options) {
|
|
|
3666
4630
|
activityLog: [],
|
|
3667
4631
|
messageCount: 0,
|
|
3668
4632
|
lastProxiedActivityAt: null,
|
|
4633
|
+
sessionCleanupTimers: [],
|
|
3669
4634
|
authHeader: ""
|
|
3670
4635
|
};
|
|
3671
4636
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
3672
4637
|
log2(
|
|
3673
4638
|
state,
|
|
3674
|
-
"
|
|
3675
|
-
|
|
4639
|
+
"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.",
|
|
4640
|
+
"warn"
|
|
3676
4641
|
);
|
|
3677
4642
|
}
|
|
3678
4643
|
const handleSignal = async () => {
|
|
@@ -3715,15 +4680,15 @@ async function run(options) {
|
|
|
3715
4680
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
3716
4681
|
if (resolved.agent_id) {
|
|
3717
4682
|
state.agentId = resolved.agent_id;
|
|
3718
|
-
log2(state, `Resolved
|
|
4683
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
3719
4684
|
if (state.interactive && !state.json) {
|
|
3720
4685
|
logActivity(state, {
|
|
3721
4686
|
type: "info",
|
|
3722
|
-
message: `
|
|
4687
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
3723
4688
|
});
|
|
3724
4689
|
}
|
|
3725
4690
|
} else {
|
|
3726
|
-
printError(resolved.error || "Failed to resolve
|
|
4691
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
3727
4692
|
process.exit(1);
|
|
3728
4693
|
}
|
|
3729
4694
|
} else {
|
|
@@ -3751,7 +4716,7 @@ async function run(options) {
|
|
|
3751
4716
|
console.log(chalk6.bold("Evident Run"));
|
|
3752
4717
|
console.log(chalk6.dim("-".repeat(40)));
|
|
3753
4718
|
}
|
|
3754
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
4719
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
3755
4720
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
3756
4721
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
3757
4722
|
spinner?.fail("Authentication failed");
|
|
@@ -3763,14 +4728,14 @@ async function run(options) {
|
|
|
3763
4728
|
"Login successful! Retrying..."
|
|
3764
4729
|
);
|
|
3765
4730
|
state.authHeader = getAuthHeader(credentials2);
|
|
3766
|
-
spinner?.start("Validating
|
|
4731
|
+
spinner?.start("Validating runner...");
|
|
3767
4732
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
3768
4733
|
}
|
|
3769
4734
|
if (!validation.valid) {
|
|
3770
|
-
spinner?.fail(`
|
|
4735
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
3771
4736
|
throw new Error(validation.error);
|
|
3772
4737
|
}
|
|
3773
|
-
spinner?.succeed(`
|
|
4738
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
3774
4739
|
state.agentName = validation.agent.name;
|
|
3775
4740
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
3776
4741
|
try {
|
|
@@ -3788,9 +4753,9 @@ async function run(options) {
|
|
|
3788
4753
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
3789
4754
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
3790
4755
|
if (versionWarning) {
|
|
3791
|
-
log2(state, versionWarning,
|
|
4756
|
+
log2(state, versionWarning, "warn");
|
|
3792
4757
|
if (state.interactive && !state.json) {
|
|
3793
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4758
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
3794
4759
|
}
|
|
3795
4760
|
}
|
|
3796
4761
|
} catch (error2) {
|
|
@@ -3805,11 +4770,17 @@ async function run(options) {
|
|
|
3805
4770
|
getAuthHeader: () => state.authHeader,
|
|
3806
4771
|
conversationFilter: state.conversationFilter,
|
|
3807
4772
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
3808
|
-
log: (entry) =>
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
4773
|
+
log: (entry) => (
|
|
4774
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4775
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4776
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4777
|
+
logActivity(state, {
|
|
4778
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4779
|
+
level: entry.level,
|
|
4780
|
+
message: entry.message,
|
|
4781
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4782
|
+
})
|
|
4783
|
+
)
|
|
3813
4784
|
});
|
|
3814
4785
|
state.channelDriver = channelDriver;
|
|
3815
4786
|
const connection = new RunnerConnection({
|
|
@@ -3823,7 +4794,7 @@ async function run(options) {
|
|
|
3823
4794
|
state.agentId = agentId;
|
|
3824
4795
|
logActivity(state, {
|
|
3825
4796
|
type: "info",
|
|
3826
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
4797
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
3827
4798
|
});
|
|
3828
4799
|
emitAgentConnected(state.agentId, {
|
|
3829
4800
|
port: state.port,
|
|
@@ -3908,6 +4879,7 @@ async function run(options) {
|
|
|
3908
4879
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
3909
4880
|
throw error2;
|
|
3910
4881
|
}
|
|
4882
|
+
scheduleSessionCleanup(state, channelDriver, options);
|
|
3911
4883
|
if (!interactive || state.json) {
|
|
3912
4884
|
log2(state, "Driving channel messages...");
|
|
3913
4885
|
}
|
|
@@ -3962,15 +4934,34 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
3962
4934
|
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
4935
|
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
4936
|
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]", "
|
|
4937
|
+
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("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4938
|
+
"--log-level <level>",
|
|
4939
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
4940
|
+
).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(
|
|
4941
|
+
"--session-cleanup-max-age <duration>",
|
|
4942
|
+
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4943
|
+
).option(
|
|
4944
|
+
"--session-cleanup-max-count <n>",
|
|
4945
|
+
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
4946
|
+
).option(
|
|
4947
|
+
"--session-cleanup-interval <duration>",
|
|
4948
|
+
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
4949
|
+
).action(
|
|
3966
4950
|
(options) => {
|
|
3967
4951
|
run({
|
|
3968
4952
|
agent: options.agent,
|
|
3969
4953
|
port: parseInt(options.port, 10),
|
|
4954
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
4955
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
4956
|
+
logLevel: options.logLevel,
|
|
3970
4957
|
verbose: options.verbose,
|
|
3971
4958
|
conversation: options.conversation,
|
|
3972
4959
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
3973
|
-
json: options.json
|
|
4960
|
+
json: options.json,
|
|
4961
|
+
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4962
|
+
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4963
|
+
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4964
|
+
sessionCleanupInterval: options.sessionCleanupInterval
|
|
3974
4965
|
});
|
|
3975
4966
|
}
|
|
3976
4967
|
);
|