@evident-ai/cli 3.0.1-dev.0c07f2c → 3.0.1-dev.0d0721c
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 +860 -132
- 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,66 @@ 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 sessionLastActivityMs(session) {
|
|
1082
|
+
const candidates = [
|
|
1083
|
+
session.time?.updated,
|
|
1084
|
+
session.time?.created,
|
|
1085
|
+
session.time_updated,
|
|
1086
|
+
session.time_created,
|
|
1087
|
+
session.updated,
|
|
1088
|
+
session.created
|
|
1089
|
+
];
|
|
1090
|
+
for (const c of candidates) {
|
|
1091
|
+
if (typeof c === "number" && Number.isFinite(c)) return c;
|
|
1092
|
+
}
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
async function listSessions(port) {
|
|
1096
|
+
try {
|
|
1097
|
+
const res = await fetch(`${opencodeBase(port)}/session`);
|
|
1098
|
+
if (!res.ok) return null;
|
|
1099
|
+
const body = await res.json();
|
|
1100
|
+
return Array.isArray(body) ? body : null;
|
|
1101
|
+
} catch {
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async function deleteSession(port, id) {
|
|
1106
|
+
try {
|
|
1107
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
|
|
1108
|
+
return res.status >= 200 && res.status < 300;
|
|
1109
|
+
} catch {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
async function sessionExists(port, id) {
|
|
1114
|
+
try {
|
|
1115
|
+
const res = await fetch(`${opencodeBase(port)}/session/${id}`);
|
|
1116
|
+
if (res.status >= 200 && res.status < 300) return true;
|
|
1117
|
+
if (res.status === 404) return false;
|
|
1118
|
+
return null;
|
|
1119
|
+
} catch {
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1066
1123
|
async function createOpenCodeSession(port, directory) {
|
|
1067
1124
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1068
1125
|
if (directory && directory.trim()) {
|
|
@@ -1080,9 +1137,16 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1080
1137
|
const data = await response.json();
|
|
1081
1138
|
return data.id;
|
|
1082
1139
|
}
|
|
1083
|
-
|
|
1140
|
+
function messageText(m) {
|
|
1141
|
+
if (!m || !Array.isArray(m.parts)) return "";
|
|
1142
|
+
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1143
|
+
}
|
|
1144
|
+
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1145
|
+
const before = await getSessionMessages(port, sessionId);
|
|
1146
|
+
const knownUserIds = new Set(
|
|
1147
|
+
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1148
|
+
);
|
|
1084
1149
|
const body = {
|
|
1085
|
-
messageID: messageId,
|
|
1086
1150
|
parts: [{ type: "text", text: content }]
|
|
1087
1151
|
};
|
|
1088
1152
|
if (options?.agent) {
|
|
@@ -1106,6 +1170,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
|
1106
1170
|
const text = await res.text().catch(() => "");
|
|
1107
1171
|
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1108
1172
|
}
|
|
1173
|
+
const READ_BACK_ATTEMPTS = 5;
|
|
1174
|
+
const READ_BACK_DELAY_MS = 150;
|
|
1175
|
+
for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
|
|
1176
|
+
const after = await getSessionMessages(port, sessionId);
|
|
1177
|
+
if (after) {
|
|
1178
|
+
let best = null;
|
|
1179
|
+
for (const m of after) {
|
|
1180
|
+
if (roleOf(m) !== "user") continue;
|
|
1181
|
+
const id = idOf(m);
|
|
1182
|
+
if (typeof id !== "string" || knownUserIds.has(id)) continue;
|
|
1183
|
+
if (messageText(m) !== content) continue;
|
|
1184
|
+
const created = createdOf(m) ?? 0;
|
|
1185
|
+
if (best === null || created > best.created) {
|
|
1186
|
+
best = { id, created };
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (best) return best.id;
|
|
1190
|
+
}
|
|
1191
|
+
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1192
|
+
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return null;
|
|
1109
1196
|
}
|
|
1110
1197
|
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1111
1198
|
if (!messages || messages.length === 0) return null;
|
|
@@ -1122,19 +1209,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
|
|
|
1122
1209
|
}
|
|
1123
1210
|
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1124
1211
|
if (!messages || messages.length === 0) return null;
|
|
1212
|
+
let lastCorrelated = null;
|
|
1213
|
+
let lastNonErrored = null;
|
|
1125
1214
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1126
1215
|
const m = messages[i];
|
|
1127
|
-
if (roleOf(m)
|
|
1216
|
+
if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
|
|
1217
|
+
if (lastCorrelated === null) lastCorrelated = m;
|
|
1218
|
+
if (errorOf(m) == null) {
|
|
1219
|
+
lastNonErrored = m;
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1128
1222
|
}
|
|
1223
|
+
if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
|
|
1129
1224
|
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1130
1225
|
if (userIndex === -1) return null;
|
|
1131
1226
|
let last = null;
|
|
1227
|
+
let lastOk = null;
|
|
1132
1228
|
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1133
1229
|
const role = roleOf(messages[i]);
|
|
1134
1230
|
if (role === "user") break;
|
|
1135
|
-
if (role === "assistant")
|
|
1231
|
+
if (role === "assistant") {
|
|
1232
|
+
last = messages[i];
|
|
1233
|
+
if (errorOf(messages[i]) == null) lastOk = messages[i];
|
|
1234
|
+
}
|
|
1136
1235
|
}
|
|
1137
|
-
return last;
|
|
1236
|
+
return lastOk ?? last;
|
|
1138
1237
|
}
|
|
1139
1238
|
function messageRunState(messages, userMessageId) {
|
|
1140
1239
|
if (!messages || messages.length === 0) return "unknown";
|
|
@@ -1144,7 +1243,21 @@ function messageRunState(messages, userMessageId) {
|
|
|
1144
1243
|
if (!reply) return "unknown";
|
|
1145
1244
|
}
|
|
1146
1245
|
if (!reply) return "queued";
|
|
1147
|
-
|
|
1246
|
+
if (isAssistantInFlight(reply)) return "running";
|
|
1247
|
+
return errorOf(reply) != null ? "failed" : "done";
|
|
1248
|
+
}
|
|
1249
|
+
function messageError(messages, userMessageId) {
|
|
1250
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1251
|
+
const error2 = errorOf(reply);
|
|
1252
|
+
if (error2 == null) return null;
|
|
1253
|
+
if (typeof error2 === "string") return error2;
|
|
1254
|
+
if (typeof error2 === "object") {
|
|
1255
|
+
const e = error2;
|
|
1256
|
+
const dataMessage = e.data?.message;
|
|
1257
|
+
if (typeof dataMessage === "string") return dataMessage;
|
|
1258
|
+
if (typeof e.message === "string") return e.message;
|
|
1259
|
+
}
|
|
1260
|
+
return "The agent run failed.";
|
|
1148
1261
|
}
|
|
1149
1262
|
function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
1150
1263
|
if (!messages || messages.length === 0) return false;
|
|
@@ -1152,8 +1265,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1152
1265
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1153
1266
|
);
|
|
1154
1267
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1268
|
+
|
|
1269
|
+
// src/lib/opencode/session-cleanup.ts
|
|
1270
|
+
var DURATION_UNIT_MS = {
|
|
1271
|
+
s: 1e3,
|
|
1272
|
+
m: 60 * 1e3,
|
|
1273
|
+
h: 60 * 60 * 1e3,
|
|
1274
|
+
d: 24 * 60 * 60 * 1e3
|
|
1275
|
+
};
|
|
1276
|
+
function parseDurationMs(input) {
|
|
1277
|
+
const trimmed = input.trim();
|
|
1278
|
+
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
|
1279
|
+
if (!match) {
|
|
1280
|
+
throw new Error(
|
|
1281
|
+
`Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
const value = Number(match[1]);
|
|
1285
|
+
if (value <= 0) {
|
|
1286
|
+
throw new Error(`Invalid duration "${input}": must be a positive value.`);
|
|
1287
|
+
}
|
|
1288
|
+
return value * DURATION_UNIT_MS[match[2]];
|
|
1289
|
+
}
|
|
1290
|
+
function selectSessionsToDelete(sessions, opts) {
|
|
1291
|
+
const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
|
|
1292
|
+
if (maxAgeMs === void 0 && maxCount === void 0) return [];
|
|
1293
|
+
const ageEligible = (s) => {
|
|
1294
|
+
if (maxAgeMs === void 0) return false;
|
|
1295
|
+
if (s.lastActivityMs === null) return true;
|
|
1296
|
+
return nowMs - s.lastActivityMs > maxAgeMs;
|
|
1297
|
+
};
|
|
1298
|
+
const countEligibleIds = /* @__PURE__ */ new Set();
|
|
1299
|
+
if (maxCount !== void 0) {
|
|
1300
|
+
const byActivityDesc = [...sessions].sort(
|
|
1301
|
+
(a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
|
|
1302
|
+
);
|
|
1303
|
+
for (const s of byActivityDesc.slice(maxCount)) {
|
|
1304
|
+
countEligibleIds.add(s.id);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const toDelete = [];
|
|
1308
|
+
for (const s of sessions) {
|
|
1309
|
+
if (protectedIds.has(s.id)) continue;
|
|
1310
|
+
if (ageEligible(s) || countEligibleIds.has(s.id)) {
|
|
1311
|
+
toDelete.push(s.id);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return toDelete;
|
|
1315
|
+
}
|
|
1316
|
+
var DEFAULT_INTERVAL = "1h";
|
|
1317
|
+
function resolve(flag, envValue, fallback) {
|
|
1318
|
+
return flag ?? envValue ?? fallback;
|
|
1319
|
+
}
|
|
1320
|
+
function parseMaxCount(input) {
|
|
1321
|
+
const trimmed = input.trim();
|
|
1322
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1323
|
+
throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
|
|
1324
|
+
}
|
|
1325
|
+
const value = Number(trimmed);
|
|
1326
|
+
if (value <= 0) {
|
|
1327
|
+
throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
|
|
1328
|
+
}
|
|
1329
|
+
return value;
|
|
1330
|
+
}
|
|
1331
|
+
function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
1332
|
+
const warnings = [];
|
|
1333
|
+
const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
|
|
1334
|
+
const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
|
|
1335
|
+
const intervalRaw = resolve(
|
|
1336
|
+
flags.interval,
|
|
1337
|
+
env.EVIDENT_SESSION_CLEANUP_INTERVAL,
|
|
1338
|
+
DEFAULT_INTERVAL
|
|
1339
|
+
);
|
|
1340
|
+
let maxAgeMs;
|
|
1341
|
+
if (maxAgeRaw !== void 0) {
|
|
1342
|
+
try {
|
|
1343
|
+
maxAgeMs = parseDurationMs(maxAgeRaw);
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
warnings.push(
|
|
1346
|
+
`Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
let maxCount;
|
|
1351
|
+
if (maxCountRaw !== void 0) {
|
|
1352
|
+
try {
|
|
1353
|
+
maxCount = parseMaxCount(maxCountRaw);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
warnings.push(
|
|
1356
|
+
`Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
let intervalMs;
|
|
1361
|
+
try {
|
|
1362
|
+
intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
warnings.push(
|
|
1365
|
+
`Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
|
|
1366
|
+
);
|
|
1367
|
+
intervalMs = parseDurationMs(DEFAULT_INTERVAL);
|
|
1368
|
+
}
|
|
1369
|
+
const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
|
|
1370
|
+
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
1157
1371
|
}
|
|
1158
1372
|
|
|
1159
1373
|
// src/lib/tunnel/connection.ts
|
|
@@ -1232,24 +1446,26 @@ var StreamForwarder = class {
|
|
|
1232
1446
|
this.send({ type: "res_end", sid });
|
|
1233
1447
|
return;
|
|
1234
1448
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1449
|
+
if (process.env.DEBUG) {
|
|
1450
|
+
log("debug", "agent_request", {
|
|
1451
|
+
correlation_id: correlationId,
|
|
1452
|
+
sid,
|
|
1453
|
+
method,
|
|
1454
|
+
path: stripQuery(path)
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1241
1457
|
const ac = new AbortController();
|
|
1242
1458
|
let bodyPromise;
|
|
1243
1459
|
let pushBody;
|
|
1244
1460
|
let endBody;
|
|
1245
1461
|
if (has_body) {
|
|
1246
1462
|
const chunks = [];
|
|
1247
|
-
bodyPromise = new Promise((
|
|
1463
|
+
bodyPromise = new Promise((resolve2) => {
|
|
1248
1464
|
pushBody = (buf) => {
|
|
1249
1465
|
chunks.push(buf);
|
|
1250
1466
|
};
|
|
1251
1467
|
endBody = () => {
|
|
1252
|
-
|
|
1468
|
+
resolve2(Buffer.concat(chunks));
|
|
1253
1469
|
};
|
|
1254
1470
|
});
|
|
1255
1471
|
}
|
|
@@ -1284,12 +1500,14 @@ var StreamForwarder = class {
|
|
|
1284
1500
|
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1285
1501
|
});
|
|
1286
1502
|
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1503
|
+
if (process.env.DEBUG) {
|
|
1504
|
+
log("debug", "agent_response", {
|
|
1505
|
+
correlation_id: correlationId,
|
|
1506
|
+
sid,
|
|
1507
|
+
status: upstream.status,
|
|
1508
|
+
duration_ms: Date.now() - startedAt
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1293
1511
|
this.callbacks.onHead?.(sid, upstream.status);
|
|
1294
1512
|
try {
|
|
1295
1513
|
if (upstream.body) {
|
|
@@ -1365,7 +1583,7 @@ function connectTunnel(options) {
|
|
|
1365
1583
|
} = options;
|
|
1366
1584
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1367
1585
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1368
|
-
return new Promise((
|
|
1586
|
+
return new Promise((resolve2, reject) => {
|
|
1369
1587
|
const ws = new WebSocket2(url, {
|
|
1370
1588
|
headers: {
|
|
1371
1589
|
Authorization: authHeader
|
|
@@ -1430,7 +1648,7 @@ function connectTunnel(options) {
|
|
|
1430
1648
|
clearTimeout(connectionTimeout);
|
|
1431
1649
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1432
1650
|
onConnected?.(connectedAgentId);
|
|
1433
|
-
|
|
1651
|
+
resolve2({
|
|
1434
1652
|
ws,
|
|
1435
1653
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1436
1654
|
});
|
|
@@ -1559,7 +1777,6 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1559
1777
|
};
|
|
1560
1778
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1561
1779
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1562
|
-
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1563
1780
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1564
1781
|
var ChannelAuthError = class extends Error {
|
|
1565
1782
|
constructor(message) {
|
|
@@ -1595,11 +1812,18 @@ var ChannelDriver = class {
|
|
|
1595
1812
|
sleep;
|
|
1596
1813
|
pausedPollIntervalMs;
|
|
1597
1814
|
pausedMaxWaitMs;
|
|
1598
|
-
dispatchConfirmMs;
|
|
1599
1815
|
stuckQueuedMs;
|
|
1600
1816
|
now;
|
|
1601
1817
|
/** Cache of conversationId → opencode sessionId. */
|
|
1602
1818
|
sessions = /* @__PURE__ */ new Map();
|
|
1819
|
+
/**
|
|
1820
|
+
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1821
|
+
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
1822
|
+
* "the one new user row" — which is only unambiguous if no OTHER dispatch into
|
|
1823
|
+
* the SAME session interleaves its snapshot→POST→read-back. This map chains each
|
|
1824
|
+
* session's dispatches so they run serially; distinct sessions stay concurrent.
|
|
1825
|
+
*/
|
|
1826
|
+
sessionDispatchLocks = /* @__PURE__ */ new Map();
|
|
1603
1827
|
/**
|
|
1604
1828
|
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1605
1829
|
* session: one polling loop services all of that session's in-flight messages.
|
|
@@ -1650,6 +1874,20 @@ var ChannelDriver = class {
|
|
|
1650
1874
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1651
1875
|
*/
|
|
1652
1876
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
1877
|
+
/**
|
|
1878
|
+
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1879
|
+
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
1880
|
+
* is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
|
|
1881
|
+
* persist hasn't landed before tick N+1 re-reads the still-null
|
|
1882
|
+
* `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
|
|
1883
|
+
* A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
|
|
1884
|
+
* short-circuits while it is present, so a null-id row is re-dispatched AT MOST
|
|
1885
|
+
* ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
|
|
1886
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
1887
|
+
* re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
|
|
1888
|
+
* so the NEXT tick may retry exactly once more).
|
|
1889
|
+
*/
|
|
1890
|
+
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
1653
1891
|
/**
|
|
1654
1892
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1655
1893
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1657,8 +1895,33 @@ var ChannelDriver = class {
|
|
|
1657
1895
|
* not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
|
|
1658
1896
|
*/
|
|
1659
1897
|
opencodeDirectory = void 0;
|
|
1898
|
+
/**
|
|
1899
|
+
* Cache of opencode `sessionId → parentID` (its parent session, or `null` when
|
|
1900
|
+
* the session is a root with no parent). Sub-agents spawned via the `task` tool
|
|
1901
|
+
* run in CHILD sessions whose `parentID` chains up to the Evident-created
|
|
1902
|
+
* (watched) session; we resolve this once per session so a child-session
|
|
1903
|
+
* question/permission can be attributed to the watched session's subtree
|
|
1904
|
+
* (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
|
|
1905
|
+
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1906
|
+
*/
|
|
1907
|
+
sessionParents = /* @__PURE__ */ new Map();
|
|
1660
1908
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1661
1909
|
draining = false;
|
|
1910
|
+
/**
|
|
1911
|
+
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
1912
|
+
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
1913
|
+
* is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
|
|
1914
|
+
* drain that entered before `stop()` still registers its watcher).
|
|
1915
|
+
*/
|
|
1916
|
+
activeDrain = null;
|
|
1917
|
+
/**
|
|
1918
|
+
* Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
|
|
1919
|
+
* dispatches NEW work (it returns 0 immediately) — but the per-session watcher
|
|
1920
|
+
* loops already running keep going so in-flight turns can finish and deliver
|
|
1921
|
+
* their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
|
|
1922
|
+
* and stops opencode.
|
|
1923
|
+
*/
|
|
1924
|
+
stopped = false;
|
|
1662
1925
|
constructor(config2) {
|
|
1663
1926
|
this.agentId = config2.agentId;
|
|
1664
1927
|
this.port = config2.port;
|
|
@@ -1672,7 +1935,6 @@ var ChannelDriver = class {
|
|
|
1672
1935
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1673
1936
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1674
1937
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1675
|
-
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1676
1938
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1677
1939
|
this.now = config2.now ?? (() => Date.now());
|
|
1678
1940
|
}
|
|
@@ -1691,8 +1953,21 @@ var ChannelDriver = class {
|
|
|
1691
1953
|
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1692
1954
|
*/
|
|
1693
1955
|
async drainPending() {
|
|
1956
|
+
if (this.stopped) return 0;
|
|
1694
1957
|
if (this.draining) return 0;
|
|
1695
1958
|
this.draining = true;
|
|
1959
|
+
const run2 = this.runDrain();
|
|
1960
|
+
this.activeDrain = run2.then(
|
|
1961
|
+
() => {
|
|
1962
|
+
this.activeDrain = null;
|
|
1963
|
+
},
|
|
1964
|
+
() => {
|
|
1965
|
+
this.activeDrain = null;
|
|
1966
|
+
}
|
|
1967
|
+
);
|
|
1968
|
+
return run2;
|
|
1969
|
+
}
|
|
1970
|
+
async runDrain() {
|
|
1696
1971
|
let dispatched = 0;
|
|
1697
1972
|
try {
|
|
1698
1973
|
const conversations = await this.getPendingConversations();
|
|
@@ -1704,6 +1979,7 @@ var ChannelDriver = class {
|
|
|
1704
1979
|
});
|
|
1705
1980
|
}
|
|
1706
1981
|
for (const conv of conversations) {
|
|
1982
|
+
if (this.stopped) break;
|
|
1707
1983
|
dispatched += await this.processConversation(conv);
|
|
1708
1984
|
}
|
|
1709
1985
|
await this.readoptProcessing();
|
|
@@ -1724,6 +2000,73 @@ var ChannelDriver = class {
|
|
|
1724
2000
|
}
|
|
1725
2001
|
return false;
|
|
1726
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2005
|
+
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
2006
|
+
* `watchers` entry whose `inFlight` set is non-empty — the same predicate
|
|
2007
|
+
* `hasInFlightWatchers()` uses, lifted to return the ids.
|
|
2008
|
+
*
|
|
2009
|
+
* Deliberately does NOT include `this.sessions` (the permanent, never-pruned
|
|
2010
|
+
* conversation→session cache). Protecting every bound-but-idle session there
|
|
2011
|
+
* would shield nearly every session and defeat cleanup — AND it is unnecessary:
|
|
2012
|
+
* `ensureSession` is self-healing (it recreates a session whose id no longer
|
|
2013
|
+
* exists), so deleting an idle bound session is harmless — the conversation's
|
|
2014
|
+
* next turn transparently rebinds a fresh one. The only thing worth protecting
|
|
2015
|
+
* is a session with a turn ACTIVELY in flight right now: tearing that down
|
|
2016
|
+
* mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
|
|
2017
|
+
*/
|
|
2018
|
+
protectedSessionIds() {
|
|
2019
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2020
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
2021
|
+
if (watcher.inFlight.size > 0) ids.add(sessionId);
|
|
2022
|
+
}
|
|
2023
|
+
return ids;
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
|
|
2027
|
+
* this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
|
|
2028
|
+
* — but the watcher loops already tracking in-flight turns keep running, so a
|
|
2029
|
+
* turn that has finished (or is about to) still fires `markDone` and delivers
|
|
2030
|
+
* its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
|
|
2031
|
+
*/
|
|
2032
|
+
stop() {
|
|
2033
|
+
this.stopped = true;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
|
|
2037
|
+
* graceful shutdown, so a turn whose reply is ready — or completes within the
|
|
2038
|
+
* window — is delivered before the process exits, instead of being cut off and
|
|
2039
|
+
* left for the ADR-0046 restart-recovery path.
|
|
2040
|
+
*
|
|
2041
|
+
* Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
|
|
2042
|
+
* far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
|
|
2043
|
+
* window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
|
|
2044
|
+
* set empties OR the timeout elapses. Anything still in flight at the timeout is
|
|
2045
|
+
* safe to abandon — it stays `processing` server-side and is re-adopted on the
|
|
2046
|
+
* next runner start (ADR-0046).
|
|
2047
|
+
*
|
|
2048
|
+
* @returns true if all in-flight work settled within the window; false if the
|
|
2049
|
+
* timeout elapsed with work still in flight.
|
|
2050
|
+
*/
|
|
2051
|
+
async waitForInFlight(timeoutMs) {
|
|
2052
|
+
const deadline = this.now() + timeoutMs;
|
|
2053
|
+
const step = Math.min(this.pausedPollIntervalMs, 250);
|
|
2054
|
+
if (this.activeDrain) {
|
|
2055
|
+
let drainSettled = false;
|
|
2056
|
+
void this.activeDrain.then(() => {
|
|
2057
|
+
drainSettled = true;
|
|
2058
|
+
});
|
|
2059
|
+
while (!drainSettled) {
|
|
2060
|
+
if (this.now() >= deadline) return false;
|
|
2061
|
+
await this.sleep(step);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
while (this.hasInFlightWatchers()) {
|
|
2065
|
+
if (this.now() >= deadline) return false;
|
|
2066
|
+
await this.sleep(step);
|
|
2067
|
+
}
|
|
2068
|
+
return true;
|
|
2069
|
+
}
|
|
1727
2070
|
/**
|
|
1728
2071
|
* Await all outstanding per-session watchers (WI-3).
|
|
1729
2072
|
*
|
|
@@ -1760,15 +2103,16 @@ var ChannelDriver = class {
|
|
|
1760
2103
|
let dispatched = 0;
|
|
1761
2104
|
let skippedAlreadyDispatched = 0;
|
|
1762
2105
|
for (const message of messages) {
|
|
2106
|
+
if (this.stopped) break;
|
|
1763
2107
|
if (this.dispatched.has(message.id)) {
|
|
1764
2108
|
skippedAlreadyDispatched += 1;
|
|
1765
2109
|
continue;
|
|
1766
2110
|
}
|
|
1767
|
-
const opencodeMessageId = opencodeMessageIdFor2(message.id);
|
|
1768
2111
|
const options = {
|
|
1769
2112
|
agent: message.opencode_agent ?? void 0,
|
|
1770
2113
|
model: message.opencode_model ?? void 0
|
|
1771
2114
|
};
|
|
2115
|
+
let opencodeMessageId;
|
|
1772
2116
|
try {
|
|
1773
2117
|
this.log({
|
|
1774
2118
|
level: "info",
|
|
@@ -1776,10 +2120,23 @@ var ChannelDriver = class {
|
|
|
1776
2120
|
conversation_id: conv.id,
|
|
1777
2121
|
message_id: message.id
|
|
1778
2122
|
});
|
|
1779
|
-
await
|
|
2123
|
+
opencodeMessageId = await this.dispatchLocked(
|
|
2124
|
+
sessionId,
|
|
2125
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2126
|
+
);
|
|
1780
2127
|
} catch (err) {
|
|
1781
2128
|
if (err instanceof ChannelAuthError) throw err;
|
|
1782
2129
|
this.dispatched.delete(message.id);
|
|
2130
|
+
if (await sessionExists(this.port, sessionId) === false) {
|
|
2131
|
+
this.sessions.delete(conv.id);
|
|
2132
|
+
this.log({
|
|
2133
|
+
level: "info",
|
|
2134
|
+
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.`,
|
|
2135
|
+
conversation_id: conv.id,
|
|
2136
|
+
message_id: message.id
|
|
2137
|
+
});
|
|
2138
|
+
break;
|
|
2139
|
+
}
|
|
1783
2140
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1784
2141
|
});
|
|
1785
2142
|
this.log({
|
|
@@ -1790,6 +2147,15 @@ var ChannelDriver = class {
|
|
|
1790
2147
|
});
|
|
1791
2148
|
continue;
|
|
1792
2149
|
}
|
|
2150
|
+
if (opencodeMessageId === null) {
|
|
2151
|
+
this.log({
|
|
2152
|
+
level: "error",
|
|
2153
|
+
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`,
|
|
2154
|
+
conversation_id: conv.id,
|
|
2155
|
+
message_id: message.id
|
|
2156
|
+
});
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
1793
2159
|
this.dispatched.add(message.id);
|
|
1794
2160
|
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1795
2161
|
dispatched += 1;
|
|
@@ -1806,16 +2172,33 @@ var ChannelDriver = class {
|
|
|
1806
2172
|
return dispatched;
|
|
1807
2173
|
}
|
|
1808
2174
|
async ensureSession(conv) {
|
|
1809
|
-
const
|
|
1810
|
-
if (
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2175
|
+
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2176
|
+
if (bound) {
|
|
2177
|
+
const exists = await sessionExists(this.port, bound);
|
|
2178
|
+
if (exists === false) {
|
|
2179
|
+
this.log({
|
|
2180
|
+
level: "info",
|
|
2181
|
+
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.`,
|
|
2182
|
+
conversation_id: conv.id
|
|
2183
|
+
});
|
|
2184
|
+
this.sessions.delete(conv.id);
|
|
2185
|
+
return this.createAndBindSession(conv.id);
|
|
2186
|
+
}
|
|
2187
|
+
this.sessions.set(conv.id, bound);
|
|
2188
|
+
return bound;
|
|
1814
2189
|
}
|
|
2190
|
+
return this.createAndBindSession(conv.id);
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
2194
|
+
* best-effort persist it server-side. Shared by the first-ever bind and the
|
|
2195
|
+
* self-heal recreate path in `ensureSession`.
|
|
2196
|
+
*/
|
|
2197
|
+
async createAndBindSession(conversationId) {
|
|
1815
2198
|
const directory = await this.resolveOpenCodeDirectory();
|
|
1816
2199
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1817
|
-
this.sessions.set(
|
|
1818
|
-
await this.persistSession(
|
|
2200
|
+
this.sessions.set(conversationId, sessionId);
|
|
2201
|
+
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
1819
2202
|
});
|
|
1820
2203
|
return sessionId;
|
|
1821
2204
|
}
|
|
@@ -1838,6 +2221,25 @@ var ChannelDriver = class {
|
|
|
1838
2221
|
// -------------------------------------------------------------------------
|
|
1839
2222
|
// Per-session watcher (WI-3)
|
|
1840
2223
|
// -------------------------------------------------------------------------
|
|
2224
|
+
/**
|
|
2225
|
+
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2226
|
+
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
2227
|
+
* never interleave and mis-correlate their read-backs. Distinct sessions run
|
|
2228
|
+
* concurrently. The chained tail intentionally ignores the prior result/error
|
|
2229
|
+
* (each dispatch reports its own outcome to its caller).
|
|
2230
|
+
*/
|
|
2231
|
+
dispatchLocked(sessionId, fn) {
|
|
2232
|
+
const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
|
|
2233
|
+
const run2 = prior.then(fn, fn);
|
|
2234
|
+
this.sessionDispatchLocks.set(
|
|
2235
|
+
sessionId,
|
|
2236
|
+
run2.then(
|
|
2237
|
+
() => void 0,
|
|
2238
|
+
() => void 0
|
|
2239
|
+
)
|
|
2240
|
+
);
|
|
2241
|
+
return run2;
|
|
2242
|
+
}
|
|
1841
2243
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1842
2244
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1843
2245
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -1902,10 +2304,10 @@ var ChannelDriver = class {
|
|
|
1902
2304
|
started: true,
|
|
1903
2305
|
done: false,
|
|
1904
2306
|
// Not yet reported stuck-queued. The once-guard (`stuckReported`) applies,
|
|
1905
|
-
// AND the stuck-queued observer
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
// (
|
|
2307
|
+
// AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
|
|
2308
|
+
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2309
|
+
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2310
|
+
// (#210/#220 observability).
|
|
1909
2311
|
stuckReported: false
|
|
1910
2312
|
});
|
|
1911
2313
|
}
|
|
@@ -1993,10 +2395,15 @@ var ChannelDriver = class {
|
|
|
1993
2395
|
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
1994
2396
|
const conv = watcher.conv;
|
|
1995
2397
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1996
|
-
if ((state === "running" || state === "done") && !inFlight.started) {
|
|
2398
|
+
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
1997
2399
|
let claimed;
|
|
1998
2400
|
try {
|
|
1999
|
-
claimed = await this.markProcessing(
|
|
2401
|
+
claimed = await this.markProcessing(
|
|
2402
|
+
conv.id,
|
|
2403
|
+
inFlight.evidentMessageId,
|
|
2404
|
+
sessionId,
|
|
2405
|
+
inFlight.opencodeMessageId
|
|
2406
|
+
);
|
|
2000
2407
|
} catch (err) {
|
|
2001
2408
|
if (err instanceof ChannelAuthError) throw err;
|
|
2002
2409
|
this.log({
|
|
@@ -2026,7 +2433,12 @@ var ChannelDriver = class {
|
|
|
2026
2433
|
message_id: inFlight.evidentMessageId
|
|
2027
2434
|
});
|
|
2028
2435
|
try {
|
|
2029
|
-
await this.markDone(
|
|
2436
|
+
await this.markDone(
|
|
2437
|
+
conv.id,
|
|
2438
|
+
inFlight.evidentMessageId,
|
|
2439
|
+
sessionId,
|
|
2440
|
+
inFlight.opencodeMessageId
|
|
2441
|
+
);
|
|
2030
2442
|
} catch (err) {
|
|
2031
2443
|
if (err instanceof ChannelAuthError) throw err;
|
|
2032
2444
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -2062,12 +2474,55 @@ var ChannelDriver = class {
|
|
|
2062
2474
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2063
2475
|
return;
|
|
2064
2476
|
}
|
|
2065
|
-
if (state === "
|
|
2066
|
-
if (
|
|
2067
|
-
|
|
2477
|
+
if (state === "failed") {
|
|
2478
|
+
if (!inFlight.done) {
|
|
2479
|
+
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2480
|
+
this.log({
|
|
2481
|
+
level: "error",
|
|
2482
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
2483
|
+
conversation_id: conv.id,
|
|
2484
|
+
message_id: inFlight.evidentMessageId
|
|
2485
|
+
});
|
|
2486
|
+
try {
|
|
2487
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
2488
|
+
} catch (err) {
|
|
2489
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2490
|
+
if (err instanceof ChannelTerminalError) {
|
|
2491
|
+
this.log({
|
|
2492
|
+
level: "error",
|
|
2493
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2494
|
+
conversation_id: conv.id,
|
|
2495
|
+
message_id: inFlight.evidentMessageId
|
|
2496
|
+
});
|
|
2497
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
if (this.now() >= inFlight.deadline) {
|
|
2501
|
+
this.log({
|
|
2502
|
+
level: "error",
|
|
2503
|
+
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)}`,
|
|
2504
|
+
conversation_id: conv.id,
|
|
2505
|
+
message_id: inFlight.evidentMessageId
|
|
2506
|
+
});
|
|
2507
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
this.log({
|
|
2511
|
+
level: "error",
|
|
2512
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2513
|
+
conversation_id: conv.id,
|
|
2514
|
+
message_id: inFlight.evidentMessageId
|
|
2515
|
+
});
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
inFlight.done = true;
|
|
2068
2519
|
}
|
|
2520
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2521
|
+
return;
|
|
2069
2522
|
}
|
|
2070
|
-
|
|
2523
|
+
const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
|
|
2524
|
+
const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
|
|
2525
|
+
if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
|
|
2071
2526
|
inFlight.stuckReported = true;
|
|
2072
2527
|
void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
|
|
2073
2528
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
@@ -2080,41 +2535,11 @@ var ChannelDriver = class {
|
|
|
2080
2535
|
conversation_id: conv.id,
|
|
2081
2536
|
message_id: inFlight.evidentMessageId
|
|
2082
2537
|
});
|
|
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
|
|
2538
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2539
|
+
watched_for_ms: this.now() - inFlight.dispatchedAt
|
|
2115
2540
|
});
|
|
2541
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2116
2542
|
}
|
|
2117
|
-
inFlight.dispatchedAt = this.now();
|
|
2118
2543
|
}
|
|
2119
2544
|
// -------------------------------------------------------------------------
|
|
2120
2545
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
@@ -2203,10 +2628,17 @@ var ChannelDriver = class {
|
|
|
2203
2628
|
* Re-adopt ONE `processing` row against the tick's session message snapshot
|
|
2204
2629
|
* (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
|
|
2205
2630
|
*
|
|
2206
|
-
* Branches on `messageRunState(messages,
|
|
2631
|
+
* Branches on `messageRunState(messages, row.opencode_message_id)` — the
|
|
2632
|
+
* opencode-assigned user-message id persisted on the first `processing` PATCH
|
|
2633
|
+
* (#218). A row with a NULL stored id (dispatched but the read-back never landed
|
|
2634
|
+
* before the restart) has no id to correlate → treated as an orphan and
|
|
2635
|
+
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
2207
2636
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
2208
|
-
* - `
|
|
2209
|
-
*
|
|
2637
|
+
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
2638
|
+
* errored turn is reported failed on restart, NOT re-dispatched;
|
|
2639
|
+
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
2640
|
+
* tracking the stored id so the reply correlates by it;
|
|
2641
|
+
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
2210
2642
|
*
|
|
2211
2643
|
* Only `ChannelAuthError` propagates.
|
|
2212
2644
|
*/
|
|
@@ -2220,8 +2652,8 @@ var ChannelDriver = class {
|
|
|
2220
2652
|
});
|
|
2221
2653
|
return;
|
|
2222
2654
|
}
|
|
2223
|
-
const ocId =
|
|
2224
|
-
const state = messageRunState(messages, ocId);
|
|
2655
|
+
const ocId = row.opencode_message_id;
|
|
2656
|
+
const state = messageRunState(messages, ocId ?? "");
|
|
2225
2657
|
if (state === "done") {
|
|
2226
2658
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2227
2659
|
this.log({
|
|
@@ -2239,7 +2671,7 @@ var ChannelDriver = class {
|
|
|
2239
2671
|
message_id: row.id
|
|
2240
2672
|
});
|
|
2241
2673
|
try {
|
|
2242
|
-
await this.markDone(row.conversation_id, row.id, sessionId);
|
|
2674
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId);
|
|
2243
2675
|
} catch (err) {
|
|
2244
2676
|
if (err instanceof ChannelAuthError) throw err;
|
|
2245
2677
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -2263,6 +2695,39 @@ var ChannelDriver = class {
|
|
|
2263
2695
|
this.dontRedispatch.delete(row.id);
|
|
2264
2696
|
return;
|
|
2265
2697
|
}
|
|
2698
|
+
if (state === "failed") {
|
|
2699
|
+
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
2700
|
+
this.log({
|
|
2701
|
+
level: "error",
|
|
2702
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
2703
|
+
conversation_id: row.conversation_id,
|
|
2704
|
+
message_id: row.id
|
|
2705
|
+
});
|
|
2706
|
+
try {
|
|
2707
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
2708
|
+
} catch (err) {
|
|
2709
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2710
|
+
if (err instanceof ChannelTerminalError) {
|
|
2711
|
+
this.doneUndeliverable.add(row.id);
|
|
2712
|
+
this.log({
|
|
2713
|
+
level: "error",
|
|
2714
|
+
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}`,
|
|
2715
|
+
conversation_id: row.conversation_id,
|
|
2716
|
+
message_id: row.id
|
|
2717
|
+
});
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
this.log({
|
|
2721
|
+
level: "error",
|
|
2722
|
+
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2723
|
+
conversation_id: row.conversation_id,
|
|
2724
|
+
message_id: row.id
|
|
2725
|
+
});
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
this.dontRedispatch.delete(row.id);
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2266
2731
|
if (this.dontRedispatch.has(row.id)) {
|
|
2267
2732
|
this.log({
|
|
2268
2733
|
level: "info",
|
|
@@ -2272,7 +2737,7 @@ var ChannelDriver = class {
|
|
|
2272
2737
|
});
|
|
2273
2738
|
return;
|
|
2274
2739
|
}
|
|
2275
|
-
if (state === "running" || state === "queued") {
|
|
2740
|
+
if ((state === "running" || state === "queued") && ocId) {
|
|
2276
2741
|
const conv = this.convForRow(sessionId, row);
|
|
2277
2742
|
const message = this.queuedMessageForRow(row);
|
|
2278
2743
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
@@ -2281,7 +2746,7 @@ var ChannelDriver = class {
|
|
|
2281
2746
|
this.ensureWatcherRunning(sessionId);
|
|
2282
2747
|
this.log({
|
|
2283
2748
|
level: "info",
|
|
2284
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (
|
|
2749
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2285
2750
|
conversation_id: row.conversation_id,
|
|
2286
2751
|
message_id: row.id
|
|
2287
2752
|
});
|
|
@@ -2290,23 +2755,45 @@ var ChannelDriver = class {
|
|
|
2290
2755
|
await this.forceReadoptRun(sessionId, row);
|
|
2291
2756
|
}
|
|
2292
2757
|
/**
|
|
2293
|
-
* Re-dispatch an orphaned (`unknown
|
|
2758
|
+
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
2759
|
+
*
|
|
2760
|
+
* #218/WI-5: the row's user message is absent (never kept, or a null stored id),
|
|
2761
|
+
* so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
|
|
2762
|
+
* read it back, and register the watcher under the assigned id so the reply
|
|
2763
|
+
* correlates server-side.
|
|
2294
2764
|
*
|
|
2295
|
-
*
|
|
2296
|
-
*
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2765
|
+
* ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
|
|
2766
|
+
* id). Without a guard, if this dispatches on tick N but the read-back+persist
|
|
2767
|
+
* hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
|
|
2768
|
+
* tick N+1 would dispatch AGAIN → duplicate user turns. The `awaitingReadopt`
|
|
2769
|
+
* latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
|
|
2770
|
+
* short-circuit while the row is latched; clear it on a successful dispatch (the
|
|
2771
|
+
* row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
|
|
2772
|
+
* re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
|
|
2773
|
+
* may retry exactly once more).
|
|
2303
2774
|
*
|
|
2304
|
-
* `evidentMessageId = row.id` addresses the SERVER row
|
|
2305
|
-
* `opencodeMessageId` is what the watcher polls. Deadline anchored to
|
|
2775
|
+
* `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
|
|
2306
2776
|
* `processed_at` (Invariant 1).
|
|
2307
2777
|
*/
|
|
2308
2778
|
async forceReadoptRun(sessionId, row) {
|
|
2309
|
-
|
|
2779
|
+
if (this.stopped) {
|
|
2780
|
+
this.log({
|
|
2781
|
+
level: "info",
|
|
2782
|
+
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`,
|
|
2783
|
+
conversation_id: row.conversation_id,
|
|
2784
|
+
message_id: row.id
|
|
2785
|
+
});
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
if (this.awaitingReadopt.has(row.id)) {
|
|
2789
|
+
this.log({
|
|
2790
|
+
level: "info",
|
|
2791
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2792
|
+
conversation_id: row.conversation_id,
|
|
2793
|
+
message_id: row.id
|
|
2794
|
+
});
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2310
2797
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2311
2798
|
this.dontRedispatch.add(row.id);
|
|
2312
2799
|
this.log({
|
|
@@ -2323,13 +2810,19 @@ var ChannelDriver = class {
|
|
|
2323
2810
|
};
|
|
2324
2811
|
this.log({
|
|
2325
2812
|
level: "info",
|
|
2326
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching
|
|
2813
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
|
|
2327
2814
|
conversation_id: row.conversation_id,
|
|
2328
2815
|
message_id: row.id
|
|
2329
2816
|
});
|
|
2817
|
+
this.awaitingReadopt.add(row.id);
|
|
2818
|
+
let ocId;
|
|
2330
2819
|
try {
|
|
2331
|
-
await
|
|
2820
|
+
ocId = await this.dispatchLocked(
|
|
2821
|
+
sessionId,
|
|
2822
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
2823
|
+
);
|
|
2332
2824
|
} catch (err) {
|
|
2825
|
+
this.awaitingReadopt.delete(row.id);
|
|
2333
2826
|
if (err instanceof ChannelAuthError) throw err;
|
|
2334
2827
|
this.log({
|
|
2335
2828
|
level: "error",
|
|
@@ -2339,11 +2832,22 @@ var ChannelDriver = class {
|
|
|
2339
2832
|
});
|
|
2340
2833
|
return;
|
|
2341
2834
|
}
|
|
2835
|
+
if (ocId === null) {
|
|
2836
|
+
this.awaitingReadopt.delete(row.id);
|
|
2837
|
+
this.log({
|
|
2838
|
+
level: "error",
|
|
2839
|
+
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`,
|
|
2840
|
+
conversation_id: row.conversation_id,
|
|
2841
|
+
message_id: row.id
|
|
2842
|
+
});
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2342
2845
|
const conv = this.convForRow(sessionId, row);
|
|
2343
2846
|
const message = this.queuedMessageForRow(row);
|
|
2344
2847
|
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
2345
2848
|
this.dispatched.add(row.id);
|
|
2346
2849
|
this.readopted.add(row.id);
|
|
2850
|
+
this.awaitingReadopt.delete(row.id);
|
|
2347
2851
|
this.ensureWatcherRunning(sessionId);
|
|
2348
2852
|
}
|
|
2349
2853
|
/**
|
|
@@ -2448,8 +2952,8 @@ var ChannelDriver = class {
|
|
|
2448
2952
|
} catch {
|
|
2449
2953
|
}
|
|
2450
2954
|
for (const q of questions) {
|
|
2451
|
-
if (q.sessionID !== sessionId) continue;
|
|
2452
2955
|
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2956
|
+
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2453
2957
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
2454
2958
|
const reported = await this.reportInteraction(
|
|
2455
2959
|
watcher.conv.id,
|
|
@@ -2469,8 +2973,8 @@ var ChannelDriver = class {
|
|
|
2469
2973
|
} catch {
|
|
2470
2974
|
}
|
|
2471
2975
|
for (const p of permissions) {
|
|
2472
|
-
if (p.sessionID !== sessionId) continue;
|
|
2473
2976
|
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2977
|
+
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2474
2978
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
2475
2979
|
const reported = await this.reportInteraction(
|
|
2476
2980
|
watcher.conv.id,
|
|
@@ -2481,6 +2985,50 @@ var ChannelDriver = class {
|
|
|
2481
2985
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2482
2986
|
}
|
|
2483
2987
|
}
|
|
2988
|
+
/**
|
|
2989
|
+
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
2990
|
+
* i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
|
|
2991
|
+
* watched root. Sub-agents spawned via the `task` tool run in child sessions,
|
|
2992
|
+
* so their questions/permissions live under a different `sessionID` that must
|
|
2993
|
+
* still be attributed to the root conversation the watcher owns.
|
|
2994
|
+
*
|
|
2995
|
+
* Parents are cached in `sessionParents` so we walk each session at most once;
|
|
2996
|
+
* a bounded depth cap guards against a cycle or a pathological chain, and any
|
|
2997
|
+
* fetch failure is treated as "not a descendant" (best-effort — the interaction
|
|
2998
|
+
* simply isn't surfaced this tick and is retried next tick once resolvable).
|
|
2999
|
+
*/
|
|
3000
|
+
async sessionBelongsTo(sessionId, rootSessionId) {
|
|
3001
|
+
let current = sessionId;
|
|
3002
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
3003
|
+
if (current === rootSessionId) return true;
|
|
3004
|
+
const parent = await this.resolveSessionParent(current);
|
|
3005
|
+
if (parent === null || parent === void 0) return false;
|
|
3006
|
+
current = parent;
|
|
3007
|
+
}
|
|
3008
|
+
return false;
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
3012
|
+
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
3013
|
+
* unreachable / the session can't be read (so the caller stops walking without
|
|
3014
|
+
* caching a wrong answer — the next tick retries).
|
|
3015
|
+
*/
|
|
3016
|
+
async resolveSessionParent(sessionId) {
|
|
3017
|
+
const cached = this.sessionParents.get(sessionId);
|
|
3018
|
+
if (cached !== void 0) return cached;
|
|
3019
|
+
let parent = void 0;
|
|
3020
|
+
try {
|
|
3021
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3022
|
+
if (res.ok) {
|
|
3023
|
+
const body = await res.json();
|
|
3024
|
+
parent = body && typeof body.parentID === "string" ? body.parentID : null;
|
|
3025
|
+
}
|
|
3026
|
+
} catch {
|
|
3027
|
+
parent = void 0;
|
|
3028
|
+
}
|
|
3029
|
+
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3030
|
+
return parent;
|
|
3031
|
+
}
|
|
2484
3032
|
/**
|
|
2485
3033
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2486
3034
|
*
|
|
@@ -2613,13 +3161,17 @@ var ChannelDriver = class {
|
|
|
2613
3161
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2614
3162
|
* retry vehicle for the swap-to-running.
|
|
2615
3163
|
*/
|
|
2616
|
-
async markProcessing(conversationId, messageId, sessionId) {
|
|
3164
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
2617
3165
|
const res = await this.fetchImpl(
|
|
2618
3166
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2619
3167
|
{
|
|
2620
3168
|
method: "PATCH",
|
|
2621
3169
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2622
|
-
body: JSON.stringify({
|
|
3170
|
+
body: JSON.stringify({
|
|
3171
|
+
status: "processing",
|
|
3172
|
+
opencode_session_id: sessionId,
|
|
3173
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3174
|
+
})
|
|
2623
3175
|
}
|
|
2624
3176
|
);
|
|
2625
3177
|
this.assertAuth(res, "marking message as processing");
|
|
@@ -2657,13 +3209,17 @@ var ChannelDriver = class {
|
|
|
2657
3209
|
* watcher retries next tick within the
|
|
2658
3210
|
* deadline, Finding 4).
|
|
2659
3211
|
*/
|
|
2660
|
-
async markDone(conversationId, messageId, sessionId) {
|
|
3212
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
2661
3213
|
const res = await this.fetchImpl(
|
|
2662
3214
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2663
3215
|
{
|
|
2664
3216
|
method: "PATCH",
|
|
2665
3217
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2666
|
-
body: JSON.stringify({
|
|
3218
|
+
body: JSON.stringify({
|
|
3219
|
+
status: "done",
|
|
3220
|
+
opencode_session_id: sessionId,
|
|
3221
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3222
|
+
})
|
|
2667
3223
|
}
|
|
2668
3224
|
);
|
|
2669
3225
|
this.assertAuth(res, "marking message as done");
|
|
@@ -2673,7 +3229,17 @@ var ChannelDriver = class {
|
|
|
2673
3229
|
}
|
|
2674
3230
|
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
2675
3231
|
}
|
|
2676
|
-
|
|
3232
|
+
/**
|
|
3233
|
+
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3234
|
+
* when provided (issue #182): a bare `markFailed(conv, msg)` sends
|
|
3235
|
+
* `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
|
|
3236
|
+
* OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
|
|
3237
|
+
* failure reason reaches the channel.
|
|
3238
|
+
*/
|
|
3239
|
+
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
3240
|
+
const body = { status: "failed" };
|
|
3241
|
+
if (sessionId !== void 0) body.opencode_session_id = sessionId;
|
|
3242
|
+
if (error2 !== void 0) body.error = error2;
|
|
2677
3243
|
await this.callWithRetry(
|
|
2678
3244
|
"marking message as failed",
|
|
2679
3245
|
() => this.fetchImpl(
|
|
@@ -2681,7 +3247,7 @@ var ChannelDriver = class {
|
|
|
2681
3247
|
{
|
|
2682
3248
|
method: "PATCH",
|
|
2683
3249
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2684
|
-
body: JSON.stringify(
|
|
3250
|
+
body: JSON.stringify(body)
|
|
2685
3251
|
}
|
|
2686
3252
|
)
|
|
2687
3253
|
);
|
|
@@ -2992,6 +3558,25 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
2992
3558
|
return { error: `Failed to resolve agent from key: ${message}` };
|
|
2993
3559
|
}
|
|
2994
3560
|
}
|
|
3561
|
+
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3562
|
+
const apiUrl = getApiUrlConfig();
|
|
3563
|
+
try {
|
|
3564
|
+
const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
|
|
3565
|
+
method: "POST",
|
|
3566
|
+
headers: { Authorization: authHeader }
|
|
3567
|
+
});
|
|
3568
|
+
if (!response.ok) {
|
|
3569
|
+
const serverMessage = await readErrorMessage(response);
|
|
3570
|
+
return {
|
|
3571
|
+
ok: false,
|
|
3572
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
return { ok: true };
|
|
3576
|
+
} catch (error2) {
|
|
3577
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
2995
3580
|
async function getAgentInfo(agentId, authHeader) {
|
|
2996
3581
|
const apiUrl = getApiUrlConfig();
|
|
2997
3582
|
try {
|
|
@@ -3037,6 +3622,8 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3037
3622
|
// src/commands/run.ts
|
|
3038
3623
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
3039
3624
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3625
|
+
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3626
|
+
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3040
3627
|
function log2(state, message, isError = false) {
|
|
3041
3628
|
if (state.json) {
|
|
3042
3629
|
console.log(
|
|
@@ -3191,7 +3778,7 @@ async function driveChannels(state, driver) {
|
|
|
3191
3778
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
3192
3779
|
if (state.interactive) displayStatus(state);
|
|
3193
3780
|
}
|
|
3194
|
-
await new Promise((
|
|
3781
|
+
await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
|
|
3195
3782
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
3196
3783
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
3197
3784
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -3202,8 +3789,122 @@ async function driveChannels(state, driver) {
|
|
|
3202
3789
|
}
|
|
3203
3790
|
}
|
|
3204
3791
|
}
|
|
3205
|
-
|
|
3792
|
+
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
3793
|
+
async function runSweep(state, driver, config2) {
|
|
3794
|
+
const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
|
|
3795
|
+
try {
|
|
3796
|
+
const sessions = await listSessions(state.port);
|
|
3797
|
+
if (sessions === null) {
|
|
3798
|
+
logActivity(state, {
|
|
3799
|
+
type: "info",
|
|
3800
|
+
message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
|
|
3801
|
+
});
|
|
3802
|
+
return;
|
|
3803
|
+
}
|
|
3804
|
+
const toDelete = selectSessionsToDelete(
|
|
3805
|
+
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
3806
|
+
{
|
|
3807
|
+
maxAgeMs: config2.maxAgeMs,
|
|
3808
|
+
maxCount: config2.maxCount,
|
|
3809
|
+
nowMs: Date.now(),
|
|
3810
|
+
protectedIds: driver.protectedSessionIds()
|
|
3811
|
+
}
|
|
3812
|
+
);
|
|
3813
|
+
const protectedNow = driver.protectedSessionIds();
|
|
3814
|
+
let deleted = 0;
|
|
3815
|
+
let failed = 0;
|
|
3816
|
+
let skippedNewlyActive = 0;
|
|
3817
|
+
for (const id of toDelete) {
|
|
3818
|
+
if (protectedNow.has(id)) {
|
|
3819
|
+
skippedNewlyActive++;
|
|
3820
|
+
logActivity(state, {
|
|
3821
|
+
type: "info",
|
|
3822
|
+
message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
|
|
3823
|
+
});
|
|
3824
|
+
continue;
|
|
3825
|
+
}
|
|
3826
|
+
if (await deleteSession(state.port, id)) deleted++;
|
|
3827
|
+
else failed++;
|
|
3828
|
+
}
|
|
3829
|
+
const failedNote = failed > 0 ? `, failed ${failed}` : "";
|
|
3830
|
+
const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
|
|
3831
|
+
logActivity(state, {
|
|
3832
|
+
type: "info",
|
|
3833
|
+
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
3834
|
+
});
|
|
3835
|
+
} catch (error2) {
|
|
3836
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
3837
|
+
logActivity(state, {
|
|
3838
|
+
type: "error",
|
|
3839
|
+
error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
function scheduleSessionCleanup(state, driver, options) {
|
|
3844
|
+
const config2 = resolveSessionCleanupConfig(
|
|
3845
|
+
{
|
|
3846
|
+
maxAge: options.sessionCleanupMaxAge,
|
|
3847
|
+
maxCount: options.sessionCleanupMaxCount,
|
|
3848
|
+
interval: options.sessionCleanupInterval
|
|
3849
|
+
},
|
|
3850
|
+
process.env
|
|
3851
|
+
);
|
|
3852
|
+
for (const warning2 of config2.warnings) {
|
|
3853
|
+
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
3854
|
+
}
|
|
3855
|
+
if (!config2.enabled) return;
|
|
3856
|
+
logActivity(state, {
|
|
3857
|
+
type: "info",
|
|
3858
|
+
message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
|
|
3859
|
+
});
|
|
3860
|
+
const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
|
|
3861
|
+
const firstSweep = setTimeout(
|
|
3862
|
+
() => void runSweep(state, driver, config2),
|
|
3863
|
+
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
3864
|
+
);
|
|
3865
|
+
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
3866
|
+
}
|
|
3867
|
+
async function notifyOffline(state) {
|
|
3868
|
+
if (!state.agentId || !state.authHeader) return;
|
|
3869
|
+
if (!state.connected) {
|
|
3870
|
+
log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
3874
|
+
if (result.ok) {
|
|
3875
|
+
log2(state, "Notified Evident the agent is going offline");
|
|
3876
|
+
} else {
|
|
3877
|
+
logActivity(state, {
|
|
3878
|
+
type: "error",
|
|
3879
|
+
error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
|
|
3880
|
+
});
|
|
3881
|
+
if (state.interactive) displayStatus(state);
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
async function cleanup(state, opts = {}) {
|
|
3206
3885
|
state.running = false;
|
|
3886
|
+
for (const timer of state.sessionCleanupTimers) {
|
|
3887
|
+
clearInterval(timer);
|
|
3888
|
+
clearTimeout(timer);
|
|
3889
|
+
}
|
|
3890
|
+
state.sessionCleanupTimers = [];
|
|
3891
|
+
if (opts.graceful && state.channelDriver) {
|
|
3892
|
+
state.channelDriver.stop();
|
|
3893
|
+
log2(state, "Draining in-flight channel work before shutdown...");
|
|
3894
|
+
if (state.interactive) {
|
|
3895
|
+
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
3896
|
+
displayStatus(state);
|
|
3897
|
+
}
|
|
3898
|
+
const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
|
|
3899
|
+
if (!settled) {
|
|
3900
|
+
logActivity(state, {
|
|
3901
|
+
type: "info",
|
|
3902
|
+
message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
|
|
3903
|
+
});
|
|
3904
|
+
if (state.interactive) displayStatus(state);
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
await notifyOffline(state);
|
|
3207
3908
|
if (state.connection) {
|
|
3208
3909
|
state.connection.close();
|
|
3209
3910
|
state.connection = null;
|
|
@@ -3234,10 +3935,13 @@ async function run(options) {
|
|
|
3234
3935
|
opencodeVersion: null,
|
|
3235
3936
|
opencodeProcess: null,
|
|
3236
3937
|
connection: null,
|
|
3938
|
+
channelDriver: null,
|
|
3237
3939
|
running: true,
|
|
3940
|
+
shuttingDown: false,
|
|
3238
3941
|
activityLog: [],
|
|
3239
3942
|
messageCount: 0,
|
|
3240
3943
|
lastProxiedActivityAt: null,
|
|
3944
|
+
sessionCleanupTimers: [],
|
|
3241
3945
|
authHeader: ""
|
|
3242
3946
|
};
|
|
3243
3947
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
@@ -3248,13 +3952,15 @@ async function run(options) {
|
|
|
3248
3952
|
);
|
|
3249
3953
|
}
|
|
3250
3954
|
const handleSignal = async () => {
|
|
3955
|
+
if (state.shuttingDown) return;
|
|
3956
|
+
state.shuttingDown = true;
|
|
3251
3957
|
if (state.interactive) {
|
|
3252
3958
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
3253
3959
|
displayStatus(state);
|
|
3254
3960
|
} else {
|
|
3255
3961
|
log2(state, "Shutting down...");
|
|
3256
3962
|
}
|
|
3257
|
-
await cleanup(state);
|
|
3963
|
+
await cleanup(state, { graceful: true });
|
|
3258
3964
|
await shutdownTelemetry();
|
|
3259
3965
|
process.exit(0);
|
|
3260
3966
|
};
|
|
@@ -3374,12 +4080,14 @@ async function run(options) {
|
|
|
3374
4080
|
apiUrl: getApiUrlConfig(),
|
|
3375
4081
|
getAuthHeader: () => state.authHeader,
|
|
3376
4082
|
conversationFilter: state.conversationFilter,
|
|
4083
|
+
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
3377
4084
|
log: (entry) => logActivity(state, {
|
|
3378
4085
|
type: entry.level === "error" ? "error" : "info",
|
|
3379
4086
|
message: entry.message,
|
|
3380
4087
|
error: entry.level === "error" ? entry.message : void 0
|
|
3381
4088
|
})
|
|
3382
4089
|
});
|
|
4090
|
+
state.channelDriver = channelDriver;
|
|
3383
4091
|
const connection = new RunnerConnection({
|
|
3384
4092
|
agentId: state.agentId,
|
|
3385
4093
|
getAuthHeader: () => state.authHeader,
|
|
@@ -3393,7 +4101,11 @@ async function run(options) {
|
|
|
3393
4101
|
type: "info",
|
|
3394
4102
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
3395
4103
|
});
|
|
3396
|
-
emitAgentConnected(state.agentId, {
|
|
4104
|
+
emitAgentConnected(state.agentId, {
|
|
4105
|
+
port: state.port,
|
|
4106
|
+
cli_version: getCliVersion(),
|
|
4107
|
+
opencode_version: state.opencodeVersion
|
|
4108
|
+
});
|
|
3397
4109
|
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
3398
4110
|
if (state.interactive) displayStatus(state);
|
|
3399
4111
|
channelDriver.drainPending().then((processed) => {
|
|
@@ -3472,10 +4184,12 @@ async function run(options) {
|
|
|
3472
4184
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
3473
4185
|
throw error2;
|
|
3474
4186
|
}
|
|
4187
|
+
scheduleSessionCleanup(state, channelDriver, options);
|
|
3475
4188
|
if (!interactive || state.json) {
|
|
3476
4189
|
log2(state, "Driving channel messages...");
|
|
3477
4190
|
}
|
|
3478
4191
|
await driveChannels(state, channelDriver);
|
|
4192
|
+
if (state.shuttingDown) return;
|
|
3479
4193
|
await cleanup(state);
|
|
3480
4194
|
if (state.json) {
|
|
3481
4195
|
console.log(
|
|
@@ -3490,6 +4204,7 @@ async function run(options) {
|
|
|
3490
4204
|
await shutdownTelemetry();
|
|
3491
4205
|
process.exit(0);
|
|
3492
4206
|
} catch (error2) {
|
|
4207
|
+
if (state.shuttingDown) return;
|
|
3493
4208
|
await cleanup(state);
|
|
3494
4209
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
3495
4210
|
if (state.json) {
|
|
@@ -3524,7 +4239,16 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
3524
4239
|
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
4240
|
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
4241
|
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").
|
|
4242
|
+
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(
|
|
4243
|
+
"--session-cleanup-max-age <duration>",
|
|
4244
|
+
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4245
|
+
).option(
|
|
4246
|
+
"--session-cleanup-max-count <n>",
|
|
4247
|
+
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
4248
|
+
).option(
|
|
4249
|
+
"--session-cleanup-interval <duration>",
|
|
4250
|
+
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
4251
|
+
).action(
|
|
3528
4252
|
(options) => {
|
|
3529
4253
|
run({
|
|
3530
4254
|
agent: options.agent,
|
|
@@ -3532,7 +4256,11 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
3532
4256
|
verbose: options.verbose,
|
|
3533
4257
|
conversation: options.conversation,
|
|
3534
4258
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
3535
|
-
json: options.json
|
|
4259
|
+
json: options.json,
|
|
4260
|
+
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4261
|
+
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4262
|
+
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4263
|
+
sessionCleanupInterval: options.sessionCleanupInterval
|
|
3536
4264
|
});
|
|
3537
4265
|
}
|
|
3538
4266
|
);
|