@evident-ai/cli 3.0.1-dev.c152b49 → 3.0.1-dev.c56b413
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 +3 -1
- package/dist/index.js +1313 -193
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -471,12 +471,6 @@ import chalk6 from "chalk";
|
|
|
471
471
|
import ora3 from "ora";
|
|
472
472
|
import { select as select3 } from "@inquirer/prompts";
|
|
473
473
|
|
|
474
|
-
// ../../packages/types/src/opencode/index.ts
|
|
475
|
-
function opencodeMessageIdFor(queuedMessageId) {
|
|
476
|
-
const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
|
|
477
|
-
return `msg_${sanitized}`;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
474
|
// ../../packages/types/src/telemetry/index.ts
|
|
481
475
|
var TelemetryEventTypes = {
|
|
482
476
|
// Agent activity events (shown in web UI activity log)
|
|
@@ -515,7 +509,10 @@ function stripQuery(url) {
|
|
|
515
509
|
}
|
|
516
510
|
|
|
517
511
|
// src/lib/telemetry.ts
|
|
518
|
-
var CLI_VERSION = process.env.npm_package_version
|
|
512
|
+
var CLI_VERSION = (true ? "3.0.0" : void 0) ?? process.env.npm_package_version ?? "unknown";
|
|
513
|
+
function getCliVersion() {
|
|
514
|
+
return CLI_VERSION;
|
|
515
|
+
}
|
|
519
516
|
var eventBuffer = [];
|
|
520
517
|
var flushTimeout = null;
|
|
521
518
|
var isShuttingDown = false;
|
|
@@ -709,13 +706,13 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
709
706
|
if (health.healthy) {
|
|
710
707
|
return health;
|
|
711
708
|
}
|
|
712
|
-
await new Promise((
|
|
709
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
713
710
|
}
|
|
714
711
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
715
712
|
}
|
|
716
713
|
|
|
717
714
|
// src/lib/opencode/opencode-version-gate.ts
|
|
718
|
-
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
|
|
715
|
+
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11", "1.18.3"];
|
|
719
716
|
function isQueueValidatedVersion(version2) {
|
|
720
717
|
if (!version2) return false;
|
|
721
718
|
return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
|
|
@@ -1039,7 +1036,11 @@ function roleOf(m) {
|
|
|
1039
1036
|
}
|
|
1040
1037
|
function completedOf(m) {
|
|
1041
1038
|
if (!m || typeof m !== "object") return void 0;
|
|
1042
|
-
return m.info?.time?.completed;
|
|
1039
|
+
return m.info?.time?.completed ?? m.time?.completed;
|
|
1040
|
+
}
|
|
1041
|
+
function createdOf(m) {
|
|
1042
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1043
|
+
return m.info?.time?.created ?? m.time?.created;
|
|
1043
1044
|
}
|
|
1044
1045
|
function idOf(m) {
|
|
1045
1046
|
if (!m || typeof m !== "object") return void 0;
|
|
@@ -1067,6 +1068,94 @@ function isAssistantInFlight(m) {
|
|
|
1067
1068
|
if (completedOf(m) == null) return true;
|
|
1068
1069
|
return finishOf(m) === "tool-calls";
|
|
1069
1070
|
}
|
|
1071
|
+
async function getSessionMessages(port, sessionId) {
|
|
1072
|
+
try {
|
|
1073
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
|
|
1074
|
+
if (!res.ok) return null;
|
|
1075
|
+
const body = await res.json();
|
|
1076
|
+
return Array.isArray(body) ? body : null;
|
|
1077
|
+
} catch {
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
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
|
+
}
|
|
1070
1159
|
async function createOpenCodeSession(port, directory) {
|
|
1071
1160
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1072
1161
|
if (directory && directory.trim()) {
|
|
@@ -1084,9 +1173,16 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1084
1173
|
const data = await response.json();
|
|
1085
1174
|
return data.id;
|
|
1086
1175
|
}
|
|
1087
|
-
|
|
1176
|
+
function messageText(m) {
|
|
1177
|
+
if (!m || !Array.isArray(m.parts)) return "";
|
|
1178
|
+
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1179
|
+
}
|
|
1180
|
+
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1181
|
+
const before = await getSessionMessages(port, sessionId);
|
|
1182
|
+
const knownUserIds = new Set(
|
|
1183
|
+
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1184
|
+
);
|
|
1088
1185
|
const body = {
|
|
1089
|
-
messageID: messageId,
|
|
1090
1186
|
parts: [{ type: "text", text: content }]
|
|
1091
1187
|
};
|
|
1092
1188
|
if (options?.agent) {
|
|
@@ -1110,6 +1206,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
|
1110
1206
|
const text = await res.text().catch(() => "");
|
|
1111
1207
|
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1112
1208
|
}
|
|
1209
|
+
const READ_BACK_ATTEMPTS = 5;
|
|
1210
|
+
const READ_BACK_DELAY_MS = 150;
|
|
1211
|
+
for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
|
|
1212
|
+
const after = await getSessionMessages(port, sessionId);
|
|
1213
|
+
if (after) {
|
|
1214
|
+
let best = null;
|
|
1215
|
+
for (const m of after) {
|
|
1216
|
+
if (roleOf(m) !== "user") continue;
|
|
1217
|
+
const id = idOf(m);
|
|
1218
|
+
if (typeof id !== "string" || knownUserIds.has(id)) continue;
|
|
1219
|
+
if (messageText(m) !== content) continue;
|
|
1220
|
+
const created = createdOf(m) ?? 0;
|
|
1221
|
+
if (best === null || created > best.created) {
|
|
1222
|
+
best = { id, created };
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
if (best) return best.id;
|
|
1226
|
+
}
|
|
1227
|
+
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1228
|
+
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return null;
|
|
1113
1232
|
}
|
|
1114
1233
|
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1115
1234
|
if (!messages || messages.length === 0) return null;
|
|
@@ -1126,19 +1245,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
|
|
|
1126
1245
|
}
|
|
1127
1246
|
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1128
1247
|
if (!messages || messages.length === 0) return null;
|
|
1248
|
+
let lastCorrelated = null;
|
|
1249
|
+
let lastNonErrored = null;
|
|
1129
1250
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1130
1251
|
const m = messages[i];
|
|
1131
|
-
if (roleOf(m)
|
|
1252
|
+
if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
|
|
1253
|
+
if (lastCorrelated === null) lastCorrelated = m;
|
|
1254
|
+
if (errorOf(m) == null) {
|
|
1255
|
+
lastNonErrored = m;
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1132
1258
|
}
|
|
1259
|
+
if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
|
|
1133
1260
|
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1134
1261
|
if (userIndex === -1) return null;
|
|
1135
1262
|
let last = null;
|
|
1263
|
+
let lastOk = null;
|
|
1136
1264
|
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1137
1265
|
const role = roleOf(messages[i]);
|
|
1138
1266
|
if (role === "user") break;
|
|
1139
|
-
if (role === "assistant")
|
|
1267
|
+
if (role === "assistant") {
|
|
1268
|
+
last = messages[i];
|
|
1269
|
+
if (errorOf(messages[i]) == null) lastOk = messages[i];
|
|
1270
|
+
}
|
|
1140
1271
|
}
|
|
1141
|
-
return last;
|
|
1272
|
+
return lastOk ?? last;
|
|
1142
1273
|
}
|
|
1143
1274
|
function messageRunState(messages, userMessageId) {
|
|
1144
1275
|
if (!messages || messages.length === 0) return "unknown";
|
|
@@ -1151,6 +1282,11 @@ function messageRunState(messages, userMessageId) {
|
|
|
1151
1282
|
if (isAssistantInFlight(reply)) return "running";
|
|
1152
1283
|
return errorOf(reply) != null ? "failed" : "done";
|
|
1153
1284
|
}
|
|
1285
|
+
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
1286
|
+
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
1287
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1288
|
+
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1289
|
+
}
|
|
1154
1290
|
function messageError(messages, userMessageId) {
|
|
1155
1291
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1156
1292
|
const error2 = errorOf(reply);
|
|
@@ -1170,8 +1306,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1170
1306
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1171
1307
|
);
|
|
1172
1308
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1309
|
+
|
|
1310
|
+
// src/lib/opencode/session-cleanup.ts
|
|
1311
|
+
var DURATION_UNIT_MS = {
|
|
1312
|
+
s: 1e3,
|
|
1313
|
+
m: 60 * 1e3,
|
|
1314
|
+
h: 60 * 60 * 1e3,
|
|
1315
|
+
d: 24 * 60 * 60 * 1e3
|
|
1316
|
+
};
|
|
1317
|
+
function parseDurationMs(input) {
|
|
1318
|
+
const trimmed = input.trim();
|
|
1319
|
+
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
|
1320
|
+
if (!match) {
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
const value = Number(match[1]);
|
|
1326
|
+
if (value <= 0) {
|
|
1327
|
+
throw new Error(`Invalid duration "${input}": must be a positive value.`);
|
|
1328
|
+
}
|
|
1329
|
+
return value * DURATION_UNIT_MS[match[2]];
|
|
1330
|
+
}
|
|
1331
|
+
function selectSessionsToDelete(sessions, opts) {
|
|
1332
|
+
const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
|
|
1333
|
+
if (maxAgeMs === void 0 && maxCount === void 0) return [];
|
|
1334
|
+
const ageEligible = (s) => {
|
|
1335
|
+
if (maxAgeMs === void 0) return false;
|
|
1336
|
+
if (s.lastActivityMs === null) return true;
|
|
1337
|
+
return nowMs - s.lastActivityMs > maxAgeMs;
|
|
1338
|
+
};
|
|
1339
|
+
const countEligibleIds = /* @__PURE__ */ new Set();
|
|
1340
|
+
if (maxCount !== void 0) {
|
|
1341
|
+
const byActivityDesc = [...sessions].sort(
|
|
1342
|
+
(a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
|
|
1343
|
+
);
|
|
1344
|
+
for (const s of byActivityDesc.slice(maxCount)) {
|
|
1345
|
+
countEligibleIds.add(s.id);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const toDelete = [];
|
|
1349
|
+
for (const s of sessions) {
|
|
1350
|
+
if (protectedIds.has(s.id)) continue;
|
|
1351
|
+
if (ageEligible(s) || countEligibleIds.has(s.id)) {
|
|
1352
|
+
toDelete.push(s.id);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return toDelete;
|
|
1356
|
+
}
|
|
1357
|
+
var DEFAULT_INTERVAL = "1h";
|
|
1358
|
+
function resolve(flag, envValue, fallback) {
|
|
1359
|
+
return flag ?? envValue ?? fallback;
|
|
1360
|
+
}
|
|
1361
|
+
function parseMaxCount(input) {
|
|
1362
|
+
const trimmed = input.trim();
|
|
1363
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1364
|
+
throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
|
|
1365
|
+
}
|
|
1366
|
+
const value = Number(trimmed);
|
|
1367
|
+
if (value <= 0) {
|
|
1368
|
+
throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
|
|
1369
|
+
}
|
|
1370
|
+
return value;
|
|
1371
|
+
}
|
|
1372
|
+
function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
1373
|
+
const warnings = [];
|
|
1374
|
+
const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
|
|
1375
|
+
const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
|
|
1376
|
+
const intervalRaw = resolve(
|
|
1377
|
+
flags.interval,
|
|
1378
|
+
env.EVIDENT_SESSION_CLEANUP_INTERVAL,
|
|
1379
|
+
DEFAULT_INTERVAL
|
|
1380
|
+
);
|
|
1381
|
+
let maxAgeMs;
|
|
1382
|
+
if (maxAgeRaw !== void 0) {
|
|
1383
|
+
try {
|
|
1384
|
+
maxAgeMs = parseDurationMs(maxAgeRaw);
|
|
1385
|
+
} catch (err) {
|
|
1386
|
+
warnings.push(
|
|
1387
|
+
`Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
let maxCount;
|
|
1392
|
+
if (maxCountRaw !== void 0) {
|
|
1393
|
+
try {
|
|
1394
|
+
maxCount = parseMaxCount(maxCountRaw);
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
warnings.push(
|
|
1397
|
+
`Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
let intervalMs;
|
|
1402
|
+
try {
|
|
1403
|
+
intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
warnings.push(
|
|
1406
|
+
`Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
|
|
1407
|
+
);
|
|
1408
|
+
intervalMs = parseDurationMs(DEFAULT_INTERVAL);
|
|
1409
|
+
}
|
|
1410
|
+
const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
|
|
1411
|
+
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
1175
1412
|
}
|
|
1176
1413
|
|
|
1177
1414
|
// src/lib/tunnel/connection.ts
|
|
@@ -1250,24 +1487,26 @@ var StreamForwarder = class {
|
|
|
1250
1487
|
this.send({ type: "res_end", sid });
|
|
1251
1488
|
return;
|
|
1252
1489
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1490
|
+
if (process.env.DEBUG) {
|
|
1491
|
+
log("debug", "agent_request", {
|
|
1492
|
+
correlation_id: correlationId,
|
|
1493
|
+
sid,
|
|
1494
|
+
method,
|
|
1495
|
+
path: stripQuery(path)
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1259
1498
|
const ac = new AbortController();
|
|
1260
1499
|
let bodyPromise;
|
|
1261
1500
|
let pushBody;
|
|
1262
1501
|
let endBody;
|
|
1263
1502
|
if (has_body) {
|
|
1264
1503
|
const chunks = [];
|
|
1265
|
-
bodyPromise = new Promise((
|
|
1504
|
+
bodyPromise = new Promise((resolve2) => {
|
|
1266
1505
|
pushBody = (buf) => {
|
|
1267
1506
|
chunks.push(buf);
|
|
1268
1507
|
};
|
|
1269
1508
|
endBody = () => {
|
|
1270
|
-
|
|
1509
|
+
resolve2(Buffer.concat(chunks));
|
|
1271
1510
|
};
|
|
1272
1511
|
});
|
|
1273
1512
|
}
|
|
@@ -1302,12 +1541,14 @@ var StreamForwarder = class {
|
|
|
1302
1541
|
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1303
1542
|
});
|
|
1304
1543
|
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1544
|
+
if (process.env.DEBUG) {
|
|
1545
|
+
log("debug", "agent_response", {
|
|
1546
|
+
correlation_id: correlationId,
|
|
1547
|
+
sid,
|
|
1548
|
+
status: upstream.status,
|
|
1549
|
+
duration_ms: Date.now() - startedAt
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1311
1552
|
this.callbacks.onHead?.(sid, upstream.status);
|
|
1312
1553
|
try {
|
|
1313
1554
|
if (upstream.body) {
|
|
@@ -1383,7 +1624,7 @@ function connectTunnel(options) {
|
|
|
1383
1624
|
} = options;
|
|
1384
1625
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1385
1626
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1386
|
-
return new Promise((
|
|
1627
|
+
return new Promise((resolve2, reject) => {
|
|
1387
1628
|
const ws = new WebSocket2(url, {
|
|
1388
1629
|
headers: {
|
|
1389
1630
|
Authorization: authHeader
|
|
@@ -1448,7 +1689,7 @@ function connectTunnel(options) {
|
|
|
1448
1689
|
clearTimeout(connectionTimeout);
|
|
1449
1690
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1450
1691
|
onConnected?.(connectedAgentId);
|
|
1451
|
-
|
|
1692
|
+
resolve2({
|
|
1452
1693
|
ws,
|
|
1453
1694
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1454
1695
|
});
|
|
@@ -1570,6 +1811,12 @@ function messageIdOf(m) {
|
|
|
1570
1811
|
const infoId = m.info?.id;
|
|
1571
1812
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1572
1813
|
}
|
|
1814
|
+
var LOG_LEVELS = {
|
|
1815
|
+
debug: 0,
|
|
1816
|
+
info: 1,
|
|
1817
|
+
warn: 2,
|
|
1818
|
+
error: 3
|
|
1819
|
+
};
|
|
1573
1820
|
var DEFAULT_RETRY_POLICY = {
|
|
1574
1821
|
maxAttempts: 6,
|
|
1575
1822
|
baseDelayMs: 500,
|
|
@@ -1577,8 +1824,10 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1577
1824
|
};
|
|
1578
1825
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1579
1826
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1580
|
-
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1581
1827
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1828
|
+
var HEARTBEAT_MS = 6e4;
|
|
1829
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1830
|
+
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1582
1831
|
var ChannelAuthError = class extends Error {
|
|
1583
1832
|
constructor(message) {
|
|
1584
1833
|
super(message);
|
|
@@ -1613,11 +1862,18 @@ var ChannelDriver = class {
|
|
|
1613
1862
|
sleep;
|
|
1614
1863
|
pausedPollIntervalMs;
|
|
1615
1864
|
pausedMaxWaitMs;
|
|
1616
|
-
dispatchConfirmMs;
|
|
1617
1865
|
stuckQueuedMs;
|
|
1618
1866
|
now;
|
|
1619
1867
|
/** Cache of conversationId → opencode sessionId. */
|
|
1620
1868
|
sessions = /* @__PURE__ */ new Map();
|
|
1869
|
+
/**
|
|
1870
|
+
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1871
|
+
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
1872
|
+
* "the one new user row" — which is only unambiguous if no OTHER dispatch into
|
|
1873
|
+
* the SAME session interleaves its snapshot→POST→read-back. This map chains each
|
|
1874
|
+
* session's dispatches so they run serially; distinct sessions stay concurrent.
|
|
1875
|
+
*/
|
|
1876
|
+
sessionDispatchLocks = /* @__PURE__ */ new Map();
|
|
1621
1877
|
/**
|
|
1622
1878
|
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1623
1879
|
* session: one polling loop services all of that session's in-flight messages.
|
|
@@ -1668,6 +1924,29 @@ var ChannelDriver = class {
|
|
|
1668
1924
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1669
1925
|
*/
|
|
1670
1926
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
1927
|
+
/**
|
|
1928
|
+
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
1929
|
+
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
1930
|
+
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
1931
|
+
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
1932
|
+
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
1933
|
+
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
1934
|
+
*/
|
|
1935
|
+
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
1936
|
+
/**
|
|
1937
|
+
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1938
|
+
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
1939
|
+
* is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
|
|
1940
|
+
* persist hasn't landed before tick N+1 re-reads the still-null
|
|
1941
|
+
* `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
|
|
1942
|
+
* A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
|
|
1943
|
+
* short-circuits while it is present, so a null-id row is re-dispatched AT MOST
|
|
1944
|
+
* ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
|
|
1945
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
1946
|
+
* re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
|
|
1947
|
+
* so the NEXT tick may retry exactly once more).
|
|
1948
|
+
*/
|
|
1949
|
+
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
1671
1950
|
/**
|
|
1672
1951
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1673
1952
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1675,8 +1954,43 @@ var ChannelDriver = class {
|
|
|
1675
1954
|
* not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
|
|
1676
1955
|
*/
|
|
1677
1956
|
opencodeDirectory = void 0;
|
|
1957
|
+
/**
|
|
1958
|
+
* Cache of opencode `sessionId → parentID` (its parent session, or `null` when
|
|
1959
|
+
* the session is a root with no parent). Sub-agents spawned via the `task` tool
|
|
1960
|
+
* run in CHILD sessions whose `parentID` chains up to the Evident-created
|
|
1961
|
+
* (watched) session; we resolve this once per session so a child-session
|
|
1962
|
+
* question/permission can be attributed to the watched session's subtree
|
|
1963
|
+
* (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
|
|
1964
|
+
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1965
|
+
*/
|
|
1966
|
+
sessionParents = /* @__PURE__ */ new Map();
|
|
1967
|
+
/**
|
|
1968
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
1969
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
1970
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
1971
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
1972
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
1973
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
1974
|
+
* no watcher) can resolve the title.
|
|
1975
|
+
*/
|
|
1976
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1678
1977
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1679
1978
|
draining = false;
|
|
1979
|
+
/**
|
|
1980
|
+
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
1981
|
+
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
1982
|
+
* is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
|
|
1983
|
+
* drain that entered before `stop()` still registers its watcher).
|
|
1984
|
+
*/
|
|
1985
|
+
activeDrain = null;
|
|
1986
|
+
/**
|
|
1987
|
+
* Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
|
|
1988
|
+
* dispatches NEW work (it returns 0 immediately) — but the per-session watcher
|
|
1989
|
+
* loops already running keep going so in-flight turns can finish and deliver
|
|
1990
|
+
* their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
|
|
1991
|
+
* and stops opencode.
|
|
1992
|
+
*/
|
|
1993
|
+
stopped = false;
|
|
1680
1994
|
constructor(config2) {
|
|
1681
1995
|
this.agentId = config2.agentId;
|
|
1682
1996
|
this.port = config2.port;
|
|
@@ -1690,7 +2004,6 @@ var ChannelDriver = class {
|
|
|
1690
2004
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1691
2005
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1692
2006
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1693
|
-
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1694
2007
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1695
2008
|
this.now = config2.now ?? (() => Date.now());
|
|
1696
2009
|
}
|
|
@@ -1709,8 +2022,21 @@ var ChannelDriver = class {
|
|
|
1709
2022
|
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1710
2023
|
*/
|
|
1711
2024
|
async drainPending() {
|
|
2025
|
+
if (this.stopped) return 0;
|
|
1712
2026
|
if (this.draining) return 0;
|
|
1713
2027
|
this.draining = true;
|
|
2028
|
+
const run2 = this.runDrain();
|
|
2029
|
+
this.activeDrain = run2.then(
|
|
2030
|
+
() => {
|
|
2031
|
+
this.activeDrain = null;
|
|
2032
|
+
},
|
|
2033
|
+
() => {
|
|
2034
|
+
this.activeDrain = null;
|
|
2035
|
+
}
|
|
2036
|
+
);
|
|
2037
|
+
return run2;
|
|
2038
|
+
}
|
|
2039
|
+
async runDrain() {
|
|
1714
2040
|
let dispatched = 0;
|
|
1715
2041
|
try {
|
|
1716
2042
|
const conversations = await this.getPendingConversations();
|
|
@@ -1722,6 +2048,7 @@ var ChannelDriver = class {
|
|
|
1722
2048
|
});
|
|
1723
2049
|
}
|
|
1724
2050
|
for (const conv of conversations) {
|
|
2051
|
+
if (this.stopped) break;
|
|
1725
2052
|
dispatched += await this.processConversation(conv);
|
|
1726
2053
|
}
|
|
1727
2054
|
await this.readoptProcessing();
|
|
@@ -1742,6 +2069,73 @@ var ChannelDriver = class {
|
|
|
1742
2069
|
}
|
|
1743
2070
|
return false;
|
|
1744
2071
|
}
|
|
2072
|
+
/**
|
|
2073
|
+
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2074
|
+
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
2075
|
+
* `watchers` entry whose `inFlight` set is non-empty — the same predicate
|
|
2076
|
+
* `hasInFlightWatchers()` uses, lifted to return the ids.
|
|
2077
|
+
*
|
|
2078
|
+
* Deliberately does NOT include `this.sessions` (the permanent, never-pruned
|
|
2079
|
+
* conversation→session cache). Protecting every bound-but-idle session there
|
|
2080
|
+
* would shield nearly every session and defeat cleanup — AND it is unnecessary:
|
|
2081
|
+
* `ensureSession` is self-healing (it recreates a session whose id no longer
|
|
2082
|
+
* exists), so deleting an idle bound session is harmless — the conversation's
|
|
2083
|
+
* next turn transparently rebinds a fresh one. The only thing worth protecting
|
|
2084
|
+
* is a session with a turn ACTIVELY in flight right now: tearing that down
|
|
2085
|
+
* mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
|
|
2086
|
+
*/
|
|
2087
|
+
protectedSessionIds() {
|
|
2088
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2089
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
2090
|
+
if (watcher.inFlight.size > 0) ids.add(sessionId);
|
|
2091
|
+
}
|
|
2092
|
+
return ids;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
|
|
2096
|
+
* this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
|
|
2097
|
+
* — but the watcher loops already tracking in-flight turns keep running, so a
|
|
2098
|
+
* turn that has finished (or is about to) still fires `markDone` and delivers
|
|
2099
|
+
* its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
|
|
2100
|
+
*/
|
|
2101
|
+
stop() {
|
|
2102
|
+
this.stopped = true;
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
|
|
2106
|
+
* graceful shutdown, so a turn whose reply is ready — or completes within the
|
|
2107
|
+
* window — is delivered before the process exits, instead of being cut off and
|
|
2108
|
+
* left for the ADR-0046 restart-recovery path.
|
|
2109
|
+
*
|
|
2110
|
+
* Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
|
|
2111
|
+
* far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
|
|
2112
|
+
* window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
|
|
2113
|
+
* set empties OR the timeout elapses. Anything still in flight at the timeout is
|
|
2114
|
+
* safe to abandon — it stays `processing` server-side and is re-adopted on the
|
|
2115
|
+
* next runner start (ADR-0046).
|
|
2116
|
+
*
|
|
2117
|
+
* @returns true if all in-flight work settled within the window; false if the
|
|
2118
|
+
* timeout elapsed with work still in flight.
|
|
2119
|
+
*/
|
|
2120
|
+
async waitForInFlight(timeoutMs) {
|
|
2121
|
+
const deadline = this.now() + timeoutMs;
|
|
2122
|
+
const step = Math.min(this.pausedPollIntervalMs, 250);
|
|
2123
|
+
if (this.activeDrain) {
|
|
2124
|
+
let drainSettled = false;
|
|
2125
|
+
void this.activeDrain.then(() => {
|
|
2126
|
+
drainSettled = true;
|
|
2127
|
+
});
|
|
2128
|
+
while (!drainSettled) {
|
|
2129
|
+
if (this.now() >= deadline) return false;
|
|
2130
|
+
await this.sleep(step);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
while (this.hasInFlightWatchers()) {
|
|
2134
|
+
if (this.now() >= deadline) return false;
|
|
2135
|
+
await this.sleep(step);
|
|
2136
|
+
}
|
|
2137
|
+
return true;
|
|
2138
|
+
}
|
|
1745
2139
|
/**
|
|
1746
2140
|
* Await all outstanding per-session watchers (WI-3).
|
|
1747
2141
|
*
|
|
@@ -1778,15 +2172,16 @@ var ChannelDriver = class {
|
|
|
1778
2172
|
let dispatched = 0;
|
|
1779
2173
|
let skippedAlreadyDispatched = 0;
|
|
1780
2174
|
for (const message of messages) {
|
|
2175
|
+
if (this.stopped) break;
|
|
1781
2176
|
if (this.dispatched.has(message.id)) {
|
|
1782
2177
|
skippedAlreadyDispatched += 1;
|
|
1783
2178
|
continue;
|
|
1784
2179
|
}
|
|
1785
|
-
const opencodeMessageId = opencodeMessageIdFor2(message.id);
|
|
1786
2180
|
const options = {
|
|
1787
2181
|
agent: message.opencode_agent ?? void 0,
|
|
1788
2182
|
model: message.opencode_model ?? void 0
|
|
1789
2183
|
};
|
|
2184
|
+
let opencodeMessageId;
|
|
1790
2185
|
try {
|
|
1791
2186
|
this.log({
|
|
1792
2187
|
level: "info",
|
|
@@ -1794,10 +2189,23 @@ var ChannelDriver = class {
|
|
|
1794
2189
|
conversation_id: conv.id,
|
|
1795
2190
|
message_id: message.id
|
|
1796
2191
|
});
|
|
1797
|
-
await
|
|
2192
|
+
opencodeMessageId = await this.dispatchLocked(
|
|
2193
|
+
sessionId,
|
|
2194
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2195
|
+
);
|
|
1798
2196
|
} catch (err) {
|
|
1799
2197
|
if (err instanceof ChannelAuthError) throw err;
|
|
1800
2198
|
this.dispatched.delete(message.id);
|
|
2199
|
+
if (await sessionExists(this.port, sessionId) === false) {
|
|
2200
|
+
this.sessions.delete(conv.id);
|
|
2201
|
+
this.log({
|
|
2202
|
+
level: "warn",
|
|
2203
|
+
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.`,
|
|
2204
|
+
conversation_id: conv.id,
|
|
2205
|
+
message_id: message.id
|
|
2206
|
+
});
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
1801
2209
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1802
2210
|
});
|
|
1803
2211
|
this.log({
|
|
@@ -1808,6 +2216,15 @@ var ChannelDriver = class {
|
|
|
1808
2216
|
});
|
|
1809
2217
|
continue;
|
|
1810
2218
|
}
|
|
2219
|
+
if (opencodeMessageId === null) {
|
|
2220
|
+
this.log({
|
|
2221
|
+
level: "warn",
|
|
2222
|
+
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`,
|
|
2223
|
+
conversation_id: conv.id,
|
|
2224
|
+
message_id: message.id
|
|
2225
|
+
});
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
1811
2228
|
this.dispatched.add(message.id);
|
|
1812
2229
|
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1813
2230
|
dispatched += 1;
|
|
@@ -1815,7 +2232,7 @@ var ChannelDriver = class {
|
|
|
1815
2232
|
}
|
|
1816
2233
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
1817
2234
|
this.log({
|
|
1818
|
-
level: "
|
|
2235
|
+
level: "warn",
|
|
1819
2236
|
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).`,
|
|
1820
2237
|
conversation_id: conv.id
|
|
1821
2238
|
});
|
|
@@ -1824,16 +2241,33 @@ var ChannelDriver = class {
|
|
|
1824
2241
|
return dispatched;
|
|
1825
2242
|
}
|
|
1826
2243
|
async ensureSession(conv) {
|
|
1827
|
-
const
|
|
1828
|
-
if (
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2244
|
+
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2245
|
+
if (bound) {
|
|
2246
|
+
const exists = await sessionExists(this.port, bound);
|
|
2247
|
+
if (exists === false) {
|
|
2248
|
+
this.log({
|
|
2249
|
+
level: "debug",
|
|
2250
|
+
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.`,
|
|
2251
|
+
conversation_id: conv.id
|
|
2252
|
+
});
|
|
2253
|
+
this.sessions.delete(conv.id);
|
|
2254
|
+
return this.createAndBindSession(conv.id);
|
|
2255
|
+
}
|
|
2256
|
+
this.sessions.set(conv.id, bound);
|
|
2257
|
+
return bound;
|
|
1832
2258
|
}
|
|
2259
|
+
return this.createAndBindSession(conv.id);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
2263
|
+
* best-effort persist it server-side. Shared by the first-ever bind and the
|
|
2264
|
+
* self-heal recreate path in `ensureSession`.
|
|
2265
|
+
*/
|
|
2266
|
+
async createAndBindSession(conversationId) {
|
|
1833
2267
|
const directory = await this.resolveOpenCodeDirectory();
|
|
1834
2268
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1835
|
-
this.sessions.set(
|
|
1836
|
-
await this.persistSession(
|
|
2269
|
+
this.sessions.set(conversationId, sessionId);
|
|
2270
|
+
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
1837
2271
|
});
|
|
1838
2272
|
return sessionId;
|
|
1839
2273
|
}
|
|
@@ -1847,7 +2281,7 @@ var ChannelDriver = class {
|
|
|
1847
2281
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
1848
2282
|
if (!this.opencodeDirectory) {
|
|
1849
2283
|
this.log({
|
|
1850
|
-
level: "
|
|
2284
|
+
level: "warn",
|
|
1851
2285
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
1852
2286
|
});
|
|
1853
2287
|
}
|
|
@@ -1856,6 +2290,25 @@ var ChannelDriver = class {
|
|
|
1856
2290
|
// -------------------------------------------------------------------------
|
|
1857
2291
|
// Per-session watcher (WI-3)
|
|
1858
2292
|
// -------------------------------------------------------------------------
|
|
2293
|
+
/**
|
|
2294
|
+
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2295
|
+
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
2296
|
+
* never interleave and mis-correlate their read-backs. Distinct sessions run
|
|
2297
|
+
* concurrently. The chained tail intentionally ignores the prior result/error
|
|
2298
|
+
* (each dispatch reports its own outcome to its caller).
|
|
2299
|
+
*/
|
|
2300
|
+
dispatchLocked(sessionId, fn) {
|
|
2301
|
+
const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
|
|
2302
|
+
const run2 = prior.then(fn, fn);
|
|
2303
|
+
this.sessionDispatchLocks.set(
|
|
2304
|
+
sessionId,
|
|
2305
|
+
run2.then(
|
|
2306
|
+
() => void 0,
|
|
2307
|
+
() => void 0
|
|
2308
|
+
)
|
|
2309
|
+
);
|
|
2310
|
+
return run2;
|
|
2311
|
+
}
|
|
1859
2312
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1860
2313
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1861
2314
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -1865,7 +2318,9 @@ var ChannelDriver = class {
|
|
|
1865
2318
|
inFlight: /* @__PURE__ */ new Map(),
|
|
1866
2319
|
loop: null,
|
|
1867
2320
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1868
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2321
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2322
|
+
lastGoodPollAt: this.now(),
|
|
2323
|
+
hadUsablePoll: false
|
|
1869
2324
|
};
|
|
1870
2325
|
this.watchers.set(sessionId, watcher);
|
|
1871
2326
|
}
|
|
@@ -1875,20 +2330,37 @@ var ChannelDriver = class {
|
|
|
1875
2330
|
opencodeMessageId,
|
|
1876
2331
|
message,
|
|
1877
2332
|
dispatchedAt: now,
|
|
2333
|
+
processingAnchorMs: now,
|
|
1878
2334
|
deadline: now + this.pausedMaxWaitMs,
|
|
1879
2335
|
started: false,
|
|
1880
2336
|
done: false,
|
|
1881
|
-
stuckReported: false
|
|
2337
|
+
stuckReported: false,
|
|
2338
|
+
lastAliveAt: 0,
|
|
2339
|
+
aliveInFlight: false,
|
|
2340
|
+
awaitingHumanLatched: false,
|
|
2341
|
+
pausedOnQuestion: false,
|
|
2342
|
+
pausedOnPermission: false,
|
|
2343
|
+
pausedClearConfirmed: false,
|
|
2344
|
+
pausedInFlight: false,
|
|
2345
|
+
deliveryDeadlineAnchored: false
|
|
1882
2346
|
});
|
|
1883
2347
|
}
|
|
1884
2348
|
/**
|
|
1885
2349
|
* Register a RE-ADOPTED `processing` message with its session watcher
|
|
1886
2350
|
* (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
|
|
1887
2351
|
* `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
|
|
1888
|
-
* `now
|
|
1889
|
-
* (10 min after `processed_at
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
2352
|
+
* `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
|
|
2353
|
+
* fresh dispatch would (10 min after `processed_at`, not 10 min from now).
|
|
2354
|
+
*
|
|
2355
|
+
* This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
|
|
2356
|
+
* give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
|
|
2357
|
+
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
2358
|
+
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
2359
|
+
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
2360
|
+
* handed to the cron. The old "the `deadline` must settle before the ~15-min
|
|
2361
|
+
* cron or they double-drive" reasoning is superseded: liveness now settles the
|
|
2362
|
+
* actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
|
|
2363
|
+
* (only the appear-guard uses it).
|
|
1892
2364
|
*
|
|
1893
2365
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
1894
2366
|
* `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
|
|
@@ -1906,7 +2378,9 @@ var ChannelDriver = class {
|
|
|
1906
2378
|
inFlight: /* @__PURE__ */ new Map(),
|
|
1907
2379
|
loop: null,
|
|
1908
2380
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1909
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2381
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2382
|
+
lastGoodPollAt: this.now(),
|
|
2383
|
+
hadUsablePoll: false
|
|
1910
2384
|
};
|
|
1911
2385
|
this.watchers.set(sessionId, watcher);
|
|
1912
2386
|
}
|
|
@@ -1915,16 +2389,33 @@ var ChannelDriver = class {
|
|
|
1915
2389
|
opencodeMessageId,
|
|
1916
2390
|
message,
|
|
1917
2391
|
dispatchedAt: this.now(),
|
|
2392
|
+
// Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
|
|
2393
|
+
// value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
|
|
2394
|
+
// reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
|
|
2395
|
+
processingAnchorMs: processedAtMs,
|
|
1918
2396
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
1919
2397
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
1920
2398
|
started: true,
|
|
1921
2399
|
done: false,
|
|
1922
2400
|
// Not yet reported stuck-queued. The once-guard (`stuckReported`) applies,
|
|
1923
|
-
// AND the stuck-queued observer
|
|
1924
|
-
//
|
|
1925
|
-
//
|
|
1926
|
-
// (
|
|
1927
|
-
stuckReported: false
|
|
2401
|
+
// AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
|
|
2402
|
+
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2403
|
+
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2404
|
+
// (#210/#220 observability).
|
|
2405
|
+
stuckReported: false,
|
|
2406
|
+
// Task 5.2: a re-adopted actively-running row re-attaches into the SAME
|
|
2407
|
+
// watcher and so hits the SAME actively-running heartbeat branch in
|
|
2408
|
+
// `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
|
|
2409
|
+
// re-adopted and is confirming this row alive" via that `alive` heartbeat,
|
|
2410
|
+
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2411
|
+
lastAliveAt: 0,
|
|
2412
|
+
aliveInFlight: false,
|
|
2413
|
+
awaitingHumanLatched: false,
|
|
2414
|
+
pausedOnQuestion: false,
|
|
2415
|
+
pausedOnPermission: false,
|
|
2416
|
+
pausedClearConfirmed: false,
|
|
2417
|
+
pausedInFlight: false,
|
|
2418
|
+
deliveryDeadlineAnchored: false
|
|
1928
2419
|
});
|
|
1929
2420
|
}
|
|
1930
2421
|
/**
|
|
@@ -1975,12 +2466,30 @@ var ChannelDriver = class {
|
|
|
1975
2466
|
messages = Array.isArray(body) ? body : null;
|
|
1976
2467
|
}
|
|
1977
2468
|
} catch {
|
|
1978
|
-
continue;
|
|
1979
2469
|
}
|
|
2470
|
+
if (messages != null && messages.length > 0) {
|
|
2471
|
+
watcher.lastGoodPollAt = this.now();
|
|
2472
|
+
watcher.hadUsablePoll = true;
|
|
2473
|
+
} else {
|
|
2474
|
+
const emptyButReachable = messages != null;
|
|
2475
|
+
const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
|
|
2476
|
+
if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
|
|
2477
|
+
continue;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
|
|
1980
2481
|
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
1981
|
-
await this.serviceInFlightMessage(
|
|
2482
|
+
await this.serviceInFlightMessage(
|
|
2483
|
+
sessionId,
|
|
2484
|
+
watcher,
|
|
2485
|
+
inFlight,
|
|
2486
|
+
messages,
|
|
2487
|
+
openQuestions,
|
|
2488
|
+
openPermissions,
|
|
2489
|
+
questionsPolledOk,
|
|
2490
|
+
permissionsPolledOk
|
|
2491
|
+
);
|
|
1982
2492
|
}
|
|
1983
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
1984
2493
|
}
|
|
1985
2494
|
} catch (err) {
|
|
1986
2495
|
if (err instanceof ChannelAuthError) {
|
|
@@ -2002,23 +2511,55 @@ var ChannelDriver = class {
|
|
|
2002
2511
|
});
|
|
2003
2512
|
}
|
|
2004
2513
|
}
|
|
2514
|
+
/**
|
|
2515
|
+
* On FIRST observing a terminal (done/failed) state, ensure the delivery
|
|
2516
|
+
* (markDone/markFailed) transient-retry path has a real window. A long
|
|
2517
|
+
* ACTIVELY-running turn is kept past its original `deadline`, so by completion
|
|
2518
|
+
* `now >= deadline` already holds and the retry bound below would fire on the
|
|
2519
|
+
* first transient PATCH failure — dropping the message before its reply lands
|
|
2520
|
+
* (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
|
|
2521
|
+
* to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
|
|
2522
|
+
* or past now, so a still-ample window is left untouched.
|
|
2523
|
+
*/
|
|
2524
|
+
anchorDeliveryDeadline(inFlight) {
|
|
2525
|
+
if (inFlight.deliveryDeadlineAnchored) return;
|
|
2526
|
+
inFlight.deliveryDeadlineAnchored = true;
|
|
2527
|
+
if (this.now() >= inFlight.deadline) {
|
|
2528
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2005
2531
|
/**
|
|
2006
2532
|
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
2007
2533
|
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
2008
2534
|
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
2009
2535
|
* in-flight set on completion or timeout.
|
|
2010
2536
|
*/
|
|
2011
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
2537
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
|
|
2012
2538
|
const conv = watcher.conv;
|
|
2013
2539
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
2540
|
+
const id = inFlight.evidentMessageId;
|
|
2541
|
+
if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
|
|
2542
|
+
else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
|
|
2543
|
+
if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
|
|
2544
|
+
else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
|
|
2545
|
+
const observedOpen = openQuestions.has(id) || openPermissions.has(id);
|
|
2546
|
+
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2547
|
+
const awaitingHuman = observedOpen || latchedPaused;
|
|
2014
2548
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2549
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2015
2550
|
let claimed;
|
|
2016
2551
|
try {
|
|
2017
|
-
claimed = await this.markProcessing(
|
|
2552
|
+
claimed = await this.markProcessing(
|
|
2553
|
+
conv.id,
|
|
2554
|
+
inFlight.evidentMessageId,
|
|
2555
|
+
sessionId,
|
|
2556
|
+
inFlight.opencodeMessageId,
|
|
2557
|
+
title
|
|
2558
|
+
);
|
|
2018
2559
|
} catch (err) {
|
|
2019
2560
|
if (err instanceof ChannelAuthError) throw err;
|
|
2020
2561
|
this.log({
|
|
2021
|
-
level: "
|
|
2562
|
+
level: "warn",
|
|
2022
2563
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2023
2564
|
conversation_id: conv.id,
|
|
2024
2565
|
message_id: inFlight.evidentMessageId
|
|
@@ -2028,7 +2569,7 @@ var ChannelDriver = class {
|
|
|
2028
2569
|
inFlight.started = true;
|
|
2029
2570
|
if (!claimed) {
|
|
2030
2571
|
this.log({
|
|
2031
|
-
level: "
|
|
2572
|
+
level: "debug",
|
|
2032
2573
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2033
2574
|
conversation_id: conv.id,
|
|
2034
2575
|
message_id: inFlight.evidentMessageId
|
|
@@ -2036,6 +2577,7 @@ var ChannelDriver = class {
|
|
|
2036
2577
|
}
|
|
2037
2578
|
}
|
|
2038
2579
|
if (state === "done") {
|
|
2580
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2039
2581
|
if (!inFlight.done) {
|
|
2040
2582
|
this.log({
|
|
2041
2583
|
level: "info",
|
|
@@ -2043,13 +2585,20 @@ var ChannelDriver = class {
|
|
|
2043
2585
|
conversation_id: conv.id,
|
|
2044
2586
|
message_id: inFlight.evidentMessageId
|
|
2045
2587
|
});
|
|
2588
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2046
2589
|
try {
|
|
2047
|
-
await this.markDone(
|
|
2590
|
+
await this.markDone(
|
|
2591
|
+
conv.id,
|
|
2592
|
+
inFlight.evidentMessageId,
|
|
2593
|
+
sessionId,
|
|
2594
|
+
inFlight.opencodeMessageId,
|
|
2595
|
+
title
|
|
2596
|
+
);
|
|
2048
2597
|
} catch (err) {
|
|
2049
2598
|
if (err instanceof ChannelAuthError) throw err;
|
|
2050
2599
|
if (err instanceof ChannelTerminalError) {
|
|
2051
2600
|
this.log({
|
|
2052
|
-
level: "
|
|
2601
|
+
level: "warn",
|
|
2053
2602
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2054
2603
|
conversation_id: conv.id,
|
|
2055
2604
|
message_id: inFlight.evidentMessageId
|
|
@@ -2059,7 +2608,7 @@ var ChannelDriver = class {
|
|
|
2059
2608
|
}
|
|
2060
2609
|
if (this.now() >= inFlight.deadline) {
|
|
2061
2610
|
this.log({
|
|
2062
|
-
level: "
|
|
2611
|
+
level: "warn",
|
|
2063
2612
|
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)}`,
|
|
2064
2613
|
conversation_id: conv.id,
|
|
2065
2614
|
message_id: inFlight.evidentMessageId
|
|
@@ -2068,7 +2617,7 @@ var ChannelDriver = class {
|
|
|
2068
2617
|
return;
|
|
2069
2618
|
}
|
|
2070
2619
|
this.log({
|
|
2071
|
-
level: "
|
|
2620
|
+
level: "warn",
|
|
2072
2621
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2073
2622
|
conversation_id: conv.id,
|
|
2074
2623
|
message_id: inFlight.evidentMessageId
|
|
@@ -2081,6 +2630,7 @@ var ChannelDriver = class {
|
|
|
2081
2630
|
return;
|
|
2082
2631
|
}
|
|
2083
2632
|
if (state === "failed") {
|
|
2633
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2084
2634
|
if (!inFlight.done) {
|
|
2085
2635
|
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2086
2636
|
this.log({
|
|
@@ -2095,7 +2645,7 @@ var ChannelDriver = class {
|
|
|
2095
2645
|
if (err instanceof ChannelAuthError) throw err;
|
|
2096
2646
|
if (err instanceof ChannelTerminalError) {
|
|
2097
2647
|
this.log({
|
|
2098
|
-
level: "
|
|
2648
|
+
level: "warn",
|
|
2099
2649
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2100
2650
|
conversation_id: conv.id,
|
|
2101
2651
|
message_id: inFlight.evidentMessageId
|
|
@@ -2105,7 +2655,7 @@ var ChannelDriver = class {
|
|
|
2105
2655
|
}
|
|
2106
2656
|
if (this.now() >= inFlight.deadline) {
|
|
2107
2657
|
this.log({
|
|
2108
|
-
level: "
|
|
2658
|
+
level: "warn",
|
|
2109
2659
|
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)}`,
|
|
2110
2660
|
conversation_id: conv.id,
|
|
2111
2661
|
message_id: inFlight.evidentMessageId
|
|
@@ -2114,7 +2664,7 @@ var ChannelDriver = class {
|
|
|
2114
2664
|
return;
|
|
2115
2665
|
}
|
|
2116
2666
|
this.log({
|
|
2117
|
-
level: "
|
|
2667
|
+
level: "warn",
|
|
2118
2668
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2119
2669
|
conversation_id: conv.id,
|
|
2120
2670
|
message_id: inFlight.evidentMessageId
|
|
@@ -2126,59 +2676,70 @@ var ChannelDriver = class {
|
|
|
2126
2676
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2127
2677
|
return;
|
|
2128
2678
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
if (state === "queued" && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
|
|
2679
|
+
const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
|
|
2680
|
+
const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
|
|
2681
|
+
if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
|
|
2135
2682
|
inFlight.stuckReported = true;
|
|
2136
2683
|
void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
|
|
2137
2684
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
2138
2685
|
});
|
|
2139
2686
|
}
|
|
2140
|
-
|
|
2687
|
+
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2688
|
+
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2141
2689
|
this.log({
|
|
2142
|
-
level: "
|
|
2143
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)}
|
|
2690
|
+
level: "warn",
|
|
2691
|
+
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`,
|
|
2144
2692
|
conversation_id: conv.id,
|
|
2145
2693
|
message_id: inFlight.evidentMessageId
|
|
2146
2694
|
});
|
|
2695
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2696
|
+
watched_for_ms: this.now() - inFlight.processingAnchorMs
|
|
2697
|
+
});
|
|
2147
2698
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2699
|
+
return;
|
|
2148
2700
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2701
|
+
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2702
|
+
inFlight.aliveInFlight = true;
|
|
2703
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
2704
|
+
inFlight.aliveInFlight = false;
|
|
2705
|
+
if (ok) inFlight.lastAliveAt = this.now();
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
if (awaitingHuman) {
|
|
2709
|
+
if (!inFlight.awaitingHumanLatched) {
|
|
2710
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2711
|
+
inFlight.awaitingHumanLatched = true;
|
|
2712
|
+
}
|
|
2713
|
+
if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
|
|
2714
|
+
inFlight.pausedInFlight = true;
|
|
2715
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
|
|
2716
|
+
inFlight.pausedInFlight = false;
|
|
2717
|
+
if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
} else if (inFlight.awaitingHumanLatched) {
|
|
2721
|
+
inFlight.awaitingHumanLatched = false;
|
|
2722
|
+
inFlight.pausedOnQuestion = false;
|
|
2723
|
+
inFlight.pausedOnPermission = false;
|
|
2724
|
+
inFlight.pausedClearConfirmed = false;
|
|
2725
|
+
}
|
|
2726
|
+
const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
|
|
2727
|
+
const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
|
|
2728
|
+
(sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
|
|
2729
|
+
);
|
|
2730
|
+
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2731
|
+
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2175
2732
|
this.log({
|
|
2176
|
-
level: "
|
|
2177
|
-
message: `
|
|
2733
|
+
level: "debug",
|
|
2734
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2735
|
+
conversation_id: conv.id,
|
|
2178
2736
|
message_id: inFlight.evidentMessageId
|
|
2179
2737
|
});
|
|
2738
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2739
|
+
watched_for_ms: this.now() - inFlight.dispatchedAt
|
|
2740
|
+
});
|
|
2741
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2180
2742
|
}
|
|
2181
|
-
inFlight.dispatchedAt = this.now();
|
|
2182
2743
|
}
|
|
2183
2744
|
// -------------------------------------------------------------------------
|
|
2184
2745
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
@@ -2199,15 +2760,20 @@ var ChannelDriver = class {
|
|
|
2199
2760
|
*/
|
|
2200
2761
|
async readoptProcessing() {
|
|
2201
2762
|
const rows = await this.getProcessingMessages();
|
|
2202
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
2763
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2203
2764
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2204
|
-
for (const id of [
|
|
2765
|
+
for (const id of [
|
|
2766
|
+
...this.dontRedispatch,
|
|
2767
|
+
...this.doneUndeliverable,
|
|
2768
|
+
...this.readoptPollUnresolvedSignalled
|
|
2769
|
+
]) {
|
|
2205
2770
|
if (!stillProcessing.has(id)) {
|
|
2206
2771
|
const cleared = this.dontRedispatch.delete(id);
|
|
2207
2772
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
2773
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2208
2774
|
if (cleared || clearedUndeliverable) {
|
|
2209
2775
|
this.log({
|
|
2210
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2211
2777
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2212
2778
|
message_id: id
|
|
2213
2779
|
});
|
|
@@ -2220,7 +2786,7 @@ var ChannelDriver = class {
|
|
|
2220
2786
|
for (const row of rows) {
|
|
2221
2787
|
if (!row.opencode_session_id) {
|
|
2222
2788
|
this.log({
|
|
2223
|
-
level: "
|
|
2789
|
+
level: "warn",
|
|
2224
2790
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2225
2791
|
conversation_id: row.conversation_id,
|
|
2226
2792
|
message_id: row.id
|
|
@@ -2237,7 +2803,7 @@ var ChannelDriver = class {
|
|
|
2237
2803
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2238
2804
|
if (!res.ok) {
|
|
2239
2805
|
this.log({
|
|
2240
|
-
level: "
|
|
2806
|
+
level: "warn",
|
|
2241
2807
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2242
2808
|
});
|
|
2243
2809
|
continue;
|
|
@@ -2245,7 +2811,7 @@ var ChannelDriver = class {
|
|
|
2245
2811
|
const body = await res.json();
|
|
2246
2812
|
if (!Array.isArray(body)) {
|
|
2247
2813
|
this.log({
|
|
2248
|
-
level: "
|
|
2814
|
+
level: "warn",
|
|
2249
2815
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2250
2816
|
});
|
|
2251
2817
|
continue;
|
|
@@ -2253,13 +2819,15 @@ var ChannelDriver = class {
|
|
|
2253
2819
|
messages = body;
|
|
2254
2820
|
} catch (err) {
|
|
2255
2821
|
this.log({
|
|
2256
|
-
level: "
|
|
2822
|
+
level: "warn",
|
|
2257
2823
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2258
2824
|
});
|
|
2259
2825
|
continue;
|
|
2260
2826
|
}
|
|
2827
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
2828
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2261
2829
|
for (const row of sessionRows) {
|
|
2262
|
-
await this.readoptOne(sessionId, row, messages);
|
|
2830
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2263
2831
|
}
|
|
2264
2832
|
}
|
|
2265
2833
|
}
|
|
@@ -2267,31 +2835,36 @@ var ChannelDriver = class {
|
|
|
2267
2835
|
* Re-adopt ONE `processing` row against the tick's session message snapshot
|
|
2268
2836
|
* (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
|
|
2269
2837
|
*
|
|
2270
|
-
* Branches on `messageRunState(messages,
|
|
2838
|
+
* Branches on `messageRunState(messages, row.opencode_message_id)` — the
|
|
2839
|
+
* opencode-assigned user-message id persisted on the first `processing` PATCH
|
|
2840
|
+
* (#218). A row with a NULL stored id (dispatched but the read-back never landed
|
|
2841
|
+
* before the restart) has no id to correlate → treated as an orphan and
|
|
2842
|
+
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
2271
2843
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
2272
2844
|
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
2273
2845
|
* errored turn is reported failed on restart, NOT re-dispatched;
|
|
2274
|
-
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch)
|
|
2275
|
-
*
|
|
2846
|
+
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
2847
|
+
* tracking the stored id so the reply correlates by it;
|
|
2848
|
+
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
2276
2849
|
*
|
|
2277
2850
|
* Only `ChannelAuthError` propagates.
|
|
2278
2851
|
*/
|
|
2279
|
-
async readoptOne(sessionId, row, messages) {
|
|
2852
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2280
2853
|
if (this.isTracked(sessionId, row.id)) {
|
|
2281
2854
|
this.log({
|
|
2282
|
-
level: "
|
|
2855
|
+
level: "debug",
|
|
2283
2856
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2284
2857
|
conversation_id: row.conversation_id,
|
|
2285
2858
|
message_id: row.id
|
|
2286
2859
|
});
|
|
2287
2860
|
return;
|
|
2288
2861
|
}
|
|
2289
|
-
const ocId =
|
|
2290
|
-
const state = messageRunState(messages, ocId);
|
|
2862
|
+
const ocId = row.opencode_message_id;
|
|
2863
|
+
const state = messageRunState(messages, ocId ?? "");
|
|
2291
2864
|
if (state === "done") {
|
|
2292
2865
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2293
2866
|
this.log({
|
|
2294
|
-
level: "
|
|
2867
|
+
level: "debug",
|
|
2295
2868
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2296
2869
|
conversation_id: row.conversation_id,
|
|
2297
2870
|
message_id: row.id
|
|
@@ -2305,21 +2878,23 @@ var ChannelDriver = class {
|
|
|
2305
2878
|
message_id: row.id
|
|
2306
2879
|
});
|
|
2307
2880
|
try {
|
|
2308
|
-
await this.
|
|
2881
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
2882
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2309
2883
|
} catch (err) {
|
|
2310
2884
|
if (err instanceof ChannelAuthError) throw err;
|
|
2311
2885
|
if (err instanceof ChannelTerminalError) {
|
|
2312
2886
|
this.doneUndeliverable.add(row.id);
|
|
2313
2887
|
this.log({
|
|
2314
|
-
level: "
|
|
2888
|
+
level: "warn",
|
|
2315
2889
|
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}`,
|
|
2316
2890
|
conversation_id: row.conversation_id,
|
|
2317
2891
|
message_id: row.id
|
|
2318
2892
|
});
|
|
2893
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2319
2894
|
return;
|
|
2320
2895
|
}
|
|
2321
2896
|
this.log({
|
|
2322
|
-
level: "
|
|
2897
|
+
level: "warn",
|
|
2323
2898
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2324
2899
|
conversation_id: row.conversation_id,
|
|
2325
2900
|
message_id: row.id
|
|
@@ -2327,10 +2902,11 @@ var ChannelDriver = class {
|
|
|
2327
2902
|
return;
|
|
2328
2903
|
}
|
|
2329
2904
|
this.dontRedispatch.delete(row.id);
|
|
2905
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2330
2906
|
return;
|
|
2331
2907
|
}
|
|
2332
2908
|
if (state === "failed") {
|
|
2333
|
-
const error2 = messageError(messages, ocId) ?? void 0;
|
|
2909
|
+
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
2334
2910
|
this.log({
|
|
2335
2911
|
level: "error",
|
|
2336
2912
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2344,15 +2920,16 @@ var ChannelDriver = class {
|
|
|
2344
2920
|
if (err instanceof ChannelTerminalError) {
|
|
2345
2921
|
this.doneUndeliverable.add(row.id);
|
|
2346
2922
|
this.log({
|
|
2347
|
-
level: "
|
|
2923
|
+
level: "warn",
|
|
2348
2924
|
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}`,
|
|
2349
2925
|
conversation_id: row.conversation_id,
|
|
2350
2926
|
message_id: row.id
|
|
2351
2927
|
});
|
|
2928
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2352
2929
|
return;
|
|
2353
2930
|
}
|
|
2354
2931
|
this.log({
|
|
2355
|
-
level: "
|
|
2932
|
+
level: "warn",
|
|
2356
2933
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2357
2934
|
conversation_id: row.conversation_id,
|
|
2358
2935
|
message_id: row.id
|
|
@@ -2360,18 +2937,84 @@ var ChannelDriver = class {
|
|
|
2360
2937
|
return;
|
|
2361
2938
|
}
|
|
2362
2939
|
this.dontRedispatch.delete(row.id);
|
|
2940
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2363
2941
|
return;
|
|
2364
2942
|
}
|
|
2365
2943
|
if (this.dontRedispatch.has(row.id)) {
|
|
2366
2944
|
this.log({
|
|
2367
|
-
level: "
|
|
2945
|
+
level: "debug",
|
|
2368
2946
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2369
2947
|
conversation_id: row.conversation_id,
|
|
2370
2948
|
message_id: row.id
|
|
2371
2949
|
});
|
|
2372
2950
|
return;
|
|
2373
2951
|
}
|
|
2374
|
-
|
|
2952
|
+
let statusReadableOngoing = null;
|
|
2953
|
+
if (state === "running" && ocId) {
|
|
2954
|
+
const reply = findLastAssistantReplyFor(messages, ocId);
|
|
2955
|
+
const shape = this.replyCompletionShape(reply);
|
|
2956
|
+
const ongoing = sessionOngoing;
|
|
2957
|
+
statusReadableOngoing = ongoing;
|
|
2958
|
+
if (ongoing === false) {
|
|
2959
|
+
this.log({
|
|
2960
|
+
level: "info",
|
|
2961
|
+
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)`,
|
|
2962
|
+
conversation_id: row.conversation_id,
|
|
2963
|
+
message_id: row.id
|
|
2964
|
+
});
|
|
2965
|
+
await this.forceReadoptRun(sessionId, row);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (ongoing === true) {
|
|
2969
|
+
this.log({
|
|
2970
|
+
level: "debug",
|
|
2971
|
+
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)`,
|
|
2972
|
+
conversation_id: row.conversation_id,
|
|
2973
|
+
message_id: row.id
|
|
2974
|
+
});
|
|
2975
|
+
} else {
|
|
2976
|
+
if (shape === "b1") {
|
|
2977
|
+
this.log({
|
|
2978
|
+
level: "debug",
|
|
2979
|
+
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`,
|
|
2980
|
+
conversation_id: row.conversation_id,
|
|
2981
|
+
message_id: row.id
|
|
2982
|
+
});
|
|
2983
|
+
if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
|
|
2984
|
+
this.readoptPollUnresolvedSignalled.add(row.id);
|
|
2985
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
|
|
2986
|
+
}
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
this.log({
|
|
2990
|
+
level: "debug",
|
|
2991
|
+
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`,
|
|
2992
|
+
conversation_id: row.conversation_id,
|
|
2993
|
+
message_id: row.id
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
|
|
2998
|
+
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2999
|
+
if (descendantAlive === true) {
|
|
3000
|
+
this.log({
|
|
3001
|
+
level: "debug",
|
|
3002
|
+
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)`,
|
|
3003
|
+
conversation_id: row.conversation_id,
|
|
3004
|
+
message_id: row.id
|
|
3005
|
+
});
|
|
3006
|
+
} else {
|
|
3007
|
+
this.log({
|
|
3008
|
+
level: "info",
|
|
3009
|
+
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)" : ""}`,
|
|
3010
|
+
conversation_id: row.conversation_id,
|
|
3011
|
+
message_id: row.id
|
|
3012
|
+
});
|
|
3013
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
if ((state === "running" || state === "queued") && ocId) {
|
|
2375
3018
|
const conv = this.convForRow(sessionId, row);
|
|
2376
3019
|
const message = this.queuedMessageForRow(row);
|
|
2377
3020
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
@@ -2379,41 +3022,65 @@ var ChannelDriver = class {
|
|
|
2379
3022
|
this.readopted.add(row.id);
|
|
2380
3023
|
this.ensureWatcherRunning(sessionId);
|
|
2381
3024
|
this.log({
|
|
2382
|
-
level: "
|
|
2383
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (
|
|
3025
|
+
level: "debug",
|
|
3026
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2384
3027
|
conversation_id: row.conversation_id,
|
|
2385
3028
|
message_id: row.id
|
|
2386
3029
|
});
|
|
3030
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2387
3031
|
return;
|
|
2388
3032
|
}
|
|
2389
3033
|
await this.forceReadoptRun(sessionId, row);
|
|
2390
3034
|
}
|
|
2391
3035
|
/**
|
|
2392
|
-
* Re-dispatch an orphaned (`unknown
|
|
3036
|
+
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
3037
|
+
*
|
|
3038
|
+
* #218/WI-5: the row's user message is absent (never kept, or a null stored id),
|
|
3039
|
+
* so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
|
|
3040
|
+
* read it back, and register the watcher under the assigned id so the reply
|
|
3041
|
+
* correlates server-side.
|
|
2393
3042
|
*
|
|
2394
|
-
*
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
2399
|
-
*
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
3043
|
+
* ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
|
|
3044
|
+
* id). Without a guard, if this dispatches on tick N but the read-back+persist
|
|
3045
|
+
* hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
|
|
3046
|
+
* tick N+1 would dispatch AGAIN → duplicate user turns. The `awaitingReadopt`
|
|
3047
|
+
* latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
|
|
3048
|
+
* short-circuit while the row is latched; clear it on a successful dispatch (the
|
|
3049
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
3050
|
+
* re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
|
|
3051
|
+
* may retry exactly once more).
|
|
2402
3052
|
*
|
|
2403
|
-
* `evidentMessageId = row.id` addresses the SERVER row
|
|
2404
|
-
* `opencodeMessageId` is what the watcher polls. Deadline anchored to
|
|
3053
|
+
* `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
|
|
2405
3054
|
* `processed_at` (Invariant 1).
|
|
2406
3055
|
*/
|
|
2407
3056
|
async forceReadoptRun(sessionId, row) {
|
|
2408
|
-
|
|
3057
|
+
if (this.stopped) {
|
|
3058
|
+
this.log({
|
|
3059
|
+
level: "debug",
|
|
3060
|
+
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`,
|
|
3061
|
+
conversation_id: row.conversation_id,
|
|
3062
|
+
message_id: row.id
|
|
3063
|
+
});
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (this.awaitingReadopt.has(row.id)) {
|
|
3067
|
+
this.log({
|
|
3068
|
+
level: "debug",
|
|
3069
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3070
|
+
conversation_id: row.conversation_id,
|
|
3071
|
+
message_id: row.id
|
|
3072
|
+
});
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
2409
3075
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2410
3076
|
this.dontRedispatch.add(row.id);
|
|
2411
3077
|
this.log({
|
|
2412
|
-
level: "
|
|
3078
|
+
level: "debug",
|
|
2413
3079
|
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)`,
|
|
2414
3080
|
conversation_id: row.conversation_id,
|
|
2415
3081
|
message_id: row.id
|
|
2416
3082
|
});
|
|
3083
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2417
3084
|
return;
|
|
2418
3085
|
}
|
|
2419
3086
|
const options = {
|
|
@@ -2422,20 +3089,38 @@ var ChannelDriver = class {
|
|
|
2422
3089
|
};
|
|
2423
3090
|
this.log({
|
|
2424
3091
|
level: "info",
|
|
2425
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching
|
|
3092
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
|
|
2426
3093
|
conversation_id: row.conversation_id,
|
|
2427
3094
|
message_id: row.id
|
|
2428
3095
|
});
|
|
3096
|
+
this.awaitingReadopt.add(row.id);
|
|
3097
|
+
let ocId;
|
|
2429
3098
|
try {
|
|
2430
|
-
await
|
|
3099
|
+
ocId = await this.dispatchLocked(
|
|
3100
|
+
sessionId,
|
|
3101
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3102
|
+
);
|
|
2431
3103
|
} catch (err) {
|
|
3104
|
+
this.awaitingReadopt.delete(row.id);
|
|
2432
3105
|
if (err instanceof ChannelAuthError) throw err;
|
|
2433
3106
|
this.log({
|
|
2434
|
-
level: "
|
|
3107
|
+
level: "warn",
|
|
2435
3108
|
message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2436
3109
|
conversation_id: row.conversation_id,
|
|
2437
3110
|
message_id: row.id
|
|
2438
3111
|
});
|
|
3112
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
if (ocId === null) {
|
|
3116
|
+
this.awaitingReadopt.delete(row.id);
|
|
3117
|
+
this.log({
|
|
3118
|
+
level: "warn",
|
|
3119
|
+
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`,
|
|
3120
|
+
conversation_id: row.conversation_id,
|
|
3121
|
+
message_id: row.id
|
|
3122
|
+
});
|
|
3123
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2439
3124
|
return;
|
|
2440
3125
|
}
|
|
2441
3126
|
const conv = this.convForRow(sessionId, row);
|
|
@@ -2443,7 +3128,9 @@ var ChannelDriver = class {
|
|
|
2443
3128
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
2444
3129
|
this.dispatched.add(row.id);
|
|
2445
3130
|
this.readopted.add(row.id);
|
|
3131
|
+
this.awaitingReadopt.delete(row.id);
|
|
2446
3132
|
this.ensureWatcherRunning(sessionId);
|
|
3133
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2447
3134
|
}
|
|
2448
3135
|
/**
|
|
2449
3136
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -2513,7 +3200,7 @@ var ChannelDriver = class {
|
|
|
2513
3200
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
2514
3201
|
this.dontRedispatch.add(evidentMessageId);
|
|
2515
3202
|
this.log({
|
|
2516
|
-
level: "
|
|
3203
|
+
level: "debug",
|
|
2517
3204
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
2518
3205
|
conversation_id: watcher.conv.id,
|
|
2519
3206
|
message_id: evidentMessageId
|
|
@@ -2535,21 +3222,41 @@ var ChannelDriver = class {
|
|
|
2535
3222
|
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2536
3223
|
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2537
3224
|
* oldest running message.
|
|
3225
|
+
*
|
|
3226
|
+
* Returns the set of in-flight Evident message ids that are paused awaiting a
|
|
3227
|
+
* human — an outstanding (still-open) question/permission is attributed to them.
|
|
3228
|
+
* `serviceInFlightMessage` uses this to keep an actively-running turn watched
|
|
3229
|
+
* forever (ADR-0047) while still bounding a turn merely blocked on a person who
|
|
3230
|
+
* may never answer. Attribution here covers ALL open interactions, not just
|
|
3231
|
+
* NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
|
|
3232
|
+
* even after it was already surfaced to the channel.
|
|
2538
3233
|
*/
|
|
2539
3234
|
async pollInteractions(sessionId, watcher, messages) {
|
|
3235
|
+
const openQuestions = /* @__PURE__ */ new Set();
|
|
3236
|
+
const openPermissions = /* @__PURE__ */ new Set();
|
|
3237
|
+
let questionsPolledOk = true;
|
|
3238
|
+
let permissionsPolledOk = true;
|
|
2540
3239
|
let questions = [];
|
|
2541
3240
|
try {
|
|
2542
3241
|
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2543
3242
|
if (res.ok) {
|
|
2544
3243
|
const body = await res.json();
|
|
2545
|
-
|
|
3244
|
+
if (Array.isArray(body)) {
|
|
3245
|
+
questions = body;
|
|
3246
|
+
} else {
|
|
3247
|
+
questionsPolledOk = false;
|
|
3248
|
+
}
|
|
3249
|
+
} else {
|
|
3250
|
+
questionsPolledOk = false;
|
|
2546
3251
|
}
|
|
2547
3252
|
} catch {
|
|
3253
|
+
questionsPolledOk = false;
|
|
2548
3254
|
}
|
|
2549
3255
|
for (const q of questions) {
|
|
2550
|
-
if (q.sessionID
|
|
2551
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
3256
|
+
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2552
3257
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
3258
|
+
if (paused) openQuestions.add(paused.evidentMessageId);
|
|
3259
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2553
3260
|
const reported = await this.reportInteraction(
|
|
2554
3261
|
watcher.conv.id,
|
|
2555
3262
|
"question",
|
|
@@ -2563,14 +3270,22 @@ var ChannelDriver = class {
|
|
|
2563
3270
|
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2564
3271
|
if (res.ok) {
|
|
2565
3272
|
const body = await res.json();
|
|
2566
|
-
|
|
3273
|
+
if (Array.isArray(body)) {
|
|
3274
|
+
permissions = body;
|
|
3275
|
+
} else {
|
|
3276
|
+
permissionsPolledOk = false;
|
|
3277
|
+
}
|
|
3278
|
+
} else {
|
|
3279
|
+
permissionsPolledOk = false;
|
|
2567
3280
|
}
|
|
2568
3281
|
} catch {
|
|
3282
|
+
permissionsPolledOk = false;
|
|
2569
3283
|
}
|
|
2570
3284
|
for (const p of permissions) {
|
|
2571
|
-
if (p.sessionID
|
|
2572
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
3285
|
+
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2573
3286
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
3287
|
+
if (paused) openPermissions.add(paused.evidentMessageId);
|
|
3288
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2574
3289
|
const reported = await this.reportInteraction(
|
|
2575
3290
|
watcher.conv.id,
|
|
2576
3291
|
"permission",
|
|
@@ -2579,6 +3294,173 @@ var ChannelDriver = class {
|
|
|
2579
3294
|
);
|
|
2580
3295
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2581
3296
|
}
|
|
3297
|
+
return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
3301
|
+
* i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
|
|
3302
|
+
* watched root. Sub-agents spawned via the `task` tool run in child sessions,
|
|
3303
|
+
* so their questions/permissions live under a different `sessionID` that must
|
|
3304
|
+
* still be attributed to the root conversation the watcher owns.
|
|
3305
|
+
*
|
|
3306
|
+
* Parents are cached in `sessionParents` so we walk each session at most once;
|
|
3307
|
+
* a bounded depth cap guards against a cycle or a pathological chain, and any
|
|
3308
|
+
* fetch failure is treated as "not a descendant" (best-effort — the interaction
|
|
3309
|
+
* simply isn't surfaced this tick and is retried next tick once resolvable).
|
|
3310
|
+
*/
|
|
3311
|
+
async sessionBelongsTo(sessionId, rootSessionId) {
|
|
3312
|
+
let current = sessionId;
|
|
3313
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
3314
|
+
if (current === rootSessionId) return true;
|
|
3315
|
+
const parent = await this.resolveSessionParent(current);
|
|
3316
|
+
if (parent === null || parent === void 0) return false;
|
|
3317
|
+
current = parent;
|
|
3318
|
+
}
|
|
3319
|
+
return false;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3322
|
+
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
3323
|
+
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
3324
|
+
* unreachable / the session can't be read (so the caller stops walking without
|
|
3325
|
+
* caching a wrong answer — the next tick retries).
|
|
3326
|
+
*/
|
|
3327
|
+
async resolveSessionParent(sessionId) {
|
|
3328
|
+
const cached = this.sessionParents.get(sessionId);
|
|
3329
|
+
if (cached !== void 0) return cached;
|
|
3330
|
+
let parent = void 0;
|
|
3331
|
+
try {
|
|
3332
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3333
|
+
if (res.ok) {
|
|
3334
|
+
const body = await res.json();
|
|
3335
|
+
parent = body && typeof body.parentID === "string" ? body.parentID : null;
|
|
3336
|
+
}
|
|
3337
|
+
} catch {
|
|
3338
|
+
parent = void 0;
|
|
3339
|
+
}
|
|
3340
|
+
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3341
|
+
return parent;
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3345
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3346
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3347
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3348
|
+
* Best-effort:
|
|
3349
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3350
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3351
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3352
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3353
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3354
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3355
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3356
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3357
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3358
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3359
|
+
*/
|
|
3360
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3361
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3362
|
+
if (cached != null) return cached;
|
|
3363
|
+
try {
|
|
3364
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3365
|
+
if (res.ok) {
|
|
3366
|
+
const body = await res.json();
|
|
3367
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3368
|
+
if (title.length > 0) {
|
|
3369
|
+
this.sessionTitles.set(sessionId, title);
|
|
3370
|
+
return title;
|
|
3371
|
+
}
|
|
3372
|
+
return null;
|
|
3373
|
+
}
|
|
3374
|
+
this.log({
|
|
3375
|
+
level: "debug",
|
|
3376
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3377
|
+
conversation_id: conversationId
|
|
3378
|
+
});
|
|
3379
|
+
} catch (err) {
|
|
3380
|
+
this.log({
|
|
3381
|
+
level: "debug",
|
|
3382
|
+
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)}`,
|
|
3383
|
+
conversation_id: conversationId
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
/**
|
|
3389
|
+
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3390
|
+
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
3391
|
+
*
|
|
3392
|
+
* The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
|
|
3393
|
+
* runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
|
|
3394
|
+
* completed `finish: "tool-calls"` root reply encountered during re-adoption is
|
|
3395
|
+
* idle by OpenCode's own definition and is re-dispatched. This method exists only
|
|
3396
|
+
* so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
|
|
3397
|
+
* provably in flight at the exact moment of recovery.
|
|
3398
|
+
*
|
|
3399
|
+
* "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
|
|
3400
|
+
* ACTIVELY generating — its LAST message is an assistant still mid-generation
|
|
3401
|
+
* (`completed == null`, via `isSessionActivelyGenerating`). An
|
|
3402
|
+
* INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
|
|
3403
|
+
* completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
|
|
3404
|
+
* is generating once the runner is gone), so it does NOT veto. (This is
|
|
3405
|
+
* deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
|
|
3406
|
+
* shapes and would falsely veto — re-hanging the very turn this path recovers.)
|
|
3407
|
+
*
|
|
3408
|
+
* Return contract (encoded so WI-3 need not re-derive it):
|
|
3409
|
+
* - `true` → a descendant is provably, actively generating (veto re-dispatch).
|
|
3410
|
+
* - `false` → descendants exist but none is actively generating (the restart
|
|
3411
|
+
* case), OR no descendant is found at all.
|
|
3412
|
+
* - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
|
|
3413
|
+
*
|
|
3414
|
+
* ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
|
|
3415
|
+
* as `false` and does NOT veto — a restart guarantees no live runner, so an
|
|
3416
|
+
* indeterminate cross-check almost always means "couldn't reach a child that no
|
|
3417
|
+
* longer exists". The inversion lives in the caller; this method just reports
|
|
3418
|
+
* true/false/null faithfully.
|
|
3419
|
+
*
|
|
3420
|
+
* VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
|
|
3421
|
+
* (already proven by the existing child-session interaction tests, via
|
|
3422
|
+
* `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
|
|
3423
|
+
* terminal state. We do NOT depend on any session-level `busy`/`idle` field —
|
|
3424
|
+
* there is none on `GET /session/:id`; OpenCode's busy state is in-memory
|
|
3425
|
+
* `SessionStatus` only.
|
|
3426
|
+
*/
|
|
3427
|
+
async isAnyDescendantSessionAlive(rootSessionId) {
|
|
3428
|
+
const sessions = await listSessions(this.port);
|
|
3429
|
+
if (!sessions) {
|
|
3430
|
+
this.log({
|
|
3431
|
+
level: "warn",
|
|
3432
|
+
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3433
|
+
});
|
|
3434
|
+
return null;
|
|
3435
|
+
}
|
|
3436
|
+
for (const candidate of sessions) {
|
|
3437
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
3438
|
+
if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
|
|
3439
|
+
const childMsgs = await getSessionMessages(this.port, candidate.id);
|
|
3440
|
+
if (isSessionActivelyGenerating(childMsgs)) {
|
|
3441
|
+
return true;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
return false;
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3448
|
+
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
3449
|
+
* - `b1` — the reply itself is still in flight (`time.completed == null`) —
|
|
3450
|
+
* the aborted-in-flight production bug after a restart.
|
|
3451
|
+
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
3452
|
+
* (the sub-agent preamble — #253's shape).
|
|
3453
|
+
* - `other` — any other shape (defensive; a running row is normally b1 or b2).
|
|
3454
|
+
* Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
|
|
3455
|
+
* shape) directly rather than re-importing the module-private `completedOf`/
|
|
3456
|
+
* `finishOf` — this is a display label only, not a correctness predicate.
|
|
3457
|
+
*/
|
|
3458
|
+
replyCompletionShape(reply) {
|
|
3459
|
+
if (!reply) return "other";
|
|
3460
|
+
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
3461
|
+
if (completed == null) return "b1";
|
|
3462
|
+
const finish = reply.info?.finish ?? reply.finish;
|
|
3463
|
+
return finish === "tool-calls" ? "b2" : "other";
|
|
2582
3464
|
}
|
|
2583
3465
|
/**
|
|
2584
3466
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
@@ -2712,13 +3594,18 @@ var ChannelDriver = class {
|
|
|
2712
3594
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2713
3595
|
* retry vehicle for the swap-to-running.
|
|
2714
3596
|
*/
|
|
2715
|
-
async markProcessing(conversationId, messageId, sessionId) {
|
|
3597
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
2716
3598
|
const res = await this.fetchImpl(
|
|
2717
3599
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2718
3600
|
{
|
|
2719
3601
|
method: "PATCH",
|
|
2720
3602
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2721
|
-
body: JSON.stringify({
|
|
3603
|
+
body: JSON.stringify({
|
|
3604
|
+
status: "processing",
|
|
3605
|
+
opencode_session_id: sessionId,
|
|
3606
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3607
|
+
...title ? { title } : {}
|
|
3608
|
+
})
|
|
2722
3609
|
}
|
|
2723
3610
|
);
|
|
2724
3611
|
this.assertAuth(res, "marking message as processing");
|
|
@@ -2756,13 +3643,18 @@ var ChannelDriver = class {
|
|
|
2756
3643
|
* watcher retries next tick within the
|
|
2757
3644
|
* deadline, Finding 4).
|
|
2758
3645
|
*/
|
|
2759
|
-
async markDone(conversationId, messageId, sessionId) {
|
|
3646
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
2760
3647
|
const res = await this.fetchImpl(
|
|
2761
3648
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2762
3649
|
{
|
|
2763
3650
|
method: "PATCH",
|
|
2764
3651
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2765
|
-
body: JSON.stringify({
|
|
3652
|
+
body: JSON.stringify({
|
|
3653
|
+
status: "done",
|
|
3654
|
+
opencode_session_id: sessionId,
|
|
3655
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3656
|
+
...title ? { title } : {}
|
|
3657
|
+
})
|
|
2766
3658
|
}
|
|
2767
3659
|
);
|
|
2768
3660
|
this.assertAuth(res, "marking message as done");
|
|
@@ -2803,6 +3695,12 @@ var ChannelDriver = class {
|
|
|
2803
3695
|
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
2804
3696
|
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
2805
3697
|
* context (no silent catch, per development-workflow).
|
|
3698
|
+
*
|
|
3699
|
+
* Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
|
|
3700
|
+
* telemetry), but the `paused` liveness-clear uses it to know whether to
|
|
3701
|
+
* RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
|
|
3702
|
+
* stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
|
|
3703
|
+
* leaves liveness").
|
|
2806
3704
|
*/
|
|
2807
3705
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
2808
3706
|
try {
|
|
@@ -2816,19 +3714,22 @@ var ChannelDriver = class {
|
|
|
2816
3714
|
);
|
|
2817
3715
|
if (!res.ok) {
|
|
2818
3716
|
this.log({
|
|
2819
|
-
level: "
|
|
3717
|
+
level: "warn",
|
|
2820
3718
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
2821
3719
|
conversation_id: conversationId,
|
|
2822
3720
|
message_id: messageId
|
|
2823
3721
|
});
|
|
3722
|
+
return false;
|
|
2824
3723
|
}
|
|
3724
|
+
return true;
|
|
2825
3725
|
} catch (err) {
|
|
2826
3726
|
this.log({
|
|
2827
|
-
level: "
|
|
3727
|
+
level: "warn",
|
|
2828
3728
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
2829
3729
|
conversation_id: conversationId,
|
|
2830
3730
|
message_id: messageId
|
|
2831
3731
|
});
|
|
3732
|
+
return false;
|
|
2832
3733
|
}
|
|
2833
3734
|
}
|
|
2834
3735
|
async persistSession(conversationId, sessionId) {
|
|
@@ -3101,6 +4002,25 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3101
4002
|
return { error: `Failed to resolve agent from key: ${message}` };
|
|
3102
4003
|
}
|
|
3103
4004
|
}
|
|
4005
|
+
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
4006
|
+
const apiUrl = getApiUrlConfig();
|
|
4007
|
+
try {
|
|
4008
|
+
const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
|
|
4009
|
+
method: "POST",
|
|
4010
|
+
headers: { Authorization: authHeader }
|
|
4011
|
+
});
|
|
4012
|
+
if (!response.ok) {
|
|
4013
|
+
const serverMessage = await readErrorMessage(response);
|
|
4014
|
+
return {
|
|
4015
|
+
ok: false,
|
|
4016
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
return { ok: true };
|
|
4020
|
+
} catch (error2) {
|
|
4021
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
3104
4024
|
async function getAgentInfo(agentId, authHeader) {
|
|
3105
4025
|
const apiUrl = getApiUrlConfig();
|
|
3106
4026
|
try {
|
|
@@ -3146,23 +4066,55 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3146
4066
|
// src/commands/run.ts
|
|
3147
4067
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
3148
4068
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3149
|
-
|
|
4069
|
+
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4070
|
+
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4071
|
+
function resolveLogLevel(options) {
|
|
4072
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4073
|
+
const validate = (value, source) => {
|
|
4074
|
+
const normalized = value.trim().toLowerCase();
|
|
4075
|
+
if (!accepted.includes(normalized)) {
|
|
4076
|
+
throw new Error(
|
|
4077
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4078
|
+
);
|
|
4079
|
+
}
|
|
4080
|
+
return normalized;
|
|
4081
|
+
};
|
|
4082
|
+
if (options.logLevel !== void 0) {
|
|
4083
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4084
|
+
}
|
|
4085
|
+
if (options.verbose) {
|
|
4086
|
+
return "debug";
|
|
4087
|
+
}
|
|
4088
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4089
|
+
if (env !== void 0 && env !== "") {
|
|
4090
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4091
|
+
}
|
|
4092
|
+
return "info";
|
|
4093
|
+
}
|
|
4094
|
+
function meetsThreshold(state, level) {
|
|
4095
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4096
|
+
}
|
|
4097
|
+
function log2(state, message, level = "info") {
|
|
4098
|
+
if (!meetsThreshold(state, level)) return;
|
|
3150
4099
|
if (state.json) {
|
|
3151
4100
|
console.log(
|
|
3152
4101
|
JSON.stringify({
|
|
3153
4102
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3154
|
-
level
|
|
4103
|
+
level,
|
|
3155
4104
|
message
|
|
3156
4105
|
})
|
|
3157
4106
|
);
|
|
3158
4107
|
} else if (!state.interactive) {
|
|
3159
|
-
const prefix =
|
|
4108
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3160
4109
|
console.log(`${prefix} ${message}`);
|
|
3161
4110
|
}
|
|
3162
4111
|
}
|
|
3163
4112
|
function logActivity(state, entry) {
|
|
4113
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4114
|
+
if (!meetsThreshold(state, level)) return;
|
|
3164
4115
|
const fullEntry = {
|
|
3165
4116
|
...entry,
|
|
4117
|
+
level,
|
|
3166
4118
|
timestamp: /* @__PURE__ */ new Date()
|
|
3167
4119
|
};
|
|
3168
4120
|
state.activityLog.push(fullEntry);
|
|
@@ -3171,9 +4123,9 @@ function logActivity(state, entry) {
|
|
|
3171
4123
|
}
|
|
3172
4124
|
if (!state.interactive) {
|
|
3173
4125
|
if (entry.type === "error") {
|
|
3174
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3175
|
-
} else if (entry.
|
|
3176
|
-
log2(state, entry.message);
|
|
4126
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4127
|
+
} else if (entry.message) {
|
|
4128
|
+
log2(state, entry.message, level);
|
|
3177
4129
|
}
|
|
3178
4130
|
}
|
|
3179
4131
|
}
|
|
@@ -3300,7 +4252,7 @@ async function driveChannels(state, driver) {
|
|
|
3300
4252
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
3301
4253
|
if (state.interactive) displayStatus(state);
|
|
3302
4254
|
}
|
|
3303
|
-
await new Promise((
|
|
4255
|
+
await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
|
|
3304
4256
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
3305
4257
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
3306
4258
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -3311,8 +4263,122 @@ async function driveChannels(state, driver) {
|
|
|
3311
4263
|
}
|
|
3312
4264
|
}
|
|
3313
4265
|
}
|
|
3314
|
-
|
|
4266
|
+
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
4267
|
+
async function runSweep(state, driver, config2) {
|
|
4268
|
+
const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
|
|
4269
|
+
try {
|
|
4270
|
+
const sessions = await listSessions(state.port);
|
|
4271
|
+
if (sessions === null) {
|
|
4272
|
+
logActivity(state, {
|
|
4273
|
+
type: "info",
|
|
4274
|
+
message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
|
|
4275
|
+
});
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
const toDelete = selectSessionsToDelete(
|
|
4279
|
+
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
4280
|
+
{
|
|
4281
|
+
maxAgeMs: config2.maxAgeMs,
|
|
4282
|
+
maxCount: config2.maxCount,
|
|
4283
|
+
nowMs: Date.now(),
|
|
4284
|
+
protectedIds: driver.protectedSessionIds()
|
|
4285
|
+
}
|
|
4286
|
+
);
|
|
4287
|
+
const protectedNow = driver.protectedSessionIds();
|
|
4288
|
+
let deleted = 0;
|
|
4289
|
+
let failed = 0;
|
|
4290
|
+
let skippedNewlyActive = 0;
|
|
4291
|
+
for (const id of toDelete) {
|
|
4292
|
+
if (protectedNow.has(id)) {
|
|
4293
|
+
skippedNewlyActive++;
|
|
4294
|
+
logActivity(state, {
|
|
4295
|
+
type: "info",
|
|
4296
|
+
message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
|
|
4297
|
+
});
|
|
4298
|
+
continue;
|
|
4299
|
+
}
|
|
4300
|
+
if (await deleteSession(state.port, id)) deleted++;
|
|
4301
|
+
else failed++;
|
|
4302
|
+
}
|
|
4303
|
+
const failedNote = failed > 0 ? `, failed ${failed}` : "";
|
|
4304
|
+
const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
|
|
4305
|
+
logActivity(state, {
|
|
4306
|
+
type: "info",
|
|
4307
|
+
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
4308
|
+
});
|
|
4309
|
+
} catch (error2) {
|
|
4310
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4311
|
+
logActivity(state, {
|
|
4312
|
+
type: "error",
|
|
4313
|
+
error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
|
|
4314
|
+
});
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
function scheduleSessionCleanup(state, driver, options) {
|
|
4318
|
+
const config2 = resolveSessionCleanupConfig(
|
|
4319
|
+
{
|
|
4320
|
+
maxAge: options.sessionCleanupMaxAge,
|
|
4321
|
+
maxCount: options.sessionCleanupMaxCount,
|
|
4322
|
+
interval: options.sessionCleanupInterval
|
|
4323
|
+
},
|
|
4324
|
+
process.env
|
|
4325
|
+
);
|
|
4326
|
+
for (const warning2 of config2.warnings) {
|
|
4327
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4328
|
+
}
|
|
4329
|
+
if (!config2.enabled) return;
|
|
4330
|
+
logActivity(state, {
|
|
4331
|
+
type: "info",
|
|
4332
|
+
message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
|
|
4333
|
+
});
|
|
4334
|
+
const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
|
|
4335
|
+
const firstSweep = setTimeout(
|
|
4336
|
+
() => void runSweep(state, driver, config2),
|
|
4337
|
+
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
4338
|
+
);
|
|
4339
|
+
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
4340
|
+
}
|
|
4341
|
+
async function notifyOffline(state) {
|
|
4342
|
+
if (!state.agentId || !state.authHeader) return;
|
|
4343
|
+
if (!state.connected) {
|
|
4344
|
+
log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
|
|
4345
|
+
return;
|
|
4346
|
+
}
|
|
4347
|
+
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4348
|
+
if (result.ok) {
|
|
4349
|
+
log2(state, "Notified Evident the agent is going offline");
|
|
4350
|
+
} else {
|
|
4351
|
+
logActivity(state, {
|
|
4352
|
+
type: "error",
|
|
4353
|
+
error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
|
|
4354
|
+
});
|
|
4355
|
+
if (state.interactive) displayStatus(state);
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
async function cleanup(state, opts = {}) {
|
|
3315
4359
|
state.running = false;
|
|
4360
|
+
for (const timer of state.sessionCleanupTimers) {
|
|
4361
|
+
clearInterval(timer);
|
|
4362
|
+
clearTimeout(timer);
|
|
4363
|
+
}
|
|
4364
|
+
state.sessionCleanupTimers = [];
|
|
4365
|
+
if (opts.graceful && state.channelDriver) {
|
|
4366
|
+
state.channelDriver.stop();
|
|
4367
|
+
log2(state, "Draining in-flight channel work before shutdown...");
|
|
4368
|
+
if (state.interactive) {
|
|
4369
|
+
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
4370
|
+
displayStatus(state);
|
|
4371
|
+
}
|
|
4372
|
+
const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
|
|
4373
|
+
if (!settled) {
|
|
4374
|
+
logActivity(state, {
|
|
4375
|
+
type: "info",
|
|
4376
|
+
message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
|
|
4377
|
+
});
|
|
4378
|
+
if (state.interactive) displayStatus(state);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
await notifyOffline(state);
|
|
3316
4382
|
if (state.connection) {
|
|
3317
4383
|
state.connection.close();
|
|
3318
4384
|
state.connection = null;
|
|
@@ -3330,6 +4396,20 @@ async function cleanup(state) {
|
|
|
3330
4396
|
}
|
|
3331
4397
|
async function run(options) {
|
|
3332
4398
|
const interactive = isInteractive(options.json);
|
|
4399
|
+
let logLevel;
|
|
4400
|
+
try {
|
|
4401
|
+
logLevel = resolveLogLevel(options);
|
|
4402
|
+
} catch (error2) {
|
|
4403
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4404
|
+
if (options.json) {
|
|
4405
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4406
|
+
} else {
|
|
4407
|
+
printError(message);
|
|
4408
|
+
}
|
|
4409
|
+
await shutdownTelemetry();
|
|
4410
|
+
process.exit(1);
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
3333
4413
|
const state = {
|
|
3334
4414
|
agentId: options.agent || "",
|
|
3335
4415
|
agentName: null,
|
|
@@ -3338,32 +4418,38 @@ async function run(options) {
|
|
|
3338
4418
|
idleTimeout: options.idleTimeout ?? null,
|
|
3339
4419
|
json: options.json ?? false,
|
|
3340
4420
|
interactive,
|
|
4421
|
+
logLevel,
|
|
3341
4422
|
connected: false,
|
|
3342
4423
|
opencodeConnected: false,
|
|
3343
4424
|
opencodeVersion: null,
|
|
3344
4425
|
opencodeProcess: null,
|
|
3345
4426
|
connection: null,
|
|
4427
|
+
channelDriver: null,
|
|
3346
4428
|
running: true,
|
|
4429
|
+
shuttingDown: false,
|
|
3347
4430
|
activityLog: [],
|
|
3348
4431
|
messageCount: 0,
|
|
3349
4432
|
lastProxiedActivityAt: null,
|
|
4433
|
+
sessionCleanupTimers: [],
|
|
3350
4434
|
authHeader: ""
|
|
3351
4435
|
};
|
|
3352
4436
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
3353
4437
|
log2(
|
|
3354
4438
|
state,
|
|
3355
|
-
"
|
|
3356
|
-
|
|
4439
|
+
"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.",
|
|
4440
|
+
"warn"
|
|
3357
4441
|
);
|
|
3358
4442
|
}
|
|
3359
4443
|
const handleSignal = async () => {
|
|
4444
|
+
if (state.shuttingDown) return;
|
|
4445
|
+
state.shuttingDown = true;
|
|
3360
4446
|
if (state.interactive) {
|
|
3361
4447
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
3362
4448
|
displayStatus(state);
|
|
3363
4449
|
} else {
|
|
3364
4450
|
log2(state, "Shutting down...");
|
|
3365
4451
|
}
|
|
3366
|
-
await cleanup(state);
|
|
4452
|
+
await cleanup(state, { graceful: true });
|
|
3367
4453
|
await shutdownTelemetry();
|
|
3368
4454
|
process.exit(0);
|
|
3369
4455
|
};
|
|
@@ -3467,9 +4553,9 @@ async function run(options) {
|
|
|
3467
4553
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
3468
4554
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
3469
4555
|
if (versionWarning) {
|
|
3470
|
-
log2(state, versionWarning,
|
|
4556
|
+
log2(state, versionWarning, "warn");
|
|
3471
4557
|
if (state.interactive && !state.json) {
|
|
3472
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4558
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
3473
4559
|
}
|
|
3474
4560
|
}
|
|
3475
4561
|
} catch (error2) {
|
|
@@ -3483,12 +4569,20 @@ async function run(options) {
|
|
|
3483
4569
|
apiUrl: getApiUrlConfig(),
|
|
3484
4570
|
getAuthHeader: () => state.authHeader,
|
|
3485
4571
|
conversationFilter: state.conversationFilter,
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
4572
|
+
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4573
|
+
log: (entry) => (
|
|
4574
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4575
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4576
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4577
|
+
logActivity(state, {
|
|
4578
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4579
|
+
level: entry.level,
|
|
4580
|
+
message: entry.message,
|
|
4581
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4582
|
+
})
|
|
4583
|
+
)
|
|
3491
4584
|
});
|
|
4585
|
+
state.channelDriver = channelDriver;
|
|
3492
4586
|
const connection = new RunnerConnection({
|
|
3493
4587
|
agentId: state.agentId,
|
|
3494
4588
|
getAuthHeader: () => state.authHeader,
|
|
@@ -3502,7 +4596,11 @@ async function run(options) {
|
|
|
3502
4596
|
type: "info",
|
|
3503
4597
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
3504
4598
|
});
|
|
3505
|
-
emitAgentConnected(state.agentId, {
|
|
4599
|
+
emitAgentConnected(state.agentId, {
|
|
4600
|
+
port: state.port,
|
|
4601
|
+
cli_version: getCliVersion(),
|
|
4602
|
+
opencode_version: state.opencodeVersion
|
|
4603
|
+
});
|
|
3506
4604
|
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
3507
4605
|
if (state.interactive) displayStatus(state);
|
|
3508
4606
|
channelDriver.drainPending().then((processed) => {
|
|
@@ -3581,10 +4679,12 @@ async function run(options) {
|
|
|
3581
4679
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
3582
4680
|
throw error2;
|
|
3583
4681
|
}
|
|
4682
|
+
scheduleSessionCleanup(state, channelDriver, options);
|
|
3584
4683
|
if (!interactive || state.json) {
|
|
3585
4684
|
log2(state, "Driving channel messages...");
|
|
3586
4685
|
}
|
|
3587
4686
|
await driveChannels(state, channelDriver);
|
|
4687
|
+
if (state.shuttingDown) return;
|
|
3588
4688
|
await cleanup(state);
|
|
3589
4689
|
if (state.json) {
|
|
3590
4690
|
console.log(
|
|
@@ -3599,6 +4699,7 @@ async function run(options) {
|
|
|
3599
4699
|
await shutdownTelemetry();
|
|
3600
4700
|
process.exit(0);
|
|
3601
4701
|
} catch (error2) {
|
|
4702
|
+
if (state.shuttingDown) return;
|
|
3602
4703
|
await cleanup(state);
|
|
3603
4704
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
3604
4705
|
if (state.json) {
|
|
@@ -3633,15 +4734,34 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
3633
4734
|
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);
|
|
3634
4735
|
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 }));
|
|
3635
4736
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
3636
|
-
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4737
|
+
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4738
|
+
"--log-level <level>",
|
|
4739
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
4740
|
+
).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(
|
|
4741
|
+
"--session-cleanup-max-age <duration>",
|
|
4742
|
+
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4743
|
+
).option(
|
|
4744
|
+
"--session-cleanup-max-count <n>",
|
|
4745
|
+
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
4746
|
+
).option(
|
|
4747
|
+
"--session-cleanup-interval <duration>",
|
|
4748
|
+
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
4749
|
+
).action(
|
|
3637
4750
|
(options) => {
|
|
3638
4751
|
run({
|
|
3639
4752
|
agent: options.agent,
|
|
3640
4753
|
port: parseInt(options.port, 10),
|
|
4754
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
4755
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
4756
|
+
logLevel: options.logLevel,
|
|
3641
4757
|
verbose: options.verbose,
|
|
3642
4758
|
conversation: options.conversation,
|
|
3643
4759
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
3644
|
-
json: options.json
|
|
4760
|
+
json: options.json,
|
|
4761
|
+
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4762
|
+
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4763
|
+
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4764
|
+
sessionCleanupInterval: options.sessionCleanupInterval
|
|
3645
4765
|
});
|
|
3646
4766
|
}
|
|
3647
4767
|
);
|