@evident-ai/cli 3.0.1-dev.6a683e7 → 3.0.1-dev.7272711
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/dist/index.js +1115 -149
- 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;
|
|
@@ -1059,10 +1060,72 @@ function finishOf(m) {
|
|
|
1059
1060
|
const infoFinish = m.info?.finish;
|
|
1060
1061
|
return typeof infoFinish === "string" ? infoFinish : void 0;
|
|
1061
1062
|
}
|
|
1063
|
+
function errorOf(m) {
|
|
1064
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1065
|
+
return m.info?.error ?? m.error;
|
|
1066
|
+
}
|
|
1062
1067
|
function isAssistantInFlight(m) {
|
|
1063
1068
|
if (completedOf(m) == null) return true;
|
|
1064
1069
|
return finishOf(m) === "tool-calls";
|
|
1065
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
|
+
}
|
|
1066
1129
|
async function createOpenCodeSession(port, directory) {
|
|
1067
1130
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1068
1131
|
if (directory && directory.trim()) {
|
|
@@ -1080,9 +1143,16 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1080
1143
|
const data = await response.json();
|
|
1081
1144
|
return data.id;
|
|
1082
1145
|
}
|
|
1083
|
-
|
|
1146
|
+
function messageText(m) {
|
|
1147
|
+
if (!m || !Array.isArray(m.parts)) return "";
|
|
1148
|
+
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1149
|
+
}
|
|
1150
|
+
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1151
|
+
const before = await getSessionMessages(port, sessionId);
|
|
1152
|
+
const knownUserIds = new Set(
|
|
1153
|
+
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1154
|
+
);
|
|
1084
1155
|
const body = {
|
|
1085
|
-
messageID: messageId,
|
|
1086
1156
|
parts: [{ type: "text", text: content }]
|
|
1087
1157
|
};
|
|
1088
1158
|
if (options?.agent) {
|
|
@@ -1106,6 +1176,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
|
1106
1176
|
const text = await res.text().catch(() => "");
|
|
1107
1177
|
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1108
1178
|
}
|
|
1179
|
+
const READ_BACK_ATTEMPTS = 5;
|
|
1180
|
+
const READ_BACK_DELAY_MS = 150;
|
|
1181
|
+
for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
|
|
1182
|
+
const after = await getSessionMessages(port, sessionId);
|
|
1183
|
+
if (after) {
|
|
1184
|
+
let best = null;
|
|
1185
|
+
for (const m of after) {
|
|
1186
|
+
if (roleOf(m) !== "user") continue;
|
|
1187
|
+
const id = idOf(m);
|
|
1188
|
+
if (typeof id !== "string" || knownUserIds.has(id)) continue;
|
|
1189
|
+
if (messageText(m) !== content) continue;
|
|
1190
|
+
const created = createdOf(m) ?? 0;
|
|
1191
|
+
if (best === null || created > best.created) {
|
|
1192
|
+
best = { id, created };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (best) return best.id;
|
|
1196
|
+
}
|
|
1197
|
+
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1198
|
+
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return null;
|
|
1109
1202
|
}
|
|
1110
1203
|
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1111
1204
|
if (!messages || messages.length === 0) return null;
|
|
@@ -1122,19 +1215,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
|
|
|
1122
1215
|
}
|
|
1123
1216
|
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1124
1217
|
if (!messages || messages.length === 0) return null;
|
|
1218
|
+
let lastCorrelated = null;
|
|
1219
|
+
let lastNonErrored = null;
|
|
1125
1220
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1126
1221
|
const m = messages[i];
|
|
1127
|
-
if (roleOf(m)
|
|
1222
|
+
if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
|
|
1223
|
+
if (lastCorrelated === null) lastCorrelated = m;
|
|
1224
|
+
if (errorOf(m) == null) {
|
|
1225
|
+
lastNonErrored = m;
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1128
1228
|
}
|
|
1229
|
+
if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
|
|
1129
1230
|
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1130
1231
|
if (userIndex === -1) return null;
|
|
1131
1232
|
let last = null;
|
|
1233
|
+
let lastOk = null;
|
|
1132
1234
|
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1133
1235
|
const role = roleOf(messages[i]);
|
|
1134
1236
|
if (role === "user") break;
|
|
1135
|
-
if (role === "assistant")
|
|
1237
|
+
if (role === "assistant") {
|
|
1238
|
+
last = messages[i];
|
|
1239
|
+
if (errorOf(messages[i]) == null) lastOk = messages[i];
|
|
1240
|
+
}
|
|
1136
1241
|
}
|
|
1137
|
-
return last;
|
|
1242
|
+
return lastOk ?? last;
|
|
1138
1243
|
}
|
|
1139
1244
|
function messageRunState(messages, userMessageId) {
|
|
1140
1245
|
if (!messages || messages.length === 0) return "unknown";
|
|
@@ -1144,7 +1249,26 @@ function messageRunState(messages, userMessageId) {
|
|
|
1144
1249
|
if (!reply) return "unknown";
|
|
1145
1250
|
}
|
|
1146
1251
|
if (!reply) return "queued";
|
|
1147
|
-
|
|
1252
|
+
if (isAssistantInFlight(reply)) return "running";
|
|
1253
|
+
return errorOf(reply) != null ? "failed" : "done";
|
|
1254
|
+
}
|
|
1255
|
+
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
1256
|
+
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
1257
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1258
|
+
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1259
|
+
}
|
|
1260
|
+
function messageError(messages, userMessageId) {
|
|
1261
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1262
|
+
const error2 = errorOf(reply);
|
|
1263
|
+
if (error2 == null) return null;
|
|
1264
|
+
if (typeof error2 === "string") return error2;
|
|
1265
|
+
if (typeof error2 === "object") {
|
|
1266
|
+
const e = error2;
|
|
1267
|
+
const dataMessage = e.data?.message;
|
|
1268
|
+
if (typeof dataMessage === "string") return dataMessage;
|
|
1269
|
+
if (typeof e.message === "string") return e.message;
|
|
1270
|
+
}
|
|
1271
|
+
return "The agent run failed.";
|
|
1148
1272
|
}
|
|
1149
1273
|
function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
1150
1274
|
if (!messages || messages.length === 0) return false;
|
|
@@ -1152,8 +1276,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1152
1276
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1153
1277
|
);
|
|
1154
1278
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1279
|
+
|
|
1280
|
+
// src/lib/opencode/session-cleanup.ts
|
|
1281
|
+
var DURATION_UNIT_MS = {
|
|
1282
|
+
s: 1e3,
|
|
1283
|
+
m: 60 * 1e3,
|
|
1284
|
+
h: 60 * 60 * 1e3,
|
|
1285
|
+
d: 24 * 60 * 60 * 1e3
|
|
1286
|
+
};
|
|
1287
|
+
function parseDurationMs(input) {
|
|
1288
|
+
const trimmed = input.trim();
|
|
1289
|
+
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
|
1290
|
+
if (!match) {
|
|
1291
|
+
throw new Error(
|
|
1292
|
+
`Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
const value = Number(match[1]);
|
|
1296
|
+
if (value <= 0) {
|
|
1297
|
+
throw new Error(`Invalid duration "${input}": must be a positive value.`);
|
|
1298
|
+
}
|
|
1299
|
+
return value * DURATION_UNIT_MS[match[2]];
|
|
1300
|
+
}
|
|
1301
|
+
function selectSessionsToDelete(sessions, opts) {
|
|
1302
|
+
const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
|
|
1303
|
+
if (maxAgeMs === void 0 && maxCount === void 0) return [];
|
|
1304
|
+
const ageEligible = (s) => {
|
|
1305
|
+
if (maxAgeMs === void 0) return false;
|
|
1306
|
+
if (s.lastActivityMs === null) return true;
|
|
1307
|
+
return nowMs - s.lastActivityMs > maxAgeMs;
|
|
1308
|
+
};
|
|
1309
|
+
const countEligibleIds = /* @__PURE__ */ new Set();
|
|
1310
|
+
if (maxCount !== void 0) {
|
|
1311
|
+
const byActivityDesc = [...sessions].sort(
|
|
1312
|
+
(a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
|
|
1313
|
+
);
|
|
1314
|
+
for (const s of byActivityDesc.slice(maxCount)) {
|
|
1315
|
+
countEligibleIds.add(s.id);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const toDelete = [];
|
|
1319
|
+
for (const s of sessions) {
|
|
1320
|
+
if (protectedIds.has(s.id)) continue;
|
|
1321
|
+
if (ageEligible(s) || countEligibleIds.has(s.id)) {
|
|
1322
|
+
toDelete.push(s.id);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return toDelete;
|
|
1326
|
+
}
|
|
1327
|
+
var DEFAULT_INTERVAL = "1h";
|
|
1328
|
+
function resolve(flag, envValue, fallback) {
|
|
1329
|
+
return flag ?? envValue ?? fallback;
|
|
1330
|
+
}
|
|
1331
|
+
function parseMaxCount(input) {
|
|
1332
|
+
const trimmed = input.trim();
|
|
1333
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1334
|
+
throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
|
|
1335
|
+
}
|
|
1336
|
+
const value = Number(trimmed);
|
|
1337
|
+
if (value <= 0) {
|
|
1338
|
+
throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
|
|
1339
|
+
}
|
|
1340
|
+
return value;
|
|
1341
|
+
}
|
|
1342
|
+
function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
1343
|
+
const warnings = [];
|
|
1344
|
+
const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
|
|
1345
|
+
const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
|
|
1346
|
+
const intervalRaw = resolve(
|
|
1347
|
+
flags.interval,
|
|
1348
|
+
env.EVIDENT_SESSION_CLEANUP_INTERVAL,
|
|
1349
|
+
DEFAULT_INTERVAL
|
|
1350
|
+
);
|
|
1351
|
+
let maxAgeMs;
|
|
1352
|
+
if (maxAgeRaw !== void 0) {
|
|
1353
|
+
try {
|
|
1354
|
+
maxAgeMs = parseDurationMs(maxAgeRaw);
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
warnings.push(
|
|
1357
|
+
`Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
let maxCount;
|
|
1362
|
+
if (maxCountRaw !== void 0) {
|
|
1363
|
+
try {
|
|
1364
|
+
maxCount = parseMaxCount(maxCountRaw);
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
warnings.push(
|
|
1367
|
+
`Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
let intervalMs;
|
|
1372
|
+
try {
|
|
1373
|
+
intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
warnings.push(
|
|
1376
|
+
`Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
|
|
1377
|
+
);
|
|
1378
|
+
intervalMs = parseDurationMs(DEFAULT_INTERVAL);
|
|
1379
|
+
}
|
|
1380
|
+
const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
|
|
1381
|
+
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
1157
1382
|
}
|
|
1158
1383
|
|
|
1159
1384
|
// src/lib/tunnel/connection.ts
|
|
@@ -1232,24 +1457,26 @@ var StreamForwarder = class {
|
|
|
1232
1457
|
this.send({ type: "res_end", sid });
|
|
1233
1458
|
return;
|
|
1234
1459
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1460
|
+
if (process.env.DEBUG) {
|
|
1461
|
+
log("debug", "agent_request", {
|
|
1462
|
+
correlation_id: correlationId,
|
|
1463
|
+
sid,
|
|
1464
|
+
method,
|
|
1465
|
+
path: stripQuery(path)
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1241
1468
|
const ac = new AbortController();
|
|
1242
1469
|
let bodyPromise;
|
|
1243
1470
|
let pushBody;
|
|
1244
1471
|
let endBody;
|
|
1245
1472
|
if (has_body) {
|
|
1246
1473
|
const chunks = [];
|
|
1247
|
-
bodyPromise = new Promise((
|
|
1474
|
+
bodyPromise = new Promise((resolve2) => {
|
|
1248
1475
|
pushBody = (buf) => {
|
|
1249
1476
|
chunks.push(buf);
|
|
1250
1477
|
};
|
|
1251
1478
|
endBody = () => {
|
|
1252
|
-
|
|
1479
|
+
resolve2(Buffer.concat(chunks));
|
|
1253
1480
|
};
|
|
1254
1481
|
});
|
|
1255
1482
|
}
|
|
@@ -1284,12 +1511,14 @@ var StreamForwarder = class {
|
|
|
1284
1511
|
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1285
1512
|
});
|
|
1286
1513
|
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1514
|
+
if (process.env.DEBUG) {
|
|
1515
|
+
log("debug", "agent_response", {
|
|
1516
|
+
correlation_id: correlationId,
|
|
1517
|
+
sid,
|
|
1518
|
+
status: upstream.status,
|
|
1519
|
+
duration_ms: Date.now() - startedAt
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1293
1522
|
this.callbacks.onHead?.(sid, upstream.status);
|
|
1294
1523
|
try {
|
|
1295
1524
|
if (upstream.body) {
|
|
@@ -1365,7 +1594,7 @@ function connectTunnel(options) {
|
|
|
1365
1594
|
} = options;
|
|
1366
1595
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1367
1596
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1368
|
-
return new Promise((
|
|
1597
|
+
return new Promise((resolve2, reject) => {
|
|
1369
1598
|
const ws = new WebSocket2(url, {
|
|
1370
1599
|
headers: {
|
|
1371
1600
|
Authorization: authHeader
|
|
@@ -1430,7 +1659,7 @@ function connectTunnel(options) {
|
|
|
1430
1659
|
clearTimeout(connectionTimeout);
|
|
1431
1660
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1432
1661
|
onConnected?.(connectedAgentId);
|
|
1433
|
-
|
|
1662
|
+
resolve2({
|
|
1434
1663
|
ws,
|
|
1435
1664
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1436
1665
|
});
|
|
@@ -1559,8 +1788,9 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1559
1788
|
};
|
|
1560
1789
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1561
1790
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1562
|
-
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1563
1791
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1792
|
+
var HEARTBEAT_MS = 6e4;
|
|
1793
|
+
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1564
1794
|
var ChannelAuthError = class extends Error {
|
|
1565
1795
|
constructor(message) {
|
|
1566
1796
|
super(message);
|
|
@@ -1595,11 +1825,18 @@ var ChannelDriver = class {
|
|
|
1595
1825
|
sleep;
|
|
1596
1826
|
pausedPollIntervalMs;
|
|
1597
1827
|
pausedMaxWaitMs;
|
|
1598
|
-
dispatchConfirmMs;
|
|
1599
1828
|
stuckQueuedMs;
|
|
1600
1829
|
now;
|
|
1601
1830
|
/** Cache of conversationId → opencode sessionId. */
|
|
1602
1831
|
sessions = /* @__PURE__ */ new Map();
|
|
1832
|
+
/**
|
|
1833
|
+
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1834
|
+
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
1835
|
+
* "the one new user row" — which is only unambiguous if no OTHER dispatch into
|
|
1836
|
+
* the SAME session interleaves its snapshot→POST→read-back. This map chains each
|
|
1837
|
+
* session's dispatches so they run serially; distinct sessions stay concurrent.
|
|
1838
|
+
*/
|
|
1839
|
+
sessionDispatchLocks = /* @__PURE__ */ new Map();
|
|
1603
1840
|
/**
|
|
1604
1841
|
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1605
1842
|
* session: one polling loop services all of that session's in-flight messages.
|
|
@@ -1650,6 +1887,20 @@ var ChannelDriver = class {
|
|
|
1650
1887
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1651
1888
|
*/
|
|
1652
1889
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
1890
|
+
/**
|
|
1891
|
+
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1892
|
+
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
1893
|
+
* is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
|
|
1894
|
+
* persist hasn't landed before tick N+1 re-reads the still-null
|
|
1895
|
+
* `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
|
|
1896
|
+
* A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
|
|
1897
|
+
* short-circuits while it is present, so a null-id row is re-dispatched AT MOST
|
|
1898
|
+
* ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
|
|
1899
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
1900
|
+
* re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
|
|
1901
|
+
* so the NEXT tick may retry exactly once more).
|
|
1902
|
+
*/
|
|
1903
|
+
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
1653
1904
|
/**
|
|
1654
1905
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1655
1906
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1657,8 +1908,33 @@ var ChannelDriver = class {
|
|
|
1657
1908
|
* not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
|
|
1658
1909
|
*/
|
|
1659
1910
|
opencodeDirectory = void 0;
|
|
1911
|
+
/**
|
|
1912
|
+
* Cache of opencode `sessionId → parentID` (its parent session, or `null` when
|
|
1913
|
+
* the session is a root with no parent). Sub-agents spawned via the `task` tool
|
|
1914
|
+
* run in CHILD sessions whose `parentID` chains up to the Evident-created
|
|
1915
|
+
* (watched) session; we resolve this once per session so a child-session
|
|
1916
|
+
* question/permission can be attributed to the watched session's subtree
|
|
1917
|
+
* (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
|
|
1918
|
+
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1919
|
+
*/
|
|
1920
|
+
sessionParents = /* @__PURE__ */ new Map();
|
|
1660
1921
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1661
1922
|
draining = false;
|
|
1923
|
+
/**
|
|
1924
|
+
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
1925
|
+
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
1926
|
+
* is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
|
|
1927
|
+
* drain that entered before `stop()` still registers its watcher).
|
|
1928
|
+
*/
|
|
1929
|
+
activeDrain = null;
|
|
1930
|
+
/**
|
|
1931
|
+
* Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
|
|
1932
|
+
* dispatches NEW work (it returns 0 immediately) — but the per-session watcher
|
|
1933
|
+
* loops already running keep going so in-flight turns can finish and deliver
|
|
1934
|
+
* their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
|
|
1935
|
+
* and stops opencode.
|
|
1936
|
+
*/
|
|
1937
|
+
stopped = false;
|
|
1662
1938
|
constructor(config2) {
|
|
1663
1939
|
this.agentId = config2.agentId;
|
|
1664
1940
|
this.port = config2.port;
|
|
@@ -1672,7 +1948,6 @@ var ChannelDriver = class {
|
|
|
1672
1948
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1673
1949
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1674
1950
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1675
|
-
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1676
1951
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1677
1952
|
this.now = config2.now ?? (() => Date.now());
|
|
1678
1953
|
}
|
|
@@ -1691,8 +1966,21 @@ var ChannelDriver = class {
|
|
|
1691
1966
|
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1692
1967
|
*/
|
|
1693
1968
|
async drainPending() {
|
|
1969
|
+
if (this.stopped) return 0;
|
|
1694
1970
|
if (this.draining) return 0;
|
|
1695
1971
|
this.draining = true;
|
|
1972
|
+
const run2 = this.runDrain();
|
|
1973
|
+
this.activeDrain = run2.then(
|
|
1974
|
+
() => {
|
|
1975
|
+
this.activeDrain = null;
|
|
1976
|
+
},
|
|
1977
|
+
() => {
|
|
1978
|
+
this.activeDrain = null;
|
|
1979
|
+
}
|
|
1980
|
+
);
|
|
1981
|
+
return run2;
|
|
1982
|
+
}
|
|
1983
|
+
async runDrain() {
|
|
1696
1984
|
let dispatched = 0;
|
|
1697
1985
|
try {
|
|
1698
1986
|
const conversations = await this.getPendingConversations();
|
|
@@ -1704,6 +1992,7 @@ var ChannelDriver = class {
|
|
|
1704
1992
|
});
|
|
1705
1993
|
}
|
|
1706
1994
|
for (const conv of conversations) {
|
|
1995
|
+
if (this.stopped) break;
|
|
1707
1996
|
dispatched += await this.processConversation(conv);
|
|
1708
1997
|
}
|
|
1709
1998
|
await this.readoptProcessing();
|
|
@@ -1724,6 +2013,73 @@ var ChannelDriver = class {
|
|
|
1724
2013
|
}
|
|
1725
2014
|
return false;
|
|
1726
2015
|
}
|
|
2016
|
+
/**
|
|
2017
|
+
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2018
|
+
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
2019
|
+
* `watchers` entry whose `inFlight` set is non-empty — the same predicate
|
|
2020
|
+
* `hasInFlightWatchers()` uses, lifted to return the ids.
|
|
2021
|
+
*
|
|
2022
|
+
* Deliberately does NOT include `this.sessions` (the permanent, never-pruned
|
|
2023
|
+
* conversation→session cache). Protecting every bound-but-idle session there
|
|
2024
|
+
* would shield nearly every session and defeat cleanup — AND it is unnecessary:
|
|
2025
|
+
* `ensureSession` is self-healing (it recreates a session whose id no longer
|
|
2026
|
+
* exists), so deleting an idle bound session is harmless — the conversation's
|
|
2027
|
+
* next turn transparently rebinds a fresh one. The only thing worth protecting
|
|
2028
|
+
* is a session with a turn ACTIVELY in flight right now: tearing that down
|
|
2029
|
+
* mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
|
|
2030
|
+
*/
|
|
2031
|
+
protectedSessionIds() {
|
|
2032
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2033
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
2034
|
+
if (watcher.inFlight.size > 0) ids.add(sessionId);
|
|
2035
|
+
}
|
|
2036
|
+
return ids;
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
|
|
2040
|
+
* this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
|
|
2041
|
+
* — but the watcher loops already tracking in-flight turns keep running, so a
|
|
2042
|
+
* turn that has finished (or is about to) still fires `markDone` and delivers
|
|
2043
|
+
* its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
|
|
2044
|
+
*/
|
|
2045
|
+
stop() {
|
|
2046
|
+
this.stopped = true;
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
|
|
2050
|
+
* graceful shutdown, so a turn whose reply is ready — or completes within the
|
|
2051
|
+
* window — is delivered before the process exits, instead of being cut off and
|
|
2052
|
+
* left for the ADR-0046 restart-recovery path.
|
|
2053
|
+
*
|
|
2054
|
+
* Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
|
|
2055
|
+
* far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
|
|
2056
|
+
* window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
|
|
2057
|
+
* set empties OR the timeout elapses. Anything still in flight at the timeout is
|
|
2058
|
+
* safe to abandon — it stays `processing` server-side and is re-adopted on the
|
|
2059
|
+
* next runner start (ADR-0046).
|
|
2060
|
+
*
|
|
2061
|
+
* @returns true if all in-flight work settled within the window; false if the
|
|
2062
|
+
* timeout elapsed with work still in flight.
|
|
2063
|
+
*/
|
|
2064
|
+
async waitForInFlight(timeoutMs) {
|
|
2065
|
+
const deadline = this.now() + timeoutMs;
|
|
2066
|
+
const step = Math.min(this.pausedPollIntervalMs, 250);
|
|
2067
|
+
if (this.activeDrain) {
|
|
2068
|
+
let drainSettled = false;
|
|
2069
|
+
void this.activeDrain.then(() => {
|
|
2070
|
+
drainSettled = true;
|
|
2071
|
+
});
|
|
2072
|
+
while (!drainSettled) {
|
|
2073
|
+
if (this.now() >= deadline) return false;
|
|
2074
|
+
await this.sleep(step);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
while (this.hasInFlightWatchers()) {
|
|
2078
|
+
if (this.now() >= deadline) return false;
|
|
2079
|
+
await this.sleep(step);
|
|
2080
|
+
}
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
1727
2083
|
/**
|
|
1728
2084
|
* Await all outstanding per-session watchers (WI-3).
|
|
1729
2085
|
*
|
|
@@ -1760,15 +2116,16 @@ var ChannelDriver = class {
|
|
|
1760
2116
|
let dispatched = 0;
|
|
1761
2117
|
let skippedAlreadyDispatched = 0;
|
|
1762
2118
|
for (const message of messages) {
|
|
2119
|
+
if (this.stopped) break;
|
|
1763
2120
|
if (this.dispatched.has(message.id)) {
|
|
1764
2121
|
skippedAlreadyDispatched += 1;
|
|
1765
2122
|
continue;
|
|
1766
2123
|
}
|
|
1767
|
-
const opencodeMessageId = opencodeMessageIdFor2(message.id);
|
|
1768
2124
|
const options = {
|
|
1769
2125
|
agent: message.opencode_agent ?? void 0,
|
|
1770
2126
|
model: message.opencode_model ?? void 0
|
|
1771
2127
|
};
|
|
2128
|
+
let opencodeMessageId;
|
|
1772
2129
|
try {
|
|
1773
2130
|
this.log({
|
|
1774
2131
|
level: "info",
|
|
@@ -1776,10 +2133,23 @@ var ChannelDriver = class {
|
|
|
1776
2133
|
conversation_id: conv.id,
|
|
1777
2134
|
message_id: message.id
|
|
1778
2135
|
});
|
|
1779
|
-
await
|
|
2136
|
+
opencodeMessageId = await this.dispatchLocked(
|
|
2137
|
+
sessionId,
|
|
2138
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2139
|
+
);
|
|
1780
2140
|
} catch (err) {
|
|
1781
2141
|
if (err instanceof ChannelAuthError) throw err;
|
|
1782
2142
|
this.dispatched.delete(message.id);
|
|
2143
|
+
if (await sessionExists(this.port, sessionId) === false) {
|
|
2144
|
+
this.sessions.delete(conv.id);
|
|
2145
|
+
this.log({
|
|
2146
|
+
level: "info",
|
|
2147
|
+
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.`,
|
|
2148
|
+
conversation_id: conv.id,
|
|
2149
|
+
message_id: message.id
|
|
2150
|
+
});
|
|
2151
|
+
break;
|
|
2152
|
+
}
|
|
1783
2153
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1784
2154
|
});
|
|
1785
2155
|
this.log({
|
|
@@ -1790,6 +2160,15 @@ var ChannelDriver = class {
|
|
|
1790
2160
|
});
|
|
1791
2161
|
continue;
|
|
1792
2162
|
}
|
|
2163
|
+
if (opencodeMessageId === null) {
|
|
2164
|
+
this.log({
|
|
2165
|
+
level: "error",
|
|
2166
|
+
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`,
|
|
2167
|
+
conversation_id: conv.id,
|
|
2168
|
+
message_id: message.id
|
|
2169
|
+
});
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
1793
2172
|
this.dispatched.add(message.id);
|
|
1794
2173
|
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1795
2174
|
dispatched += 1;
|
|
@@ -1806,16 +2185,33 @@ var ChannelDriver = class {
|
|
|
1806
2185
|
return dispatched;
|
|
1807
2186
|
}
|
|
1808
2187
|
async ensureSession(conv) {
|
|
1809
|
-
const
|
|
1810
|
-
if (
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2188
|
+
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2189
|
+
if (bound) {
|
|
2190
|
+
const exists = await sessionExists(this.port, bound);
|
|
2191
|
+
if (exists === false) {
|
|
2192
|
+
this.log({
|
|
2193
|
+
level: "info",
|
|
2194
|
+
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.`,
|
|
2195
|
+
conversation_id: conv.id
|
|
2196
|
+
});
|
|
2197
|
+
this.sessions.delete(conv.id);
|
|
2198
|
+
return this.createAndBindSession(conv.id);
|
|
2199
|
+
}
|
|
2200
|
+
this.sessions.set(conv.id, bound);
|
|
2201
|
+
return bound;
|
|
1814
2202
|
}
|
|
2203
|
+
return this.createAndBindSession(conv.id);
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
2207
|
+
* best-effort persist it server-side. Shared by the first-ever bind and the
|
|
2208
|
+
* self-heal recreate path in `ensureSession`.
|
|
2209
|
+
*/
|
|
2210
|
+
async createAndBindSession(conversationId) {
|
|
1815
2211
|
const directory = await this.resolveOpenCodeDirectory();
|
|
1816
2212
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1817
|
-
this.sessions.set(
|
|
1818
|
-
await this.persistSession(
|
|
2213
|
+
this.sessions.set(conversationId, sessionId);
|
|
2214
|
+
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
1819
2215
|
});
|
|
1820
2216
|
return sessionId;
|
|
1821
2217
|
}
|
|
@@ -1838,6 +2234,25 @@ var ChannelDriver = class {
|
|
|
1838
2234
|
// -------------------------------------------------------------------------
|
|
1839
2235
|
// Per-session watcher (WI-3)
|
|
1840
2236
|
// -------------------------------------------------------------------------
|
|
2237
|
+
/**
|
|
2238
|
+
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2239
|
+
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
2240
|
+
* never interleave and mis-correlate their read-backs. Distinct sessions run
|
|
2241
|
+
* concurrently. The chained tail intentionally ignores the prior result/error
|
|
2242
|
+
* (each dispatch reports its own outcome to its caller).
|
|
2243
|
+
*/
|
|
2244
|
+
dispatchLocked(sessionId, fn) {
|
|
2245
|
+
const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
|
|
2246
|
+
const run2 = prior.then(fn, fn);
|
|
2247
|
+
this.sessionDispatchLocks.set(
|
|
2248
|
+
sessionId,
|
|
2249
|
+
run2.then(
|
|
2250
|
+
() => void 0,
|
|
2251
|
+
() => void 0
|
|
2252
|
+
)
|
|
2253
|
+
);
|
|
2254
|
+
return run2;
|
|
2255
|
+
}
|
|
1841
2256
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1842
2257
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1843
2258
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -1847,7 +2262,9 @@ var ChannelDriver = class {
|
|
|
1847
2262
|
inFlight: /* @__PURE__ */ new Map(),
|
|
1848
2263
|
loop: null,
|
|
1849
2264
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1850
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2265
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2266
|
+
lastGoodPollAt: this.now(),
|
|
2267
|
+
hadUsablePoll: false
|
|
1851
2268
|
};
|
|
1852
2269
|
this.watchers.set(sessionId, watcher);
|
|
1853
2270
|
}
|
|
@@ -1860,17 +2277,33 @@ var ChannelDriver = class {
|
|
|
1860
2277
|
deadline: now + this.pausedMaxWaitMs,
|
|
1861
2278
|
started: false,
|
|
1862
2279
|
done: false,
|
|
1863
|
-
stuckReported: false
|
|
2280
|
+
stuckReported: false,
|
|
2281
|
+
lastAliveAt: 0,
|
|
2282
|
+
aliveInFlight: false,
|
|
2283
|
+
awaitingHumanLatched: false,
|
|
2284
|
+
pausedOnQuestion: false,
|
|
2285
|
+
pausedOnPermission: false,
|
|
2286
|
+
pausedClearConfirmed: false,
|
|
2287
|
+
pausedInFlight: false,
|
|
2288
|
+
deliveryDeadlineAnchored: false
|
|
1864
2289
|
});
|
|
1865
2290
|
}
|
|
1866
2291
|
/**
|
|
1867
2292
|
* Register a RE-ADOPTED `processing` message with its session watcher
|
|
1868
2293
|
* (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
|
|
1869
2294
|
* `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
|
|
1870
|
-
* `now
|
|
1871
|
-
* (10 min after `processed_at
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
2295
|
+
* `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
|
|
2296
|
+
* fresh dispatch would (10 min after `processed_at`, not 10 min from now).
|
|
2297
|
+
*
|
|
2298
|
+
* This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
|
|
2299
|
+
* give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
|
|
2300
|
+
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
2301
|
+
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
2302
|
+
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
2303
|
+
* handed to the cron. The old "the `deadline` must settle before the ~15-min
|
|
2304
|
+
* cron or they double-drive" reasoning is superseded: liveness now settles the
|
|
2305
|
+
* actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
|
|
2306
|
+
* (only the appear-guard uses it).
|
|
1874
2307
|
*
|
|
1875
2308
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
1876
2309
|
* `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
|
|
@@ -1888,7 +2321,9 @@ var ChannelDriver = class {
|
|
|
1888
2321
|
inFlight: /* @__PURE__ */ new Map(),
|
|
1889
2322
|
loop: null,
|
|
1890
2323
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1891
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2324
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2325
|
+
lastGoodPollAt: this.now(),
|
|
2326
|
+
hadUsablePoll: false
|
|
1892
2327
|
};
|
|
1893
2328
|
this.watchers.set(sessionId, watcher);
|
|
1894
2329
|
}
|
|
@@ -1902,11 +2337,24 @@ var ChannelDriver = class {
|
|
|
1902
2337
|
started: true,
|
|
1903
2338
|
done: false,
|
|
1904
2339
|
// Not yet reported stuck-queued. The once-guard (`stuckReported`) applies,
|
|
1905
|
-
// AND the stuck-queued observer
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
// (
|
|
1909
|
-
stuckReported: false
|
|
2340
|
+
// AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
|
|
2341
|
+
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2342
|
+
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2343
|
+
// (#210/#220 observability).
|
|
2344
|
+
stuckReported: false,
|
|
2345
|
+
// Task 5.2: a re-adopted actively-running row re-attaches into the SAME
|
|
2346
|
+
// watcher and so hits the SAME actively-running heartbeat branch in
|
|
2347
|
+
// `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
|
|
2348
|
+
// re-adopted and is confirming this row alive" via that `alive` heartbeat,
|
|
2349
|
+
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2350
|
+
lastAliveAt: 0,
|
|
2351
|
+
aliveInFlight: false,
|
|
2352
|
+
awaitingHumanLatched: false,
|
|
2353
|
+
pausedOnQuestion: false,
|
|
2354
|
+
pausedOnPermission: false,
|
|
2355
|
+
pausedClearConfirmed: false,
|
|
2356
|
+
pausedInFlight: false,
|
|
2357
|
+
deliveryDeadlineAnchored: false
|
|
1910
2358
|
});
|
|
1911
2359
|
}
|
|
1912
2360
|
/**
|
|
@@ -1957,12 +2405,30 @@ var ChannelDriver = class {
|
|
|
1957
2405
|
messages = Array.isArray(body) ? body : null;
|
|
1958
2406
|
}
|
|
1959
2407
|
} catch {
|
|
1960
|
-
continue;
|
|
1961
2408
|
}
|
|
2409
|
+
if (messages != null && messages.length > 0) {
|
|
2410
|
+
watcher.lastGoodPollAt = this.now();
|
|
2411
|
+
watcher.hadUsablePoll = true;
|
|
2412
|
+
} else {
|
|
2413
|
+
const emptyButReachable = messages != null;
|
|
2414
|
+
const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
|
|
2415
|
+
if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
|
|
2416
|
+
continue;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
|
|
1962
2420
|
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
1963
|
-
await this.serviceInFlightMessage(
|
|
2421
|
+
await this.serviceInFlightMessage(
|
|
2422
|
+
sessionId,
|
|
2423
|
+
watcher,
|
|
2424
|
+
inFlight,
|
|
2425
|
+
messages,
|
|
2426
|
+
openQuestions,
|
|
2427
|
+
openPermissions,
|
|
2428
|
+
questionsPolledOk,
|
|
2429
|
+
permissionsPolledOk
|
|
2430
|
+
);
|
|
1964
2431
|
}
|
|
1965
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
1966
2432
|
}
|
|
1967
2433
|
} catch (err) {
|
|
1968
2434
|
if (err instanceof ChannelAuthError) {
|
|
@@ -1984,19 +2450,49 @@ var ChannelDriver = class {
|
|
|
1984
2450
|
});
|
|
1985
2451
|
}
|
|
1986
2452
|
}
|
|
2453
|
+
/**
|
|
2454
|
+
* On FIRST observing a terminal (done/failed) state, ensure the delivery
|
|
2455
|
+
* (markDone/markFailed) transient-retry path has a real window. A long
|
|
2456
|
+
* ACTIVELY-running turn is kept past its original `deadline`, so by completion
|
|
2457
|
+
* `now >= deadline` already holds and the retry bound below would fire on the
|
|
2458
|
+
* first transient PATCH failure — dropping the message before its reply lands
|
|
2459
|
+
* (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
|
|
2460
|
+
* to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
|
|
2461
|
+
* or past now, so a still-ample window is left untouched.
|
|
2462
|
+
*/
|
|
2463
|
+
anchorDeliveryDeadline(inFlight) {
|
|
2464
|
+
if (inFlight.deliveryDeadlineAnchored) return;
|
|
2465
|
+
inFlight.deliveryDeadlineAnchored = true;
|
|
2466
|
+
if (this.now() >= inFlight.deadline) {
|
|
2467
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
1987
2470
|
/**
|
|
1988
2471
|
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
1989
2472
|
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
1990
2473
|
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
1991
2474
|
* in-flight set on completion or timeout.
|
|
1992
2475
|
*/
|
|
1993
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
2476
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
|
|
1994
2477
|
const conv = watcher.conv;
|
|
1995
2478
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1996
|
-
|
|
2479
|
+
const id = inFlight.evidentMessageId;
|
|
2480
|
+
if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
|
|
2481
|
+
else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
|
|
2482
|
+
if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
|
|
2483
|
+
else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
|
|
2484
|
+
const observedOpen = openQuestions.has(id) || openPermissions.has(id);
|
|
2485
|
+
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2486
|
+
const awaitingHuman = observedOpen || latchedPaused;
|
|
2487
|
+
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
1997
2488
|
let claimed;
|
|
1998
2489
|
try {
|
|
1999
|
-
claimed = await this.markProcessing(
|
|
2490
|
+
claimed = await this.markProcessing(
|
|
2491
|
+
conv.id,
|
|
2492
|
+
inFlight.evidentMessageId,
|
|
2493
|
+
sessionId,
|
|
2494
|
+
inFlight.opencodeMessageId
|
|
2495
|
+
);
|
|
2000
2496
|
} catch (err) {
|
|
2001
2497
|
if (err instanceof ChannelAuthError) throw err;
|
|
2002
2498
|
this.log({
|
|
@@ -2018,6 +2514,7 @@ var ChannelDriver = class {
|
|
|
2018
2514
|
}
|
|
2019
2515
|
}
|
|
2020
2516
|
if (state === "done") {
|
|
2517
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2021
2518
|
if (!inFlight.done) {
|
|
2022
2519
|
this.log({
|
|
2023
2520
|
level: "info",
|
|
@@ -2026,7 +2523,12 @@ var ChannelDriver = class {
|
|
|
2026
2523
|
message_id: inFlight.evidentMessageId
|
|
2027
2524
|
});
|
|
2028
2525
|
try {
|
|
2029
|
-
await this.markDone(
|
|
2526
|
+
await this.markDone(
|
|
2527
|
+
conv.id,
|
|
2528
|
+
inFlight.evidentMessageId,
|
|
2529
|
+
sessionId,
|
|
2530
|
+
inFlight.opencodeMessageId
|
|
2531
|
+
);
|
|
2030
2532
|
} catch (err) {
|
|
2031
2533
|
if (err instanceof ChannelAuthError) throw err;
|
|
2032
2534
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -2062,59 +2564,104 @@ var ChannelDriver = class {
|
|
|
2062
2564
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2063
2565
|
return;
|
|
2064
2566
|
}
|
|
2065
|
-
if (state === "
|
|
2066
|
-
|
|
2067
|
-
|
|
2567
|
+
if (state === "failed") {
|
|
2568
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2569
|
+
if (!inFlight.done) {
|
|
2570
|
+
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2571
|
+
this.log({
|
|
2572
|
+
level: "error",
|
|
2573
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
2574
|
+
conversation_id: conv.id,
|
|
2575
|
+
message_id: inFlight.evidentMessageId
|
|
2576
|
+
});
|
|
2577
|
+
try {
|
|
2578
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
2579
|
+
} catch (err) {
|
|
2580
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2581
|
+
if (err instanceof ChannelTerminalError) {
|
|
2582
|
+
this.log({
|
|
2583
|
+
level: "error",
|
|
2584
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2585
|
+
conversation_id: conv.id,
|
|
2586
|
+
message_id: inFlight.evidentMessageId
|
|
2587
|
+
});
|
|
2588
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
if (this.now() >= inFlight.deadline) {
|
|
2592
|
+
this.log({
|
|
2593
|
+
level: "error",
|
|
2594
|
+
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)}`,
|
|
2595
|
+
conversation_id: conv.id,
|
|
2596
|
+
message_id: inFlight.evidentMessageId
|
|
2597
|
+
});
|
|
2598
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
this.log({
|
|
2602
|
+
level: "error",
|
|
2603
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2604
|
+
conversation_id: conv.id,
|
|
2605
|
+
message_id: inFlight.evidentMessageId
|
|
2606
|
+
});
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
inFlight.done = true;
|
|
2068
2610
|
}
|
|
2611
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2612
|
+
return;
|
|
2069
2613
|
}
|
|
2070
|
-
|
|
2614
|
+
const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
|
|
2615
|
+
const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
|
|
2616
|
+
if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
|
|
2071
2617
|
inFlight.stuckReported = true;
|
|
2072
2618
|
void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
|
|
2073
2619
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
2074
2620
|
});
|
|
2075
2621
|
}
|
|
2076
|
-
|
|
2622
|
+
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2623
|
+
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2624
|
+
inFlight.aliveInFlight = true;
|
|
2625
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
2626
|
+
inFlight.aliveInFlight = false;
|
|
2627
|
+
if (ok) inFlight.lastAliveAt = this.now();
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
if (awaitingHuman) {
|
|
2631
|
+
if (!inFlight.awaitingHumanLatched) {
|
|
2632
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2633
|
+
inFlight.awaitingHumanLatched = true;
|
|
2634
|
+
}
|
|
2635
|
+
if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
|
|
2636
|
+
inFlight.pausedInFlight = true;
|
|
2637
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
|
|
2638
|
+
inFlight.pausedInFlight = false;
|
|
2639
|
+
if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
} else if (inFlight.awaitingHumanLatched) {
|
|
2643
|
+
inFlight.awaitingHumanLatched = false;
|
|
2644
|
+
inFlight.pausedOnQuestion = false;
|
|
2645
|
+
inFlight.pausedOnPermission = false;
|
|
2646
|
+
inFlight.pausedClearConfirmed = false;
|
|
2647
|
+
}
|
|
2648
|
+
const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
|
|
2649
|
+
const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
|
|
2650
|
+
(sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
|
|
2651
|
+
);
|
|
2652
|
+
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2653
|
+
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2077
2654
|
this.log({
|
|
2078
2655
|
level: "info",
|
|
2079
2656
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2080
2657
|
conversation_id: conv.id,
|
|
2081
2658
|
message_id: inFlight.evidentMessageId
|
|
2082
2659
|
});
|
|
2083
|
-
this.
|
|
2084
|
-
|
|
2085
|
-
}
|
|
2086
|
-
/**
|
|
2087
|
-
* Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
|
|
2088
|
-
* opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
|
|
2089
|
-
* fact 9) — one user message + one reply even if the original DID land. Resets
|
|
2090
|
-
* the dispatch timestamp so the guard doesn't immediately fire again.
|
|
2091
|
-
*/
|
|
2092
|
-
async redispatchInFlight(sessionId, inFlight) {
|
|
2093
|
-
const options = {
|
|
2094
|
-
agent: inFlight.message.opencode_agent ?? void 0,
|
|
2095
|
-
model: inFlight.message.opencode_model ?? void 0
|
|
2096
|
-
};
|
|
2097
|
-
this.log({
|
|
2098
|
-
level: "info",
|
|
2099
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
|
|
2100
|
-
message_id: inFlight.evidentMessageId
|
|
2101
|
-
});
|
|
2102
|
-
try {
|
|
2103
|
-
await sendPromptAsync(
|
|
2104
|
-
this.port,
|
|
2105
|
-
sessionId,
|
|
2106
|
-
inFlight.message.content,
|
|
2107
|
-
options,
|
|
2108
|
-
inFlight.opencodeMessageId
|
|
2109
|
-
);
|
|
2110
|
-
} catch (err) {
|
|
2111
|
-
this.log({
|
|
2112
|
-
level: "error",
|
|
2113
|
-
message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2114
|
-
message_id: inFlight.evidentMessageId
|
|
2660
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2661
|
+
watched_for_ms: this.now() - inFlight.dispatchedAt
|
|
2115
2662
|
});
|
|
2663
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2116
2664
|
}
|
|
2117
|
-
inFlight.dispatchedAt = this.now();
|
|
2118
2665
|
}
|
|
2119
2666
|
// -------------------------------------------------------------------------
|
|
2120
2667
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
@@ -2203,10 +2750,17 @@ var ChannelDriver = class {
|
|
|
2203
2750
|
* Re-adopt ONE `processing` row against the tick's session message snapshot
|
|
2204
2751
|
* (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
|
|
2205
2752
|
*
|
|
2206
|
-
* Branches on `messageRunState(messages,
|
|
2753
|
+
* Branches on `messageRunState(messages, row.opencode_message_id)` — the
|
|
2754
|
+
* opencode-assigned user-message id persisted on the first `processing` PATCH
|
|
2755
|
+
* (#218). A row with a NULL stored id (dispatched but the read-back never landed
|
|
2756
|
+
* before the restart) has no id to correlate → treated as an orphan and
|
|
2757
|
+
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
2207
2758
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
2208
|
-
* - `
|
|
2209
|
-
*
|
|
2759
|
+
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
2760
|
+
* errored turn is reported failed on restart, NOT re-dispatched;
|
|
2761
|
+
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
2762
|
+
* tracking the stored id so the reply correlates by it;
|
|
2763
|
+
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
2210
2764
|
*
|
|
2211
2765
|
* Only `ChannelAuthError` propagates.
|
|
2212
2766
|
*/
|
|
@@ -2220,8 +2774,8 @@ var ChannelDriver = class {
|
|
|
2220
2774
|
});
|
|
2221
2775
|
return;
|
|
2222
2776
|
}
|
|
2223
|
-
const ocId =
|
|
2224
|
-
const state = messageRunState(messages, ocId);
|
|
2777
|
+
const ocId = row.opencode_message_id;
|
|
2778
|
+
const state = messageRunState(messages, ocId ?? "");
|
|
2225
2779
|
if (state === "done") {
|
|
2226
2780
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2227
2781
|
this.log({
|
|
@@ -2239,7 +2793,7 @@ var ChannelDriver = class {
|
|
|
2239
2793
|
message_id: row.id
|
|
2240
2794
|
});
|
|
2241
2795
|
try {
|
|
2242
|
-
await this.markDone(row.conversation_id, row.id, sessionId);
|
|
2796
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId);
|
|
2243
2797
|
} catch (err) {
|
|
2244
2798
|
if (err instanceof ChannelAuthError) throw err;
|
|
2245
2799
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -2263,6 +2817,39 @@ var ChannelDriver = class {
|
|
|
2263
2817
|
this.dontRedispatch.delete(row.id);
|
|
2264
2818
|
return;
|
|
2265
2819
|
}
|
|
2820
|
+
if (state === "failed") {
|
|
2821
|
+
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
2822
|
+
this.log({
|
|
2823
|
+
level: "error",
|
|
2824
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
2825
|
+
conversation_id: row.conversation_id,
|
|
2826
|
+
message_id: row.id
|
|
2827
|
+
});
|
|
2828
|
+
try {
|
|
2829
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2832
|
+
if (err instanceof ChannelTerminalError) {
|
|
2833
|
+
this.doneUndeliverable.add(row.id);
|
|
2834
|
+
this.log({
|
|
2835
|
+
level: "error",
|
|
2836
|
+
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}`,
|
|
2837
|
+
conversation_id: row.conversation_id,
|
|
2838
|
+
message_id: row.id
|
|
2839
|
+
});
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
this.log({
|
|
2843
|
+
level: "error",
|
|
2844
|
+
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2845
|
+
conversation_id: row.conversation_id,
|
|
2846
|
+
message_id: row.id
|
|
2847
|
+
});
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
this.dontRedispatch.delete(row.id);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2266
2853
|
if (this.dontRedispatch.has(row.id)) {
|
|
2267
2854
|
this.log({
|
|
2268
2855
|
level: "info",
|
|
@@ -2272,7 +2859,27 @@ var ChannelDriver = class {
|
|
|
2272
2859
|
});
|
|
2273
2860
|
return;
|
|
2274
2861
|
}
|
|
2275
|
-
if (state === "running"
|
|
2862
|
+
if (state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
|
|
2863
|
+
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2864
|
+
if (descendantAlive === true) {
|
|
2865
|
+
this.log({
|
|
2866
|
+
level: "info",
|
|
2867
|
+
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)`,
|
|
2868
|
+
conversation_id: row.conversation_id,
|
|
2869
|
+
message_id: row.id
|
|
2870
|
+
});
|
|
2871
|
+
} else {
|
|
2872
|
+
this.log({
|
|
2873
|
+
level: "info",
|
|
2874
|
+
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)" : ""}`,
|
|
2875
|
+
conversation_id: row.conversation_id,
|
|
2876
|
+
message_id: row.id
|
|
2877
|
+
});
|
|
2878
|
+
await this.forceReadoptRun(sessionId, row);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
if ((state === "running" || state === "queued") && ocId) {
|
|
2276
2883
|
const conv = this.convForRow(sessionId, row);
|
|
2277
2884
|
const message = this.queuedMessageForRow(row);
|
|
2278
2885
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
@@ -2281,7 +2888,7 @@ var ChannelDriver = class {
|
|
|
2281
2888
|
this.ensureWatcherRunning(sessionId);
|
|
2282
2889
|
this.log({
|
|
2283
2890
|
level: "info",
|
|
2284
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (
|
|
2891
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2285
2892
|
conversation_id: row.conversation_id,
|
|
2286
2893
|
message_id: row.id
|
|
2287
2894
|
});
|
|
@@ -2290,23 +2897,45 @@ var ChannelDriver = class {
|
|
|
2290
2897
|
await this.forceReadoptRun(sessionId, row);
|
|
2291
2898
|
}
|
|
2292
2899
|
/**
|
|
2293
|
-
* Re-dispatch an orphaned (`unknown
|
|
2900
|
+
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
2901
|
+
*
|
|
2902
|
+
* #218/WI-5: the row's user message is absent (never kept, or a null stored id),
|
|
2903
|
+
* so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
|
|
2904
|
+
* read it back, and register the watcher under the assigned id so the reply
|
|
2905
|
+
* correlates server-side.
|
|
2294
2906
|
*
|
|
2295
|
-
*
|
|
2296
|
-
*
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2907
|
+
* ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
|
|
2908
|
+
* id). Without a guard, if this dispatches on tick N but the read-back+persist
|
|
2909
|
+
* hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
|
|
2910
|
+
* tick N+1 would dispatch AGAIN → duplicate user turns. The `awaitingReadopt`
|
|
2911
|
+
* latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
|
|
2912
|
+
* short-circuit while the row is latched; clear it on a successful dispatch (the
|
|
2913
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
2914
|
+
* re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
|
|
2915
|
+
* may retry exactly once more).
|
|
2303
2916
|
*
|
|
2304
|
-
* `evidentMessageId = row.id` addresses the SERVER row
|
|
2305
|
-
* `opencodeMessageId` is what the watcher polls. Deadline anchored to
|
|
2917
|
+
* `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
|
|
2306
2918
|
* `processed_at` (Invariant 1).
|
|
2307
2919
|
*/
|
|
2308
2920
|
async forceReadoptRun(sessionId, row) {
|
|
2309
|
-
|
|
2921
|
+
if (this.stopped) {
|
|
2922
|
+
this.log({
|
|
2923
|
+
level: "info",
|
|
2924
|
+
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`,
|
|
2925
|
+
conversation_id: row.conversation_id,
|
|
2926
|
+
message_id: row.id
|
|
2927
|
+
});
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (this.awaitingReadopt.has(row.id)) {
|
|
2931
|
+
this.log({
|
|
2932
|
+
level: "info",
|
|
2933
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2934
|
+
conversation_id: row.conversation_id,
|
|
2935
|
+
message_id: row.id
|
|
2936
|
+
});
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2310
2939
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2311
2940
|
this.dontRedispatch.add(row.id);
|
|
2312
2941
|
this.log({
|
|
@@ -2323,13 +2952,19 @@ var ChannelDriver = class {
|
|
|
2323
2952
|
};
|
|
2324
2953
|
this.log({
|
|
2325
2954
|
level: "info",
|
|
2326
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching
|
|
2955
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
|
|
2327
2956
|
conversation_id: row.conversation_id,
|
|
2328
2957
|
message_id: row.id
|
|
2329
2958
|
});
|
|
2959
|
+
this.awaitingReadopt.add(row.id);
|
|
2960
|
+
let ocId;
|
|
2330
2961
|
try {
|
|
2331
|
-
await
|
|
2962
|
+
ocId = await this.dispatchLocked(
|
|
2963
|
+
sessionId,
|
|
2964
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
2965
|
+
);
|
|
2332
2966
|
} catch (err) {
|
|
2967
|
+
this.awaitingReadopt.delete(row.id);
|
|
2333
2968
|
if (err instanceof ChannelAuthError) throw err;
|
|
2334
2969
|
this.log({
|
|
2335
2970
|
level: "error",
|
|
@@ -2339,11 +2974,22 @@ var ChannelDriver = class {
|
|
|
2339
2974
|
});
|
|
2340
2975
|
return;
|
|
2341
2976
|
}
|
|
2977
|
+
if (ocId === null) {
|
|
2978
|
+
this.awaitingReadopt.delete(row.id);
|
|
2979
|
+
this.log({
|
|
2980
|
+
level: "error",
|
|
2981
|
+
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`,
|
|
2982
|
+
conversation_id: row.conversation_id,
|
|
2983
|
+
message_id: row.id
|
|
2984
|
+
});
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2342
2987
|
const conv = this.convForRow(sessionId, row);
|
|
2343
2988
|
const message = this.queuedMessageForRow(row);
|
|
2344
2989
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
2345
2990
|
this.dispatched.add(row.id);
|
|
2346
2991
|
this.readopted.add(row.id);
|
|
2992
|
+
this.awaitingReadopt.delete(row.id);
|
|
2347
2993
|
this.ensureWatcherRunning(sessionId);
|
|
2348
2994
|
}
|
|
2349
2995
|
/**
|
|
@@ -2436,21 +3082,41 @@ var ChannelDriver = class {
|
|
|
2436
3082
|
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2437
3083
|
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2438
3084
|
* oldest running message.
|
|
3085
|
+
*
|
|
3086
|
+
* Returns the set of in-flight Evident message ids that are paused awaiting a
|
|
3087
|
+
* human — an outstanding (still-open) question/permission is attributed to them.
|
|
3088
|
+
* `serviceInFlightMessage` uses this to keep an actively-running turn watched
|
|
3089
|
+
* forever (ADR-0047) while still bounding a turn merely blocked on a person who
|
|
3090
|
+
* may never answer. Attribution here covers ALL open interactions, not just
|
|
3091
|
+
* NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
|
|
3092
|
+
* even after it was already surfaced to the channel.
|
|
2439
3093
|
*/
|
|
2440
3094
|
async pollInteractions(sessionId, watcher, messages) {
|
|
3095
|
+
const openQuestions = /* @__PURE__ */ new Set();
|
|
3096
|
+
const openPermissions = /* @__PURE__ */ new Set();
|
|
3097
|
+
let questionsPolledOk = true;
|
|
3098
|
+
let permissionsPolledOk = true;
|
|
2441
3099
|
let questions = [];
|
|
2442
3100
|
try {
|
|
2443
3101
|
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2444
3102
|
if (res.ok) {
|
|
2445
3103
|
const body = await res.json();
|
|
2446
|
-
|
|
3104
|
+
if (Array.isArray(body)) {
|
|
3105
|
+
questions = body;
|
|
3106
|
+
} else {
|
|
3107
|
+
questionsPolledOk = false;
|
|
3108
|
+
}
|
|
3109
|
+
} else {
|
|
3110
|
+
questionsPolledOk = false;
|
|
2447
3111
|
}
|
|
2448
3112
|
} catch {
|
|
3113
|
+
questionsPolledOk = false;
|
|
2449
3114
|
}
|
|
2450
3115
|
for (const q of questions) {
|
|
2451
|
-
if (q.sessionID
|
|
2452
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
3116
|
+
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2453
3117
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
3118
|
+
if (paused) openQuestions.add(paused.evidentMessageId);
|
|
3119
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2454
3120
|
const reported = await this.reportInteraction(
|
|
2455
3121
|
watcher.conv.id,
|
|
2456
3122
|
"question",
|
|
@@ -2464,14 +3130,22 @@ var ChannelDriver = class {
|
|
|
2464
3130
|
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2465
3131
|
if (res.ok) {
|
|
2466
3132
|
const body = await res.json();
|
|
2467
|
-
|
|
3133
|
+
if (Array.isArray(body)) {
|
|
3134
|
+
permissions = body;
|
|
3135
|
+
} else {
|
|
3136
|
+
permissionsPolledOk = false;
|
|
3137
|
+
}
|
|
3138
|
+
} else {
|
|
3139
|
+
permissionsPolledOk = false;
|
|
2468
3140
|
}
|
|
2469
3141
|
} catch {
|
|
3142
|
+
permissionsPolledOk = false;
|
|
2470
3143
|
}
|
|
2471
3144
|
for (const p of permissions) {
|
|
2472
|
-
if (p.sessionID
|
|
2473
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
3145
|
+
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2474
3146
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
3147
|
+
if (paused) openPermissions.add(paused.evidentMessageId);
|
|
3148
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2475
3149
|
const reported = await this.reportInteraction(
|
|
2476
3150
|
watcher.conv.id,
|
|
2477
3151
|
"permission",
|
|
@@ -2480,6 +3154,109 @@ var ChannelDriver = class {
|
|
|
2480
3154
|
);
|
|
2481
3155
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2482
3156
|
}
|
|
3157
|
+
return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
3161
|
+
* i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
|
|
3162
|
+
* watched root. Sub-agents spawned via the `task` tool run in child sessions,
|
|
3163
|
+
* so their questions/permissions live under a different `sessionID` that must
|
|
3164
|
+
* still be attributed to the root conversation the watcher owns.
|
|
3165
|
+
*
|
|
3166
|
+
* Parents are cached in `sessionParents` so we walk each session at most once;
|
|
3167
|
+
* a bounded depth cap guards against a cycle or a pathological chain, and any
|
|
3168
|
+
* fetch failure is treated as "not a descendant" (best-effort — the interaction
|
|
3169
|
+
* simply isn't surfaced this tick and is retried next tick once resolvable).
|
|
3170
|
+
*/
|
|
3171
|
+
async sessionBelongsTo(sessionId, rootSessionId) {
|
|
3172
|
+
let current = sessionId;
|
|
3173
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
3174
|
+
if (current === rootSessionId) return true;
|
|
3175
|
+
const parent = await this.resolveSessionParent(current);
|
|
3176
|
+
if (parent === null || parent === void 0) return false;
|
|
3177
|
+
current = parent;
|
|
3178
|
+
}
|
|
3179
|
+
return false;
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
3183
|
+
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
3184
|
+
* unreachable / the session can't be read (so the caller stops walking without
|
|
3185
|
+
* caching a wrong answer — the next tick retries).
|
|
3186
|
+
*/
|
|
3187
|
+
async resolveSessionParent(sessionId) {
|
|
3188
|
+
const cached = this.sessionParents.get(sessionId);
|
|
3189
|
+
if (cached !== void 0) return cached;
|
|
3190
|
+
let parent = void 0;
|
|
3191
|
+
try {
|
|
3192
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3193
|
+
if (res.ok) {
|
|
3194
|
+
const body = await res.json();
|
|
3195
|
+
parent = body && typeof body.parentID === "string" ? body.parentID : null;
|
|
3196
|
+
}
|
|
3197
|
+
} catch {
|
|
3198
|
+
parent = void 0;
|
|
3199
|
+
}
|
|
3200
|
+
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3201
|
+
return parent;
|
|
3202
|
+
}
|
|
3203
|
+
/**
|
|
3204
|
+
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3205
|
+
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
3206
|
+
*
|
|
3207
|
+
* The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
|
|
3208
|
+
* runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
|
|
3209
|
+
* completed `finish: "tool-calls"` root reply encountered during re-adoption is
|
|
3210
|
+
* idle by OpenCode's own definition and is re-dispatched. This method exists only
|
|
3211
|
+
* so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
|
|
3212
|
+
* provably in flight at the exact moment of recovery.
|
|
3213
|
+
*
|
|
3214
|
+
* "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
|
|
3215
|
+
* ACTIVELY generating — its LAST message is an assistant still mid-generation
|
|
3216
|
+
* (`completed == null`, via `isSessionActivelyGenerating`). An
|
|
3217
|
+
* INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
|
|
3218
|
+
* completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
|
|
3219
|
+
* is generating once the runner is gone), so it does NOT veto. (This is
|
|
3220
|
+
* deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
|
|
3221
|
+
* shapes and would falsely veto — re-hanging the very turn this path recovers.)
|
|
3222
|
+
*
|
|
3223
|
+
* Return contract (encoded so WI-3 need not re-derive it):
|
|
3224
|
+
* - `true` → a descendant is provably, actively generating (veto re-dispatch).
|
|
3225
|
+
* - `false` → descendants exist but none is actively generating (the restart
|
|
3226
|
+
* case), OR no descendant is found at all.
|
|
3227
|
+
* - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
|
|
3228
|
+
*
|
|
3229
|
+
* ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
|
|
3230
|
+
* as `false` and does NOT veto — a restart guarantees no live runner, so an
|
|
3231
|
+
* indeterminate cross-check almost always means "couldn't reach a child that no
|
|
3232
|
+
* longer exists". The inversion lives in the caller; this method just reports
|
|
3233
|
+
* true/false/null faithfully.
|
|
3234
|
+
*
|
|
3235
|
+
* VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
|
|
3236
|
+
* (already proven by the existing child-session interaction tests, via
|
|
3237
|
+
* `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
|
|
3238
|
+
* terminal state. We do NOT depend on any session-level `busy`/`idle` field —
|
|
3239
|
+
* there is none on `GET /session/:id`; OpenCode's busy state is in-memory
|
|
3240
|
+
* `SessionStatus` only.
|
|
3241
|
+
*/
|
|
3242
|
+
async isAnyDescendantSessionAlive(rootSessionId) {
|
|
3243
|
+
const sessions = await listSessions(this.port);
|
|
3244
|
+
if (!sessions) {
|
|
3245
|
+
this.log({
|
|
3246
|
+
level: "error",
|
|
3247
|
+
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3248
|
+
});
|
|
3249
|
+
return null;
|
|
3250
|
+
}
|
|
3251
|
+
for (const candidate of sessions) {
|
|
3252
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
3253
|
+
if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
|
|
3254
|
+
const childMsgs = await getSessionMessages(this.port, candidate.id);
|
|
3255
|
+
if (isSessionActivelyGenerating(childMsgs)) {
|
|
3256
|
+
return true;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
return false;
|
|
2483
3260
|
}
|
|
2484
3261
|
/**
|
|
2485
3262
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
@@ -2613,13 +3390,17 @@ var ChannelDriver = class {
|
|
|
2613
3390
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2614
3391
|
* retry vehicle for the swap-to-running.
|
|
2615
3392
|
*/
|
|
2616
|
-
async markProcessing(conversationId, messageId, sessionId) {
|
|
3393
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
2617
3394
|
const res = await this.fetchImpl(
|
|
2618
3395
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2619
3396
|
{
|
|
2620
3397
|
method: "PATCH",
|
|
2621
3398
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2622
|
-
body: JSON.stringify({
|
|
3399
|
+
body: JSON.stringify({
|
|
3400
|
+
status: "processing",
|
|
3401
|
+
opencode_session_id: sessionId,
|
|
3402
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3403
|
+
})
|
|
2623
3404
|
}
|
|
2624
3405
|
);
|
|
2625
3406
|
this.assertAuth(res, "marking message as processing");
|
|
@@ -2657,13 +3438,17 @@ var ChannelDriver = class {
|
|
|
2657
3438
|
* watcher retries next tick within the
|
|
2658
3439
|
* deadline, Finding 4).
|
|
2659
3440
|
*/
|
|
2660
|
-
async markDone(conversationId, messageId, sessionId) {
|
|
3441
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
2661
3442
|
const res = await this.fetchImpl(
|
|
2662
3443
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2663
3444
|
{
|
|
2664
3445
|
method: "PATCH",
|
|
2665
3446
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2666
|
-
body: JSON.stringify({
|
|
3447
|
+
body: JSON.stringify({
|
|
3448
|
+
status: "done",
|
|
3449
|
+
opencode_session_id: sessionId,
|
|
3450
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3451
|
+
})
|
|
2667
3452
|
}
|
|
2668
3453
|
);
|
|
2669
3454
|
this.assertAuth(res, "marking message as done");
|
|
@@ -2673,7 +3458,17 @@ var ChannelDriver = class {
|
|
|
2673
3458
|
}
|
|
2674
3459
|
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
2675
3460
|
}
|
|
2676
|
-
|
|
3461
|
+
/**
|
|
3462
|
+
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3463
|
+
* when provided (issue #182): a bare `markFailed(conv, msg)` sends
|
|
3464
|
+
* `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
|
|
3465
|
+
* OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
|
|
3466
|
+
* failure reason reaches the channel.
|
|
3467
|
+
*/
|
|
3468
|
+
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
3469
|
+
const body = { status: "failed" };
|
|
3470
|
+
if (sessionId !== void 0) body.opencode_session_id = sessionId;
|
|
3471
|
+
if (error2 !== void 0) body.error = error2;
|
|
2677
3472
|
await this.callWithRetry(
|
|
2678
3473
|
"marking message as failed",
|
|
2679
3474
|
() => this.fetchImpl(
|
|
@@ -2681,7 +3476,7 @@ var ChannelDriver = class {
|
|
|
2681
3476
|
{
|
|
2682
3477
|
method: "PATCH",
|
|
2683
3478
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2684
|
-
body: JSON.stringify(
|
|
3479
|
+
body: JSON.stringify(body)
|
|
2685
3480
|
}
|
|
2686
3481
|
)
|
|
2687
3482
|
);
|
|
@@ -2694,6 +3489,12 @@ var ChannelDriver = class {
|
|
|
2694
3489
|
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
2695
3490
|
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
2696
3491
|
* context (no silent catch, per development-workflow).
|
|
3492
|
+
*
|
|
3493
|
+
* Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
|
|
3494
|
+
* telemetry), but the `paused` liveness-clear uses it to know whether to
|
|
3495
|
+
* RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
|
|
3496
|
+
* stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
|
|
3497
|
+
* leaves liveness").
|
|
2697
3498
|
*/
|
|
2698
3499
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
2699
3500
|
try {
|
|
@@ -2712,7 +3513,9 @@ var ChannelDriver = class {
|
|
|
2712
3513
|
conversation_id: conversationId,
|
|
2713
3514
|
message_id: messageId
|
|
2714
3515
|
});
|
|
3516
|
+
return false;
|
|
2715
3517
|
}
|
|
3518
|
+
return true;
|
|
2716
3519
|
} catch (err) {
|
|
2717
3520
|
this.log({
|
|
2718
3521
|
level: "error",
|
|
@@ -2720,6 +3523,7 @@ var ChannelDriver = class {
|
|
|
2720
3523
|
conversation_id: conversationId,
|
|
2721
3524
|
message_id: messageId
|
|
2722
3525
|
});
|
|
3526
|
+
return false;
|
|
2723
3527
|
}
|
|
2724
3528
|
}
|
|
2725
3529
|
async persistSession(conversationId, sessionId) {
|
|
@@ -2992,6 +3796,25 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
2992
3796
|
return { error: `Failed to resolve agent from key: ${message}` };
|
|
2993
3797
|
}
|
|
2994
3798
|
}
|
|
3799
|
+
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3800
|
+
const apiUrl = getApiUrlConfig();
|
|
3801
|
+
try {
|
|
3802
|
+
const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
|
|
3803
|
+
method: "POST",
|
|
3804
|
+
headers: { Authorization: authHeader }
|
|
3805
|
+
});
|
|
3806
|
+
if (!response.ok) {
|
|
3807
|
+
const serverMessage = await readErrorMessage(response);
|
|
3808
|
+
return {
|
|
3809
|
+
ok: false,
|
|
3810
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
return { ok: true };
|
|
3814
|
+
} catch (error2) {
|
|
3815
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
2995
3818
|
async function getAgentInfo(agentId, authHeader) {
|
|
2996
3819
|
const apiUrl = getApiUrlConfig();
|
|
2997
3820
|
try {
|
|
@@ -3037,6 +3860,8 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3037
3860
|
// src/commands/run.ts
|
|
3038
3861
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
3039
3862
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3863
|
+
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3864
|
+
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3040
3865
|
function log2(state, message, isError = false) {
|
|
3041
3866
|
if (state.json) {
|
|
3042
3867
|
console.log(
|
|
@@ -3191,7 +4016,7 @@ async function driveChannels(state, driver) {
|
|
|
3191
4016
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
3192
4017
|
if (state.interactive) displayStatus(state);
|
|
3193
4018
|
}
|
|
3194
|
-
await new Promise((
|
|
4019
|
+
await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
|
|
3195
4020
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
3196
4021
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
3197
4022
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -3202,8 +4027,122 @@ async function driveChannels(state, driver) {
|
|
|
3202
4027
|
}
|
|
3203
4028
|
}
|
|
3204
4029
|
}
|
|
3205
|
-
|
|
4030
|
+
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
4031
|
+
async function runSweep(state, driver, config2) {
|
|
4032
|
+
const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
|
|
4033
|
+
try {
|
|
4034
|
+
const sessions = await listSessions(state.port);
|
|
4035
|
+
if (sessions === null) {
|
|
4036
|
+
logActivity(state, {
|
|
4037
|
+
type: "info",
|
|
4038
|
+
message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
|
|
4039
|
+
});
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
const toDelete = selectSessionsToDelete(
|
|
4043
|
+
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
4044
|
+
{
|
|
4045
|
+
maxAgeMs: config2.maxAgeMs,
|
|
4046
|
+
maxCount: config2.maxCount,
|
|
4047
|
+
nowMs: Date.now(),
|
|
4048
|
+
protectedIds: driver.protectedSessionIds()
|
|
4049
|
+
}
|
|
4050
|
+
);
|
|
4051
|
+
const protectedNow = driver.protectedSessionIds();
|
|
4052
|
+
let deleted = 0;
|
|
4053
|
+
let failed = 0;
|
|
4054
|
+
let skippedNewlyActive = 0;
|
|
4055
|
+
for (const id of toDelete) {
|
|
4056
|
+
if (protectedNow.has(id)) {
|
|
4057
|
+
skippedNewlyActive++;
|
|
4058
|
+
logActivity(state, {
|
|
4059
|
+
type: "info",
|
|
4060
|
+
message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
|
|
4061
|
+
});
|
|
4062
|
+
continue;
|
|
4063
|
+
}
|
|
4064
|
+
if (await deleteSession(state.port, id)) deleted++;
|
|
4065
|
+
else failed++;
|
|
4066
|
+
}
|
|
4067
|
+
const failedNote = failed > 0 ? `, failed ${failed}` : "";
|
|
4068
|
+
const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
|
|
4069
|
+
logActivity(state, {
|
|
4070
|
+
type: "info",
|
|
4071
|
+
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
4072
|
+
});
|
|
4073
|
+
} catch (error2) {
|
|
4074
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4075
|
+
logActivity(state, {
|
|
4076
|
+
type: "error",
|
|
4077
|
+
error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
|
|
4078
|
+
});
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
function scheduleSessionCleanup(state, driver, options) {
|
|
4082
|
+
const config2 = resolveSessionCleanupConfig(
|
|
4083
|
+
{
|
|
4084
|
+
maxAge: options.sessionCleanupMaxAge,
|
|
4085
|
+
maxCount: options.sessionCleanupMaxCount,
|
|
4086
|
+
interval: options.sessionCleanupInterval
|
|
4087
|
+
},
|
|
4088
|
+
process.env
|
|
4089
|
+
);
|
|
4090
|
+
for (const warning2 of config2.warnings) {
|
|
4091
|
+
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4092
|
+
}
|
|
4093
|
+
if (!config2.enabled) return;
|
|
4094
|
+
logActivity(state, {
|
|
4095
|
+
type: "info",
|
|
4096
|
+
message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
|
|
4097
|
+
});
|
|
4098
|
+
const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
|
|
4099
|
+
const firstSweep = setTimeout(
|
|
4100
|
+
() => void runSweep(state, driver, config2),
|
|
4101
|
+
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
4102
|
+
);
|
|
4103
|
+
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
4104
|
+
}
|
|
4105
|
+
async function notifyOffline(state) {
|
|
4106
|
+
if (!state.agentId || !state.authHeader) return;
|
|
4107
|
+
if (!state.connected) {
|
|
4108
|
+
log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4112
|
+
if (result.ok) {
|
|
4113
|
+
log2(state, "Notified Evident the agent is going offline");
|
|
4114
|
+
} else {
|
|
4115
|
+
logActivity(state, {
|
|
4116
|
+
type: "error",
|
|
4117
|
+
error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
|
|
4118
|
+
});
|
|
4119
|
+
if (state.interactive) displayStatus(state);
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
async function cleanup(state, opts = {}) {
|
|
3206
4123
|
state.running = false;
|
|
4124
|
+
for (const timer of state.sessionCleanupTimers) {
|
|
4125
|
+
clearInterval(timer);
|
|
4126
|
+
clearTimeout(timer);
|
|
4127
|
+
}
|
|
4128
|
+
state.sessionCleanupTimers = [];
|
|
4129
|
+
if (opts.graceful && state.channelDriver) {
|
|
4130
|
+
state.channelDriver.stop();
|
|
4131
|
+
log2(state, "Draining in-flight channel work before shutdown...");
|
|
4132
|
+
if (state.interactive) {
|
|
4133
|
+
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
4134
|
+
displayStatus(state);
|
|
4135
|
+
}
|
|
4136
|
+
const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
|
|
4137
|
+
if (!settled) {
|
|
4138
|
+
logActivity(state, {
|
|
4139
|
+
type: "info",
|
|
4140
|
+
message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
|
|
4141
|
+
});
|
|
4142
|
+
if (state.interactive) displayStatus(state);
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
await notifyOffline(state);
|
|
3207
4146
|
if (state.connection) {
|
|
3208
4147
|
state.connection.close();
|
|
3209
4148
|
state.connection = null;
|
|
@@ -3234,10 +4173,13 @@ async function run(options) {
|
|
|
3234
4173
|
opencodeVersion: null,
|
|
3235
4174
|
opencodeProcess: null,
|
|
3236
4175
|
connection: null,
|
|
4176
|
+
channelDriver: null,
|
|
3237
4177
|
running: true,
|
|
4178
|
+
shuttingDown: false,
|
|
3238
4179
|
activityLog: [],
|
|
3239
4180
|
messageCount: 0,
|
|
3240
4181
|
lastProxiedActivityAt: null,
|
|
4182
|
+
sessionCleanupTimers: [],
|
|
3241
4183
|
authHeader: ""
|
|
3242
4184
|
};
|
|
3243
4185
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
@@ -3248,13 +4190,15 @@ async function run(options) {
|
|
|
3248
4190
|
);
|
|
3249
4191
|
}
|
|
3250
4192
|
const handleSignal = async () => {
|
|
4193
|
+
if (state.shuttingDown) return;
|
|
4194
|
+
state.shuttingDown = true;
|
|
3251
4195
|
if (state.interactive) {
|
|
3252
4196
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
3253
4197
|
displayStatus(state);
|
|
3254
4198
|
} else {
|
|
3255
4199
|
log2(state, "Shutting down...");
|
|
3256
4200
|
}
|
|
3257
|
-
await cleanup(state);
|
|
4201
|
+
await cleanup(state, { graceful: true });
|
|
3258
4202
|
await shutdownTelemetry();
|
|
3259
4203
|
process.exit(0);
|
|
3260
4204
|
};
|
|
@@ -3374,12 +4318,14 @@ async function run(options) {
|
|
|
3374
4318
|
apiUrl: getApiUrlConfig(),
|
|
3375
4319
|
getAuthHeader: () => state.authHeader,
|
|
3376
4320
|
conversationFilter: state.conversationFilter,
|
|
4321
|
+
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
3377
4322
|
log: (entry) => logActivity(state, {
|
|
3378
4323
|
type: entry.level === "error" ? "error" : "info",
|
|
3379
4324
|
message: entry.message,
|
|
3380
4325
|
error: entry.level === "error" ? entry.message : void 0
|
|
3381
4326
|
})
|
|
3382
4327
|
});
|
|
4328
|
+
state.channelDriver = channelDriver;
|
|
3383
4329
|
const connection = new RunnerConnection({
|
|
3384
4330
|
agentId: state.agentId,
|
|
3385
4331
|
getAuthHeader: () => state.authHeader,
|
|
@@ -3393,7 +4339,11 @@ async function run(options) {
|
|
|
3393
4339
|
type: "info",
|
|
3394
4340
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
3395
4341
|
});
|
|
3396
|
-
emitAgentConnected(state.agentId, {
|
|
4342
|
+
emitAgentConnected(state.agentId, {
|
|
4343
|
+
port: state.port,
|
|
4344
|
+
cli_version: getCliVersion(),
|
|
4345
|
+
opencode_version: state.opencodeVersion
|
|
4346
|
+
});
|
|
3397
4347
|
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
3398
4348
|
if (state.interactive) displayStatus(state);
|
|
3399
4349
|
channelDriver.drainPending().then((processed) => {
|
|
@@ -3472,10 +4422,12 @@ async function run(options) {
|
|
|
3472
4422
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
3473
4423
|
throw error2;
|
|
3474
4424
|
}
|
|
4425
|
+
scheduleSessionCleanup(state, channelDriver, options);
|
|
3475
4426
|
if (!interactive || state.json) {
|
|
3476
4427
|
log2(state, "Driving channel messages...");
|
|
3477
4428
|
}
|
|
3478
4429
|
await driveChannels(state, channelDriver);
|
|
4430
|
+
if (state.shuttingDown) return;
|
|
3479
4431
|
await cleanup(state);
|
|
3480
4432
|
if (state.json) {
|
|
3481
4433
|
console.log(
|
|
@@ -3490,6 +4442,7 @@ async function run(options) {
|
|
|
3490
4442
|
await shutdownTelemetry();
|
|
3491
4443
|
process.exit(0);
|
|
3492
4444
|
} catch (error2) {
|
|
4445
|
+
if (state.shuttingDown) return;
|
|
3493
4446
|
await cleanup(state);
|
|
3494
4447
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
3495
4448
|
if (state.json) {
|
|
@@ -3524,7 +4477,16 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
3524
4477
|
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);
|
|
3525
4478
|
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 }));
|
|
3526
4479
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
3527
|
-
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("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").
|
|
4480
|
+
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("-v, --verbose", "Show detailed request/response information").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(
|
|
4481
|
+
"--session-cleanup-max-age <duration>",
|
|
4482
|
+
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4483
|
+
).option(
|
|
4484
|
+
"--session-cleanup-max-count <n>",
|
|
4485
|
+
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
4486
|
+
).option(
|
|
4487
|
+
"--session-cleanup-interval <duration>",
|
|
4488
|
+
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
4489
|
+
).action(
|
|
3528
4490
|
(options) => {
|
|
3529
4491
|
run({
|
|
3530
4492
|
agent: options.agent,
|
|
@@ -3532,7 +4494,11 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
3532
4494
|
verbose: options.verbose,
|
|
3533
4495
|
conversation: options.conversation,
|
|
3534
4496
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
3535
|
-
json: options.json
|
|
4497
|
+
json: options.json,
|
|
4498
|
+
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4499
|
+
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4500
|
+
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4501
|
+
sessionCleanupInterval: options.sessionCleanupInterval
|
|
3536
4502
|
});
|
|
3537
4503
|
}
|
|
3538
4504
|
);
|