@evident-ai/cli 3.0.1-dev.4b2c312 → 3.0.1-dev.4e02c98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/index.js +803 -102
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1078,6 +1078,12 @@ async function getSessionMessages(port, sessionId) {
|
|
|
1078
1078
|
return null;
|
|
1079
1079
|
}
|
|
1080
1080
|
}
|
|
1081
|
+
function isSessionActivelyGenerating(messages) {
|
|
1082
|
+
if (!messages || messages.length === 0) return false;
|
|
1083
|
+
const last = messages[messages.length - 1];
|
|
1084
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1085
|
+
return completedOf(last) == null;
|
|
1086
|
+
}
|
|
1081
1087
|
function sessionLastActivityMs(session) {
|
|
1082
1088
|
const candidates = [
|
|
1083
1089
|
session.time?.updated,
|
|
@@ -1120,6 +1126,36 @@ async function sessionExists(port, id) {
|
|
|
1120
1126
|
return null;
|
|
1121
1127
|
}
|
|
1122
1128
|
}
|
|
1129
|
+
async function getSessionStatuses(port) {
|
|
1130
|
+
try {
|
|
1131
|
+
const res = await fetch(`${opencodeBase(port)}/session/status`);
|
|
1132
|
+
if (!res.ok) {
|
|
1133
|
+
console.error(
|
|
1134
|
+
`[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
|
|
1135
|
+
);
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
const body = await res.json();
|
|
1139
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
1140
|
+
console.error(
|
|
1141
|
+
`[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
|
|
1142
|
+
);
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
return body;
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
console.error(
|
|
1148
|
+
`[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1149
|
+
);
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function isSessionOngoing(port, id) {
|
|
1154
|
+
const map = await getSessionStatuses(port);
|
|
1155
|
+
if (map == null) return null;
|
|
1156
|
+
const entry = map[id];
|
|
1157
|
+
return entry != null && entry.type !== "idle";
|
|
1158
|
+
}
|
|
1123
1159
|
async function createOpenCodeSession(port, directory) {
|
|
1124
1160
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1125
1161
|
if (directory && directory.trim()) {
|
|
@@ -1137,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1137
1173
|
const data = await response.json();
|
|
1138
1174
|
return data.id;
|
|
1139
1175
|
}
|
|
1176
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1177
|
+
try {
|
|
1178
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1179
|
+
if (!res.ok) {
|
|
1180
|
+
console.error(
|
|
1181
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1182
|
+
);
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
const body = await res.json();
|
|
1186
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1187
|
+
if (!providers) {
|
|
1188
|
+
console.error(
|
|
1189
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1190
|
+
);
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1194
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1195
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1196
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1197
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1198
|
+
if (!provider && !providerId) {
|
|
1199
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1200
|
+
if (defaultProviderIds.length === 1) {
|
|
1201
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (!provider || !provider.models) return null;
|
|
1205
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1206
|
+
const def = defaults2[provider.id];
|
|
1207
|
+
if (typeof def === "string") modelId = def;
|
|
1208
|
+
}
|
|
1209
|
+
if (!modelId) {
|
|
1210
|
+
if (providerId) {
|
|
1211
|
+
const keys = Object.keys(provider.models);
|
|
1212
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1213
|
+
}
|
|
1214
|
+
if (!modelId) return null;
|
|
1215
|
+
}
|
|
1216
|
+
const entry = provider.models[modelId];
|
|
1217
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1218
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
console.error(
|
|
1221
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1222
|
+
);
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function buildFileParts(attachments, capable) {
|
|
1227
|
+
const outcomes = [];
|
|
1228
|
+
const parts = [];
|
|
1229
|
+
const capabilityUnknown = capable === null;
|
|
1230
|
+
if (capable !== true) {
|
|
1231
|
+
for (const a of attachments.inputs) {
|
|
1232
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1233
|
+
}
|
|
1234
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1235
|
+
}
|
|
1236
|
+
for (const a of attachments.inputs) {
|
|
1237
|
+
let dataUrl = null;
|
|
1238
|
+
try {
|
|
1239
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.error(
|
|
1242
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1243
|
+
);
|
|
1244
|
+
dataUrl = null;
|
|
1245
|
+
}
|
|
1246
|
+
if (dataUrl == null) {
|
|
1247
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
parts.push({
|
|
1251
|
+
type: "file",
|
|
1252
|
+
mime: a.mime,
|
|
1253
|
+
url: dataUrl,
|
|
1254
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1255
|
+
});
|
|
1256
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1257
|
+
}
|
|
1258
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1259
|
+
}
|
|
1140
1260
|
function messageText(m) {
|
|
1141
1261
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1142
1262
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1143
1263
|
}
|
|
1144
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1264
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1145
1265
|
const before = await getSessionMessages(port, sessionId);
|
|
1146
1266
|
const knownUserIds = new Set(
|
|
1147
1267
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1148
1268
|
);
|
|
1269
|
+
const parts = [{ type: "text", text: content }];
|
|
1270
|
+
let pendingOutcomes = null;
|
|
1271
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1272
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1273
|
+
const {
|
|
1274
|
+
parts: fileParts,
|
|
1275
|
+
outcomes,
|
|
1276
|
+
capabilityUnknown
|
|
1277
|
+
} = await buildFileParts(attachments, capable);
|
|
1278
|
+
parts.push(...fileParts);
|
|
1279
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1280
|
+
}
|
|
1149
1281
|
const body = {
|
|
1150
|
-
parts
|
|
1282
|
+
parts
|
|
1151
1283
|
};
|
|
1152
1284
|
if (options?.agent) {
|
|
1153
1285
|
body.agent = options.agent;
|
|
@@ -1186,7 +1318,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1186
1318
|
best = { id, created };
|
|
1187
1319
|
}
|
|
1188
1320
|
}
|
|
1189
|
-
if (best)
|
|
1321
|
+
if (best) {
|
|
1322
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1323
|
+
return best.id;
|
|
1324
|
+
}
|
|
1190
1325
|
}
|
|
1191
1326
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1192
1327
|
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
@@ -1246,6 +1381,11 @@ function messageRunState(messages, userMessageId) {
|
|
|
1246
1381
|
if (isAssistantInFlight(reply)) return "running";
|
|
1247
1382
|
return errorOf(reply) != null ? "failed" : "done";
|
|
1248
1383
|
}
|
|
1384
|
+
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
1385
|
+
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
1386
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1387
|
+
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1388
|
+
}
|
|
1249
1389
|
function messageError(messages, userMessageId) {
|
|
1250
1390
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1251
1391
|
const error2 = errorOf(reply);
|
|
@@ -1770,6 +1910,17 @@ function messageIdOf(m) {
|
|
|
1770
1910
|
const infoId = m.info?.id;
|
|
1771
1911
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1772
1912
|
}
|
|
1913
|
+
function cleanImageMime(contentType) {
|
|
1914
|
+
if (!contentType) return null;
|
|
1915
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
1916
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
1917
|
+
}
|
|
1918
|
+
var LOG_LEVELS = {
|
|
1919
|
+
debug: 0,
|
|
1920
|
+
info: 1,
|
|
1921
|
+
warn: 2,
|
|
1922
|
+
error: 3
|
|
1923
|
+
};
|
|
1773
1924
|
var DEFAULT_RETRY_POLICY = {
|
|
1774
1925
|
maxAttempts: 6,
|
|
1775
1926
|
baseDelayMs: 500,
|
|
@@ -1778,6 +1929,9 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1778
1929
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1779
1930
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1780
1931
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1932
|
+
var HEARTBEAT_MS = 6e4;
|
|
1933
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1934
|
+
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1781
1935
|
var ChannelAuthError = class extends Error {
|
|
1782
1936
|
constructor(message) {
|
|
1783
1937
|
super(message);
|
|
@@ -1874,6 +2028,15 @@ var ChannelDriver = class {
|
|
|
1874
2028
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1875
2029
|
*/
|
|
1876
2030
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
2031
|
+
/**
|
|
2032
|
+
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
2033
|
+
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
2034
|
+
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
2035
|
+
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
2036
|
+
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
2037
|
+
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
2038
|
+
*/
|
|
2039
|
+
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
1877
2040
|
/**
|
|
1878
2041
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1879
2042
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -1888,6 +2051,15 @@ var ChannelDriver = class {
|
|
|
1888
2051
|
* so the NEXT tick may retry exactly once more).
|
|
1889
2052
|
*/
|
|
1890
2053
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2054
|
+
/**
|
|
2055
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2056
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2057
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2058
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2059
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2060
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2061
|
+
*/
|
|
2062
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1891
2063
|
/**
|
|
1892
2064
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1893
2065
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1905,6 +2077,16 @@ var ChannelDriver = class {
|
|
|
1905
2077
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1906
2078
|
*/
|
|
1907
2079
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2080
|
+
/**
|
|
2081
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2082
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
2083
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
2084
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
2085
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2086
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2087
|
+
* no watcher) can resolve the title.
|
|
2088
|
+
*/
|
|
2089
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1908
2090
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1909
2091
|
draining = false;
|
|
1910
2092
|
/**
|
|
@@ -1942,9 +2124,6 @@ var ChannelDriver = class {
|
|
|
1942
2124
|
get opencodeBase() {
|
|
1943
2125
|
return `http://127.0.0.1:${this.port}`;
|
|
1944
2126
|
}
|
|
1945
|
-
// -------------------------------------------------------------------------
|
|
1946
|
-
// Public API
|
|
1947
|
-
// -------------------------------------------------------------------------
|
|
1948
2127
|
/**
|
|
1949
2128
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1950
2129
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2086,9 +2265,7 @@ var ChannelDriver = class {
|
|
|
2086
2265
|
if (!stillLive) return;
|
|
2087
2266
|
}
|
|
2088
2267
|
}
|
|
2089
|
-
// -------------------------------------------------------------------------
|
|
2090
2268
|
// Conversation processing (WI-3 — async dispatch)
|
|
2091
|
-
// -------------------------------------------------------------------------
|
|
2092
2269
|
/**
|
|
2093
2270
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2094
2271
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2120,9 +2297,10 @@ var ChannelDriver = class {
|
|
|
2120
2297
|
conversation_id: conv.id,
|
|
2121
2298
|
message_id: message.id
|
|
2122
2299
|
});
|
|
2300
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2123
2301
|
opencodeMessageId = await this.dispatchLocked(
|
|
2124
2302
|
sessionId,
|
|
2125
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2303
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2126
2304
|
);
|
|
2127
2305
|
} catch (err) {
|
|
2128
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
@@ -2130,7 +2308,7 @@ var ChannelDriver = class {
|
|
|
2130
2308
|
if (await sessionExists(this.port, sessionId) === false) {
|
|
2131
2309
|
this.sessions.delete(conv.id);
|
|
2132
2310
|
this.log({
|
|
2133
|
-
level: "
|
|
2311
|
+
level: "warn",
|
|
2134
2312
|
message: `Message ${message.id.slice(0, 8)} dispatch hit a session (${sessionId.slice(0, 8)}) that was deleted mid-dispatch (cleanup race) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick (recreated then, in order). Already-dispatched turns keep their watcher.`,
|
|
2135
2313
|
conversation_id: conv.id,
|
|
2136
2314
|
message_id: message.id
|
|
@@ -2149,7 +2327,7 @@ var ChannelDriver = class {
|
|
|
2149
2327
|
}
|
|
2150
2328
|
if (opencodeMessageId === null) {
|
|
2151
2329
|
this.log({
|
|
2152
|
-
level: "
|
|
2330
|
+
level: "warn",
|
|
2153
2331
|
message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
|
|
2154
2332
|
conversation_id: conv.id,
|
|
2155
2333
|
message_id: message.id
|
|
@@ -2163,7 +2341,7 @@ var ChannelDriver = class {
|
|
|
2163
2341
|
}
|
|
2164
2342
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2165
2343
|
this.log({
|
|
2166
|
-
level: "
|
|
2344
|
+
level: "warn",
|
|
2167
2345
|
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
2168
2346
|
conversation_id: conv.id
|
|
2169
2347
|
});
|
|
@@ -2177,7 +2355,7 @@ var ChannelDriver = class {
|
|
|
2177
2355
|
const exists = await sessionExists(this.port, bound);
|
|
2178
2356
|
if (exists === false) {
|
|
2179
2357
|
this.log({
|
|
2180
|
-
level: "
|
|
2358
|
+
level: "debug",
|
|
2181
2359
|
message: `OpenCode session ${bound} for conversation ${conv.id.slice(0, 8)} no longer exists (deleted or DB reset) \u2014 creating a fresh session and rebinding.`,
|
|
2182
2360
|
conversation_id: conv.id
|
|
2183
2361
|
});
|
|
@@ -2212,15 +2390,13 @@ var ChannelDriver = class {
|
|
|
2212
2390
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2213
2391
|
if (!this.opencodeDirectory) {
|
|
2214
2392
|
this.log({
|
|
2215
|
-
level: "
|
|
2393
|
+
level: "warn",
|
|
2216
2394
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2217
2395
|
});
|
|
2218
2396
|
}
|
|
2219
2397
|
return this.opencodeDirectory;
|
|
2220
2398
|
}
|
|
2221
|
-
// -------------------------------------------------------------------------
|
|
2222
2399
|
// Per-session watcher (WI-3)
|
|
2223
|
-
// -------------------------------------------------------------------------
|
|
2224
2400
|
/**
|
|
2225
2401
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2226
2402
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2240,6 +2416,103 @@ var ChannelDriver = class {
|
|
|
2240
2416
|
);
|
|
2241
2417
|
return run2;
|
|
2242
2418
|
}
|
|
2419
|
+
// Inbound image attachments (#255, WI-8)
|
|
2420
|
+
/**
|
|
2421
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2422
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2423
|
+
*
|
|
2424
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2425
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2426
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2427
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2428
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2429
|
+
* existing callback surface when any image was skipped/failed.
|
|
2430
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2431
|
+
* reports outcomes back via `onOutcomes`.
|
|
2432
|
+
*/
|
|
2433
|
+
buildSendAttachments(conv, message) {
|
|
2434
|
+
const refs = message.attachments;
|
|
2435
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2436
|
+
return {
|
|
2437
|
+
inputs: refs.map((a, index) => ({
|
|
2438
|
+
index,
|
|
2439
|
+
mime: a.mime,
|
|
2440
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2441
|
+
})),
|
|
2442
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2443
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2448
|
+
* (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2449
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2450
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2451
|
+
*
|
|
2452
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2453
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2454
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2455
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2456
|
+
* Failures are logged with context (no silent swallow).
|
|
2457
|
+
*/
|
|
2458
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2459
|
+
try {
|
|
2460
|
+
const res = await this.fetchImpl(
|
|
2461
|
+
`${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2462
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2463
|
+
);
|
|
2464
|
+
if (!res.ok) {
|
|
2465
|
+
this.log({
|
|
2466
|
+
level: "error",
|
|
2467
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2468
|
+
message_id: messageId
|
|
2469
|
+
});
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
const buf = await res.arrayBuffer();
|
|
2473
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2474
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2475
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2476
|
+
} catch (err) {
|
|
2477
|
+
this.log({
|
|
2478
|
+
level: "error",
|
|
2479
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} failed \u2014 omitting this image (text turn proceeds): ${err instanceof Error ? err.message : String(err)}`,
|
|
2480
|
+
message_id: messageId
|
|
2481
|
+
});
|
|
2482
|
+
return null;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2487
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2488
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2489
|
+
*
|
|
2490
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2491
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2492
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2493
|
+
* reaches the channel.
|
|
2494
|
+
*
|
|
2495
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2496
|
+
*/
|
|
2497
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2498
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2499
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2500
|
+
if (skipped === 0 && failed === 0) return;
|
|
2501
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2502
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2503
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2504
|
+
this.log({
|
|
2505
|
+
level: "info",
|
|
2506
|
+
message: `Message ${messageId.slice(0, 8)}: ${skipped} image(s) skipped (${capabilityUnknown ? "capability was unreadable \u2014 failed open to text-only" : "model not attachment-capable"}), ${failed} image(s) unavailable (deleted-at-source or fetch failure) \u2014 noting to Evident`,
|
|
2507
|
+
conversation_id: conversationId,
|
|
2508
|
+
message_id: messageId
|
|
2509
|
+
});
|
|
2510
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2511
|
+
skipped,
|
|
2512
|
+
failed,
|
|
2513
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2243
2516
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2244
2517
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2245
2518
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2249,7 +2522,9 @@ var ChannelDriver = class {
|
|
|
2249
2522
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2250
2523
|
loop: null,
|
|
2251
2524
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2252
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2525
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2526
|
+
lastGoodPollAt: this.now(),
|
|
2527
|
+
hadUsablePoll: false
|
|
2253
2528
|
};
|
|
2254
2529
|
this.watchers.set(sessionId, watcher);
|
|
2255
2530
|
}
|
|
@@ -2259,20 +2534,37 @@ var ChannelDriver = class {
|
|
|
2259
2534
|
opencodeMessageId,
|
|
2260
2535
|
message,
|
|
2261
2536
|
dispatchedAt: now,
|
|
2537
|
+
processingAnchorMs: now,
|
|
2262
2538
|
deadline: now + this.pausedMaxWaitMs,
|
|
2263
2539
|
started: false,
|
|
2264
2540
|
done: false,
|
|
2265
|
-
stuckReported: false
|
|
2541
|
+
stuckReported: false,
|
|
2542
|
+
lastAliveAt: 0,
|
|
2543
|
+
aliveInFlight: false,
|
|
2544
|
+
awaitingHumanLatched: false,
|
|
2545
|
+
pausedOnQuestion: false,
|
|
2546
|
+
pausedOnPermission: false,
|
|
2547
|
+
pausedClearConfirmed: false,
|
|
2548
|
+
pausedInFlight: false,
|
|
2549
|
+
deliveryDeadlineAnchored: false
|
|
2266
2550
|
});
|
|
2267
2551
|
}
|
|
2268
2552
|
/**
|
|
2269
2553
|
* Register a RE-ADOPTED `processing` message with its session watcher
|
|
2270
2554
|
* (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
|
|
2271
2555
|
* `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
|
|
2272
|
-
* `now
|
|
2273
|
-
* (10 min after `processed_at
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2556
|
+
* `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
|
|
2557
|
+
* fresh dispatch would (10 min after `processed_at`, not 10 min from now).
|
|
2558
|
+
*
|
|
2559
|
+
* This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
|
|
2560
|
+
* give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
|
|
2561
|
+
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
2562
|
+
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
2563
|
+
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
2564
|
+
* handed to the cron. The old "the `deadline` must settle before the ~15-min
|
|
2565
|
+
* cron or they double-drive" reasoning is superseded: liveness now settles the
|
|
2566
|
+
* actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
|
|
2567
|
+
* (only the appear-guard uses it).
|
|
2276
2568
|
*
|
|
2277
2569
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
2278
2570
|
* `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
|
|
@@ -2290,7 +2582,9 @@ var ChannelDriver = class {
|
|
|
2290
2582
|
inFlight: /* @__PURE__ */ new Map(),
|
|
2291
2583
|
loop: null,
|
|
2292
2584
|
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
2293
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
2585
|
+
reportedPermissions: /* @__PURE__ */ new Set(),
|
|
2586
|
+
lastGoodPollAt: this.now(),
|
|
2587
|
+
hadUsablePoll: false
|
|
2294
2588
|
};
|
|
2295
2589
|
this.watchers.set(sessionId, watcher);
|
|
2296
2590
|
}
|
|
@@ -2299,6 +2593,10 @@ var ChannelDriver = class {
|
|
|
2299
2593
|
opencodeMessageId,
|
|
2300
2594
|
message,
|
|
2301
2595
|
dispatchedAt: this.now(),
|
|
2596
|
+
// Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
|
|
2597
|
+
// value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
|
|
2598
|
+
// reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
|
|
2599
|
+
processingAnchorMs: processedAtMs,
|
|
2302
2600
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
2303
2601
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
2304
2602
|
started: true,
|
|
@@ -2308,7 +2606,20 @@ var ChannelDriver = class {
|
|
|
2308
2606
|
// on `state === 'queued'` (turn produced no reply), not on `started`, so a
|
|
2309
2607
|
// re-adopted row left wedged in `queued` still emits the signal once
|
|
2310
2608
|
// (#210/#220 observability).
|
|
2311
|
-
stuckReported: false
|
|
2609
|
+
stuckReported: false,
|
|
2610
|
+
// Task 5.2: a re-adopted actively-running row re-attaches into the SAME
|
|
2611
|
+
// watcher and so hits the SAME actively-running heartbeat branch in
|
|
2612
|
+
// `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
|
|
2613
|
+
// re-adopted and is confirming this row alive" via that `alive` heartbeat,
|
|
2614
|
+
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2615
|
+
lastAliveAt: 0,
|
|
2616
|
+
aliveInFlight: false,
|
|
2617
|
+
awaitingHumanLatched: false,
|
|
2618
|
+
pausedOnQuestion: false,
|
|
2619
|
+
pausedOnPermission: false,
|
|
2620
|
+
pausedClearConfirmed: false,
|
|
2621
|
+
pausedInFlight: false,
|
|
2622
|
+
deliveryDeadlineAnchored: false
|
|
2312
2623
|
});
|
|
2313
2624
|
}
|
|
2314
2625
|
/**
|
|
@@ -2359,12 +2670,30 @@ var ChannelDriver = class {
|
|
|
2359
2670
|
messages = Array.isArray(body) ? body : null;
|
|
2360
2671
|
}
|
|
2361
2672
|
} catch {
|
|
2362
|
-
continue;
|
|
2363
2673
|
}
|
|
2674
|
+
if (messages != null && messages.length > 0) {
|
|
2675
|
+
watcher.lastGoodPollAt = this.now();
|
|
2676
|
+
watcher.hadUsablePoll = true;
|
|
2677
|
+
} else {
|
|
2678
|
+
const emptyButReachable = messages != null;
|
|
2679
|
+
const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
|
|
2680
|
+
if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
|
|
2364
2685
|
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
2365
|
-
await this.serviceInFlightMessage(
|
|
2686
|
+
await this.serviceInFlightMessage(
|
|
2687
|
+
sessionId,
|
|
2688
|
+
watcher,
|
|
2689
|
+
inFlight,
|
|
2690
|
+
messages,
|
|
2691
|
+
openQuestions,
|
|
2692
|
+
openPermissions,
|
|
2693
|
+
questionsPolledOk,
|
|
2694
|
+
permissionsPolledOk
|
|
2695
|
+
);
|
|
2366
2696
|
}
|
|
2367
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
2368
2697
|
}
|
|
2369
2698
|
} catch (err) {
|
|
2370
2699
|
if (err instanceof ChannelAuthError) {
|
|
@@ -2386,28 +2715,55 @@ var ChannelDriver = class {
|
|
|
2386
2715
|
});
|
|
2387
2716
|
}
|
|
2388
2717
|
}
|
|
2718
|
+
/**
|
|
2719
|
+
* On FIRST observing a terminal (done/failed) state, ensure the delivery
|
|
2720
|
+
* (markDone/markFailed) transient-retry path has a real window. A long
|
|
2721
|
+
* ACTIVELY-running turn is kept past its original `deadline`, so by completion
|
|
2722
|
+
* `now >= deadline` already holds and the retry bound below would fire on the
|
|
2723
|
+
* first transient PATCH failure — dropping the message before its reply lands
|
|
2724
|
+
* (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
|
|
2725
|
+
* to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
|
|
2726
|
+
* or past now, so a still-ample window is left untouched.
|
|
2727
|
+
*/
|
|
2728
|
+
anchorDeliveryDeadline(inFlight) {
|
|
2729
|
+
if (inFlight.deliveryDeadlineAnchored) return;
|
|
2730
|
+
inFlight.deliveryDeadlineAnchored = true;
|
|
2731
|
+
if (this.now() >= inFlight.deadline) {
|
|
2732
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2389
2735
|
/**
|
|
2390
2736
|
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
2391
2737
|
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
2392
2738
|
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
2393
2739
|
* in-flight set on completion or timeout.
|
|
2394
2740
|
*/
|
|
2395
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
2741
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
|
|
2396
2742
|
const conv = watcher.conv;
|
|
2397
2743
|
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
2744
|
+
const id = inFlight.evidentMessageId;
|
|
2745
|
+
if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
|
|
2746
|
+
else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
|
|
2747
|
+
if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
|
|
2748
|
+
else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
|
|
2749
|
+
const observedOpen = openQuestions.has(id) || openPermissions.has(id);
|
|
2750
|
+
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2751
|
+
const awaitingHuman = observedOpen || latchedPaused;
|
|
2398
2752
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2753
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2399
2754
|
let claimed;
|
|
2400
2755
|
try {
|
|
2401
2756
|
claimed = await this.markProcessing(
|
|
2402
2757
|
conv.id,
|
|
2403
2758
|
inFlight.evidentMessageId,
|
|
2404
2759
|
sessionId,
|
|
2405
|
-
inFlight.opencodeMessageId
|
|
2760
|
+
inFlight.opencodeMessageId,
|
|
2761
|
+
title
|
|
2406
2762
|
);
|
|
2407
2763
|
} catch (err) {
|
|
2408
2764
|
if (err instanceof ChannelAuthError) throw err;
|
|
2409
2765
|
this.log({
|
|
2410
|
-
level: "
|
|
2766
|
+
level: "warn",
|
|
2411
2767
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2412
2768
|
conversation_id: conv.id,
|
|
2413
2769
|
message_id: inFlight.evidentMessageId
|
|
@@ -2417,7 +2773,7 @@ var ChannelDriver = class {
|
|
|
2417
2773
|
inFlight.started = true;
|
|
2418
2774
|
if (!claimed) {
|
|
2419
2775
|
this.log({
|
|
2420
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2421
2777
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2422
2778
|
conversation_id: conv.id,
|
|
2423
2779
|
message_id: inFlight.evidentMessageId
|
|
@@ -2425,6 +2781,7 @@ var ChannelDriver = class {
|
|
|
2425
2781
|
}
|
|
2426
2782
|
}
|
|
2427
2783
|
if (state === "done") {
|
|
2784
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2428
2785
|
if (!inFlight.done) {
|
|
2429
2786
|
this.log({
|
|
2430
2787
|
level: "info",
|
|
@@ -2432,18 +2789,20 @@ var ChannelDriver = class {
|
|
|
2432
2789
|
conversation_id: conv.id,
|
|
2433
2790
|
message_id: inFlight.evidentMessageId
|
|
2434
2791
|
});
|
|
2792
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2435
2793
|
try {
|
|
2436
2794
|
await this.markDone(
|
|
2437
2795
|
conv.id,
|
|
2438
2796
|
inFlight.evidentMessageId,
|
|
2439
2797
|
sessionId,
|
|
2440
|
-
inFlight.opencodeMessageId
|
|
2798
|
+
inFlight.opencodeMessageId,
|
|
2799
|
+
title
|
|
2441
2800
|
);
|
|
2442
2801
|
} catch (err) {
|
|
2443
2802
|
if (err instanceof ChannelAuthError) throw err;
|
|
2444
2803
|
if (err instanceof ChannelTerminalError) {
|
|
2445
2804
|
this.log({
|
|
2446
|
-
level: "
|
|
2805
|
+
level: "warn",
|
|
2447
2806
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2448
2807
|
conversation_id: conv.id,
|
|
2449
2808
|
message_id: inFlight.evidentMessageId
|
|
@@ -2453,7 +2812,7 @@ var ChannelDriver = class {
|
|
|
2453
2812
|
}
|
|
2454
2813
|
if (this.now() >= inFlight.deadline) {
|
|
2455
2814
|
this.log({
|
|
2456
|
-
level: "
|
|
2815
|
+
level: "warn",
|
|
2457
2816
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2458
2817
|
conversation_id: conv.id,
|
|
2459
2818
|
message_id: inFlight.evidentMessageId
|
|
@@ -2462,7 +2821,7 @@ var ChannelDriver = class {
|
|
|
2462
2821
|
return;
|
|
2463
2822
|
}
|
|
2464
2823
|
this.log({
|
|
2465
|
-
level: "
|
|
2824
|
+
level: "warn",
|
|
2466
2825
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2467
2826
|
conversation_id: conv.id,
|
|
2468
2827
|
message_id: inFlight.evidentMessageId
|
|
@@ -2475,6 +2834,7 @@ var ChannelDriver = class {
|
|
|
2475
2834
|
return;
|
|
2476
2835
|
}
|
|
2477
2836
|
if (state === "failed") {
|
|
2837
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
2478
2838
|
if (!inFlight.done) {
|
|
2479
2839
|
const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
|
|
2480
2840
|
this.log({
|
|
@@ -2489,7 +2849,7 @@ var ChannelDriver = class {
|
|
|
2489
2849
|
if (err instanceof ChannelAuthError) throw err;
|
|
2490
2850
|
if (err instanceof ChannelTerminalError) {
|
|
2491
2851
|
this.log({
|
|
2492
|
-
level: "
|
|
2852
|
+
level: "warn",
|
|
2493
2853
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2494
2854
|
conversation_id: conv.id,
|
|
2495
2855
|
message_id: inFlight.evidentMessageId
|
|
@@ -2499,7 +2859,7 @@ var ChannelDriver = class {
|
|
|
2499
2859
|
}
|
|
2500
2860
|
if (this.now() >= inFlight.deadline) {
|
|
2501
2861
|
this.log({
|
|
2502
|
-
level: "
|
|
2862
|
+
level: "warn",
|
|
2503
2863
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2504
2864
|
conversation_id: conv.id,
|
|
2505
2865
|
message_id: inFlight.evidentMessageId
|
|
@@ -2508,7 +2868,7 @@ var ChannelDriver = class {
|
|
|
2508
2868
|
return;
|
|
2509
2869
|
}
|
|
2510
2870
|
this.log({
|
|
2511
|
-
level: "
|
|
2871
|
+
level: "warn",
|
|
2512
2872
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2513
2873
|
conversation_id: conv.id,
|
|
2514
2874
|
message_id: inFlight.evidentMessageId
|
|
@@ -2528,9 +2888,53 @@ var ChannelDriver = class {
|
|
|
2528
2888
|
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
2529
2889
|
});
|
|
2530
2890
|
}
|
|
2531
|
-
|
|
2891
|
+
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2892
|
+
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2532
2893
|
this.log({
|
|
2533
|
-
level: "
|
|
2894
|
+
level: "warn",
|
|
2895
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
|
|
2896
|
+
conversation_id: conv.id,
|
|
2897
|
+
message_id: inFlight.evidentMessageId
|
|
2898
|
+
});
|
|
2899
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
|
|
2900
|
+
watched_for_ms: this.now() - inFlight.processingAnchorMs
|
|
2901
|
+
});
|
|
2902
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2906
|
+
inFlight.aliveInFlight = true;
|
|
2907
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
2908
|
+
inFlight.aliveInFlight = false;
|
|
2909
|
+
if (ok) inFlight.lastAliveAt = this.now();
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
if (awaitingHuman) {
|
|
2913
|
+
if (!inFlight.awaitingHumanLatched) {
|
|
2914
|
+
inFlight.deadline = this.now() + this.pausedMaxWaitMs;
|
|
2915
|
+
inFlight.awaitingHumanLatched = true;
|
|
2916
|
+
}
|
|
2917
|
+
if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
|
|
2918
|
+
inFlight.pausedInFlight = true;
|
|
2919
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
|
|
2920
|
+
inFlight.pausedInFlight = false;
|
|
2921
|
+
if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
} else if (inFlight.awaitingHumanLatched) {
|
|
2925
|
+
inFlight.awaitingHumanLatched = false;
|
|
2926
|
+
inFlight.pausedOnQuestion = false;
|
|
2927
|
+
inFlight.pausedOnPermission = false;
|
|
2928
|
+
inFlight.pausedClearConfirmed = false;
|
|
2929
|
+
}
|
|
2930
|
+
const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
|
|
2931
|
+
const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
|
|
2932
|
+
(sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
|
|
2933
|
+
);
|
|
2934
|
+
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2935
|
+
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2936
|
+
this.log({
|
|
2937
|
+
level: "debug",
|
|
2534
2938
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2535
2939
|
conversation_id: conv.id,
|
|
2536
2940
|
message_id: inFlight.evidentMessageId
|
|
@@ -2541,9 +2945,7 @@ var ChannelDriver = class {
|
|
|
2541
2945
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2542
2946
|
}
|
|
2543
2947
|
}
|
|
2544
|
-
// -------------------------------------------------------------------------
|
|
2545
2948
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2546
|
-
// -------------------------------------------------------------------------
|
|
2547
2949
|
/**
|
|
2548
2950
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2549
2951
|
*
|
|
@@ -2560,15 +2962,20 @@ var ChannelDriver = class {
|
|
|
2560
2962
|
*/
|
|
2561
2963
|
async readoptProcessing() {
|
|
2562
2964
|
const rows = await this.getProcessingMessages();
|
|
2563
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
2965
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2564
2966
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2565
|
-
for (const id of [
|
|
2967
|
+
for (const id of [
|
|
2968
|
+
...this.dontRedispatch,
|
|
2969
|
+
...this.doneUndeliverable,
|
|
2970
|
+
...this.readoptPollUnresolvedSignalled
|
|
2971
|
+
]) {
|
|
2566
2972
|
if (!stillProcessing.has(id)) {
|
|
2567
2973
|
const cleared = this.dontRedispatch.delete(id);
|
|
2568
2974
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
2975
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2569
2976
|
if (cleared || clearedUndeliverable) {
|
|
2570
2977
|
this.log({
|
|
2571
|
-
level: "
|
|
2978
|
+
level: "debug",
|
|
2572
2979
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2573
2980
|
message_id: id
|
|
2574
2981
|
});
|
|
@@ -2581,7 +2988,7 @@ var ChannelDriver = class {
|
|
|
2581
2988
|
for (const row of rows) {
|
|
2582
2989
|
if (!row.opencode_session_id) {
|
|
2583
2990
|
this.log({
|
|
2584
|
-
level: "
|
|
2991
|
+
level: "warn",
|
|
2585
2992
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2586
2993
|
conversation_id: row.conversation_id,
|
|
2587
2994
|
message_id: row.id
|
|
@@ -2598,7 +3005,7 @@ var ChannelDriver = class {
|
|
|
2598
3005
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2599
3006
|
if (!res.ok) {
|
|
2600
3007
|
this.log({
|
|
2601
|
-
level: "
|
|
3008
|
+
level: "warn",
|
|
2602
3009
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2603
3010
|
});
|
|
2604
3011
|
continue;
|
|
@@ -2606,7 +3013,7 @@ var ChannelDriver = class {
|
|
|
2606
3013
|
const body = await res.json();
|
|
2607
3014
|
if (!Array.isArray(body)) {
|
|
2608
3015
|
this.log({
|
|
2609
|
-
level: "
|
|
3016
|
+
level: "warn",
|
|
2610
3017
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2611
3018
|
});
|
|
2612
3019
|
continue;
|
|
@@ -2614,13 +3021,15 @@ var ChannelDriver = class {
|
|
|
2614
3021
|
messages = body;
|
|
2615
3022
|
} catch (err) {
|
|
2616
3023
|
this.log({
|
|
2617
|
-
level: "
|
|
3024
|
+
level: "warn",
|
|
2618
3025
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2619
3026
|
});
|
|
2620
3027
|
continue;
|
|
2621
3028
|
}
|
|
3029
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
3030
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2622
3031
|
for (const row of sessionRows) {
|
|
2623
|
-
await this.readoptOne(sessionId, row, messages);
|
|
3032
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2624
3033
|
}
|
|
2625
3034
|
}
|
|
2626
3035
|
}
|
|
@@ -2642,10 +3051,10 @@ var ChannelDriver = class {
|
|
|
2642
3051
|
*
|
|
2643
3052
|
* Only `ChannelAuthError` propagates.
|
|
2644
3053
|
*/
|
|
2645
|
-
async readoptOne(sessionId, row, messages) {
|
|
3054
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2646
3055
|
if (this.isTracked(sessionId, row.id)) {
|
|
2647
3056
|
this.log({
|
|
2648
|
-
level: "
|
|
3057
|
+
level: "debug",
|
|
2649
3058
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2650
3059
|
conversation_id: row.conversation_id,
|
|
2651
3060
|
message_id: row.id
|
|
@@ -2657,7 +3066,7 @@ var ChannelDriver = class {
|
|
|
2657
3066
|
if (state === "done") {
|
|
2658
3067
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2659
3068
|
this.log({
|
|
2660
|
-
level: "
|
|
3069
|
+
level: "debug",
|
|
2661
3070
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2662
3071
|
conversation_id: row.conversation_id,
|
|
2663
3072
|
message_id: row.id
|
|
@@ -2671,21 +3080,23 @@ var ChannelDriver = class {
|
|
|
2671
3080
|
message_id: row.id
|
|
2672
3081
|
});
|
|
2673
3082
|
try {
|
|
2674
|
-
await this.
|
|
3083
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2675
3085
|
} catch (err) {
|
|
2676
3086
|
if (err instanceof ChannelAuthError) throw err;
|
|
2677
3087
|
if (err instanceof ChannelTerminalError) {
|
|
2678
3088
|
this.doneUndeliverable.add(row.id);
|
|
2679
3089
|
this.log({
|
|
2680
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2681
3091
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2682
3092
|
conversation_id: row.conversation_id,
|
|
2683
3093
|
message_id: row.id
|
|
2684
3094
|
});
|
|
3095
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2685
3096
|
return;
|
|
2686
3097
|
}
|
|
2687
3098
|
this.log({
|
|
2688
|
-
level: "
|
|
3099
|
+
level: "warn",
|
|
2689
3100
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2690
3101
|
conversation_id: row.conversation_id,
|
|
2691
3102
|
message_id: row.id
|
|
@@ -2693,6 +3104,7 @@ var ChannelDriver = class {
|
|
|
2693
3104
|
return;
|
|
2694
3105
|
}
|
|
2695
3106
|
this.dontRedispatch.delete(row.id);
|
|
3107
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2696
3108
|
return;
|
|
2697
3109
|
}
|
|
2698
3110
|
if (state === "failed") {
|
|
@@ -2710,15 +3122,16 @@ var ChannelDriver = class {
|
|
|
2710
3122
|
if (err instanceof ChannelTerminalError) {
|
|
2711
3123
|
this.doneUndeliverable.add(row.id);
|
|
2712
3124
|
this.log({
|
|
2713
|
-
level: "
|
|
3125
|
+
level: "warn",
|
|
2714
3126
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2715
3127
|
conversation_id: row.conversation_id,
|
|
2716
3128
|
message_id: row.id
|
|
2717
3129
|
});
|
|
3130
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2718
3131
|
return;
|
|
2719
3132
|
}
|
|
2720
3133
|
this.log({
|
|
2721
|
-
level: "
|
|
3134
|
+
level: "warn",
|
|
2722
3135
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2723
3136
|
conversation_id: row.conversation_id,
|
|
2724
3137
|
message_id: row.id
|
|
@@ -2726,17 +3139,83 @@ var ChannelDriver = class {
|
|
|
2726
3139
|
return;
|
|
2727
3140
|
}
|
|
2728
3141
|
this.dontRedispatch.delete(row.id);
|
|
3142
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2729
3143
|
return;
|
|
2730
3144
|
}
|
|
2731
3145
|
if (this.dontRedispatch.has(row.id)) {
|
|
2732
3146
|
this.log({
|
|
2733
|
-
level: "
|
|
3147
|
+
level: "debug",
|
|
2734
3148
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2735
3149
|
conversation_id: row.conversation_id,
|
|
2736
3150
|
message_id: row.id
|
|
2737
3151
|
});
|
|
2738
3152
|
return;
|
|
2739
3153
|
}
|
|
3154
|
+
let statusReadableOngoing = null;
|
|
3155
|
+
if (state === "running" && ocId) {
|
|
3156
|
+
const reply = findLastAssistantReplyFor(messages, ocId);
|
|
3157
|
+
const shape = this.replyCompletionShape(reply);
|
|
3158
|
+
const ongoing = sessionOngoing;
|
|
3159
|
+
statusReadableOngoing = ongoing;
|
|
3160
|
+
if (ongoing === false) {
|
|
3161
|
+
this.log({
|
|
3162
|
+
level: "info",
|
|
3163
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status (absent/idle) \u2014 re-dispatching from scratch (status-gated recovery)`,
|
|
3164
|
+
conversation_id: row.conversation_id,
|
|
3165
|
+
message_id: row.id
|
|
3166
|
+
});
|
|
3167
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
if (ongoing === true) {
|
|
3171
|
+
this.log({
|
|
3172
|
+
level: "debug",
|
|
3173
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
|
|
3174
|
+
conversation_id: row.conversation_id,
|
|
3175
|
+
message_id: row.id
|
|
3176
|
+
});
|
|
3177
|
+
} else {
|
|
3178
|
+
if (shape === "b1") {
|
|
3179
|
+
this.log({
|
|
3180
|
+
level: "debug",
|
|
3181
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
|
|
3182
|
+
conversation_id: row.conversation_id,
|
|
3183
|
+
message_id: row.id
|
|
3184
|
+
});
|
|
3185
|
+
if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
|
|
3186
|
+
this.readoptPollUnresolvedSignalled.add(row.id);
|
|
3187
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
|
|
3188
|
+
}
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
this.log({
|
|
3192
|
+
level: "debug",
|
|
3193
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
|
|
3194
|
+
conversation_id: row.conversation_id,
|
|
3195
|
+
message_id: row.id
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
|
|
3200
|
+
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
3201
|
+
if (descendantAlive === true) {
|
|
3202
|
+
this.log({
|
|
3203
|
+
level: "debug",
|
|
3204
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
|
|
3205
|
+
conversation_id: row.conversation_id,
|
|
3206
|
+
message_id: row.id
|
|
3207
|
+
});
|
|
3208
|
+
} else {
|
|
3209
|
+
this.log({
|
|
3210
|
+
level: "info",
|
|
3211
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned on recovery (root ${sessionId.slice(0, 8)}), no live descendant runner \u2014 re-dispatching from scratch${descendantAlive === null ? " (descendant liveness indeterminate; a restart guarantees no live runner, so this does NOT block the re-dispatch)" : ""}`,
|
|
3212
|
+
conversation_id: row.conversation_id,
|
|
3213
|
+
message_id: row.id
|
|
3214
|
+
});
|
|
3215
|
+
await this.forceReadoptRun(sessionId, row);
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
2740
3219
|
if ((state === "running" || state === "queued") && ocId) {
|
|
2741
3220
|
const conv = this.convForRow(sessionId, row);
|
|
2742
3221
|
const message = this.queuedMessageForRow(row);
|
|
@@ -2745,11 +3224,12 @@ var ChannelDriver = class {
|
|
|
2745
3224
|
this.readopted.add(row.id);
|
|
2746
3225
|
this.ensureWatcherRunning(sessionId);
|
|
2747
3226
|
this.log({
|
|
2748
|
-
level: "
|
|
3227
|
+
level: "debug",
|
|
2749
3228
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2750
3229
|
conversation_id: row.conversation_id,
|
|
2751
3230
|
message_id: row.id
|
|
2752
3231
|
});
|
|
3232
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2753
3233
|
return;
|
|
2754
3234
|
}
|
|
2755
3235
|
await this.forceReadoptRun(sessionId, row);
|
|
@@ -2778,7 +3258,7 @@ var ChannelDriver = class {
|
|
|
2778
3258
|
async forceReadoptRun(sessionId, row) {
|
|
2779
3259
|
if (this.stopped) {
|
|
2780
3260
|
this.log({
|
|
2781
|
-
level: "
|
|
3261
|
+
level: "debug",
|
|
2782
3262
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but the runner is stopping \u2014 not starting a fresh turn; leaving for restart recovery`,
|
|
2783
3263
|
conversation_id: row.conversation_id,
|
|
2784
3264
|
message_id: row.id
|
|
@@ -2787,7 +3267,7 @@ var ChannelDriver = class {
|
|
|
2787
3267
|
}
|
|
2788
3268
|
if (this.awaitingReadopt.has(row.id)) {
|
|
2789
3269
|
this.log({
|
|
2790
|
-
level: "
|
|
3270
|
+
level: "debug",
|
|
2791
3271
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2792
3272
|
conversation_id: row.conversation_id,
|
|
2793
3273
|
message_id: row.id
|
|
@@ -2797,11 +3277,12 @@ var ChannelDriver = class {
|
|
|
2797
3277
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2798
3278
|
this.dontRedispatch.add(row.id);
|
|
2799
3279
|
this.log({
|
|
2800
|
-
level: "
|
|
3280
|
+
level: "debug",
|
|
2801
3281
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but its re-adopt window has already elapsed \u2014 not dispatching an unwatchable turn; parking until it leaves processing (cron will reset it)`,
|
|
2802
3282
|
conversation_id: row.conversation_id,
|
|
2803
3283
|
message_id: row.id
|
|
2804
3284
|
});
|
|
3285
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2805
3286
|
return;
|
|
2806
3287
|
}
|
|
2807
3288
|
const options = {
|
|
@@ -2815,40 +3296,44 @@ var ChannelDriver = class {
|
|
|
2815
3296
|
message_id: row.id
|
|
2816
3297
|
});
|
|
2817
3298
|
this.awaitingReadopt.add(row.id);
|
|
3299
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3300
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3301
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
2818
3302
|
let ocId;
|
|
2819
3303
|
try {
|
|
2820
3304
|
ocId = await this.dispatchLocked(
|
|
2821
3305
|
sessionId,
|
|
2822
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3306
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
2823
3307
|
);
|
|
2824
3308
|
} catch (err) {
|
|
2825
3309
|
this.awaitingReadopt.delete(row.id);
|
|
2826
3310
|
if (err instanceof ChannelAuthError) throw err;
|
|
2827
3311
|
this.log({
|
|
2828
|
-
level: "
|
|
3312
|
+
level: "warn",
|
|
2829
3313
|
message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2830
3314
|
conversation_id: row.conversation_id,
|
|
2831
3315
|
message_id: row.id
|
|
2832
3316
|
});
|
|
3317
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2833
3318
|
return;
|
|
2834
3319
|
}
|
|
2835
3320
|
if (ocId === null) {
|
|
2836
3321
|
this.awaitingReadopt.delete(row.id);
|
|
2837
3322
|
this.log({
|
|
2838
|
-
level: "
|
|
3323
|
+
level: "warn",
|
|
2839
3324
|
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
|
|
2840
3325
|
conversation_id: row.conversation_id,
|
|
2841
3326
|
message_id: row.id
|
|
2842
3327
|
});
|
|
3328
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2843
3329
|
return;
|
|
2844
3330
|
}
|
|
2845
|
-
|
|
2846
|
-
const message = this.queuedMessageForRow(row);
|
|
2847
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3331
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
2848
3332
|
this.dispatched.add(row.id);
|
|
2849
3333
|
this.readopted.add(row.id);
|
|
2850
3334
|
this.awaitingReadopt.delete(row.id);
|
|
2851
3335
|
this.ensureWatcherRunning(sessionId);
|
|
3336
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2852
3337
|
}
|
|
2853
3338
|
/**
|
|
2854
3339
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -2897,7 +3382,8 @@ var ChannelDriver = class {
|
|
|
2897
3382
|
opencode_agent: row.opencode_agent,
|
|
2898
3383
|
opencode_model: row.opencode_model,
|
|
2899
3384
|
source_message_id: row.source_message_id,
|
|
2900
|
-
slack_user_id: row.slack_user_id
|
|
3385
|
+
slack_user_id: row.slack_user_id,
|
|
3386
|
+
attachments: row.attachments ?? null
|
|
2901
3387
|
};
|
|
2902
3388
|
}
|
|
2903
3389
|
/**
|
|
@@ -2918,7 +3404,7 @@ var ChannelDriver = class {
|
|
|
2918
3404
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
2919
3405
|
this.dontRedispatch.add(evidentMessageId);
|
|
2920
3406
|
this.log({
|
|
2921
|
-
level: "
|
|
3407
|
+
level: "debug",
|
|
2922
3408
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
2923
3409
|
conversation_id: watcher.conv.id,
|
|
2924
3410
|
message_id: evidentMessageId
|
|
@@ -2940,21 +3426,41 @@ var ChannelDriver = class {
|
|
|
2940
3426
|
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2941
3427
|
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2942
3428
|
* oldest running message.
|
|
3429
|
+
*
|
|
3430
|
+
* Returns the set of in-flight Evident message ids that are paused awaiting a
|
|
3431
|
+
* human — an outstanding (still-open) question/permission is attributed to them.
|
|
3432
|
+
* `serviceInFlightMessage` uses this to keep an actively-running turn watched
|
|
3433
|
+
* forever (ADR-0047) while still bounding a turn merely blocked on a person who
|
|
3434
|
+
* may never answer. Attribution here covers ALL open interactions, not just
|
|
3435
|
+
* NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
|
|
3436
|
+
* even after it was already surfaced to the channel.
|
|
2943
3437
|
*/
|
|
2944
3438
|
async pollInteractions(sessionId, watcher, messages) {
|
|
3439
|
+
const openQuestions = /* @__PURE__ */ new Set();
|
|
3440
|
+
const openPermissions = /* @__PURE__ */ new Set();
|
|
3441
|
+
let questionsPolledOk = true;
|
|
3442
|
+
let permissionsPolledOk = true;
|
|
2945
3443
|
let questions = [];
|
|
2946
3444
|
try {
|
|
2947
3445
|
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2948
3446
|
if (res.ok) {
|
|
2949
3447
|
const body = await res.json();
|
|
2950
|
-
|
|
3448
|
+
if (Array.isArray(body)) {
|
|
3449
|
+
questions = body;
|
|
3450
|
+
} else {
|
|
3451
|
+
questionsPolledOk = false;
|
|
3452
|
+
}
|
|
3453
|
+
} else {
|
|
3454
|
+
questionsPolledOk = false;
|
|
2951
3455
|
}
|
|
2952
3456
|
} catch {
|
|
3457
|
+
questionsPolledOk = false;
|
|
2953
3458
|
}
|
|
2954
3459
|
for (const q of questions) {
|
|
2955
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2956
3460
|
if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
|
|
2957
3461
|
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
3462
|
+
if (paused) openQuestions.add(paused.evidentMessageId);
|
|
3463
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2958
3464
|
const reported = await this.reportInteraction(
|
|
2959
3465
|
watcher.conv.id,
|
|
2960
3466
|
"question",
|
|
@@ -2968,14 +3474,22 @@ var ChannelDriver = class {
|
|
|
2968
3474
|
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2969
3475
|
if (res.ok) {
|
|
2970
3476
|
const body = await res.json();
|
|
2971
|
-
|
|
3477
|
+
if (Array.isArray(body)) {
|
|
3478
|
+
permissions = body;
|
|
3479
|
+
} else {
|
|
3480
|
+
permissionsPolledOk = false;
|
|
3481
|
+
}
|
|
3482
|
+
} else {
|
|
3483
|
+
permissionsPolledOk = false;
|
|
2972
3484
|
}
|
|
2973
3485
|
} catch {
|
|
3486
|
+
permissionsPolledOk = false;
|
|
2974
3487
|
}
|
|
2975
3488
|
for (const p of permissions) {
|
|
2976
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2977
3489
|
if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
|
|
2978
3490
|
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
3491
|
+
if (paused) openPermissions.add(paused.evidentMessageId);
|
|
3492
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2979
3493
|
const reported = await this.reportInteraction(
|
|
2980
3494
|
watcher.conv.id,
|
|
2981
3495
|
"permission",
|
|
@@ -2984,6 +3498,7 @@ var ChannelDriver = class {
|
|
|
2984
3498
|
);
|
|
2985
3499
|
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2986
3500
|
}
|
|
3501
|
+
return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
|
|
2987
3502
|
}
|
|
2988
3503
|
/**
|
|
2989
3504
|
* True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
|
|
@@ -3029,6 +3544,128 @@ var ChannelDriver = class {
|
|
|
3029
3544
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3030
3545
|
return parent;
|
|
3031
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3549
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3550
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3551
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3552
|
+
* Best-effort:
|
|
3553
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3554
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3555
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3556
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3557
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3558
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3559
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3560
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3561
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3562
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3563
|
+
*/
|
|
3564
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3565
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3566
|
+
if (cached != null) return cached;
|
|
3567
|
+
try {
|
|
3568
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3569
|
+
if (res.ok) {
|
|
3570
|
+
const body = await res.json();
|
|
3571
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3572
|
+
if (title.length > 0) {
|
|
3573
|
+
this.sessionTitles.set(sessionId, title);
|
|
3574
|
+
return title;
|
|
3575
|
+
}
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
this.log({
|
|
3579
|
+
level: "debug",
|
|
3580
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3581
|
+
conversation_id: conversationId
|
|
3582
|
+
});
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
this.log({
|
|
3585
|
+
level: "debug",
|
|
3586
|
+
message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
|
|
3587
|
+
conversation_id: conversationId
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
return null;
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3594
|
+
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
3595
|
+
*
|
|
3596
|
+
* The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
|
|
3597
|
+
* runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
|
|
3598
|
+
* completed `finish: "tool-calls"` root reply encountered during re-adoption is
|
|
3599
|
+
* idle by OpenCode's own definition and is re-dispatched. This method exists only
|
|
3600
|
+
* so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
|
|
3601
|
+
* provably in flight at the exact moment of recovery.
|
|
3602
|
+
*
|
|
3603
|
+
* "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
|
|
3604
|
+
* ACTIVELY generating — its LAST message is an assistant still mid-generation
|
|
3605
|
+
* (`completed == null`, via `isSessionActivelyGenerating`). An
|
|
3606
|
+
* INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
|
|
3607
|
+
* completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
|
|
3608
|
+
* is generating once the runner is gone), so it does NOT veto. (This is
|
|
3609
|
+
* deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
|
|
3610
|
+
* shapes and would falsely veto — re-hanging the very turn this path recovers.)
|
|
3611
|
+
*
|
|
3612
|
+
* Return contract (encoded so WI-3 need not re-derive it):
|
|
3613
|
+
* - `true` → a descendant is provably, actively generating (veto re-dispatch).
|
|
3614
|
+
* - `false` → descendants exist but none is actively generating (the restart
|
|
3615
|
+
* case), OR no descendant is found at all.
|
|
3616
|
+
* - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
|
|
3617
|
+
*
|
|
3618
|
+
* ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
|
|
3619
|
+
* as `false` and does NOT veto — a restart guarantees no live runner, so an
|
|
3620
|
+
* indeterminate cross-check almost always means "couldn't reach a child that no
|
|
3621
|
+
* longer exists". The inversion lives in the caller; this method just reports
|
|
3622
|
+
* true/false/null faithfully.
|
|
3623
|
+
*
|
|
3624
|
+
* VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
|
|
3625
|
+
* (already proven by the existing child-session interaction tests, via
|
|
3626
|
+
* `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
|
|
3627
|
+
* terminal state. We do NOT depend on any session-level `busy`/`idle` field —
|
|
3628
|
+
* there is none on `GET /session/:id`; OpenCode's busy state is in-memory
|
|
3629
|
+
* `SessionStatus` only.
|
|
3630
|
+
*/
|
|
3631
|
+
async isAnyDescendantSessionAlive(rootSessionId) {
|
|
3632
|
+
const sessions = await listSessions(this.port);
|
|
3633
|
+
if (!sessions) {
|
|
3634
|
+
this.log({
|
|
3635
|
+
level: "warn",
|
|
3636
|
+
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3637
|
+
});
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
for (const candidate of sessions) {
|
|
3641
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
3642
|
+
if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
|
|
3643
|
+
const childMsgs = await getSessionMessages(this.port, candidate.id);
|
|
3644
|
+
if (isSessionActivelyGenerating(childMsgs)) {
|
|
3645
|
+
return true;
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
return false;
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3652
|
+
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
3653
|
+
* - `b1` — the reply itself is still in flight (`time.completed == null`) —
|
|
3654
|
+
* the aborted-in-flight production bug after a restart.
|
|
3655
|
+
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
3656
|
+
* (the sub-agent preamble — #253's shape).
|
|
3657
|
+
* - `other` — any other shape (defensive; a running row is normally b1 or b2).
|
|
3658
|
+
* Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
|
|
3659
|
+
* shape) directly rather than re-importing the module-private `completedOf`/
|
|
3660
|
+
* `finishOf` — this is a display label only, not a correctness predicate.
|
|
3661
|
+
*/
|
|
3662
|
+
replyCompletionShape(reply) {
|
|
3663
|
+
if (!reply) return "other";
|
|
3664
|
+
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
3665
|
+
if (completed == null) return "b1";
|
|
3666
|
+
const finish = reply.info?.finish ?? reply.finish;
|
|
3667
|
+
return finish === "tool-calls" ? "b2" : "other";
|
|
3668
|
+
}
|
|
3032
3669
|
/**
|
|
3033
3670
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
3034
3671
|
*
|
|
@@ -3079,9 +3716,7 @@ var ChannelDriver = class {
|
|
|
3079
3716
|
}
|
|
3080
3717
|
return inFlight.sort(byOldest)[0];
|
|
3081
3718
|
}
|
|
3082
|
-
// -------------------------------------------------------------------------
|
|
3083
3719
|
// Evident API calls (combinedAuth thread routes)
|
|
3084
|
-
// -------------------------------------------------------------------------
|
|
3085
3720
|
async getPendingConversations() {
|
|
3086
3721
|
const res = await this.fetchImpl(
|
|
3087
3722
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -3161,7 +3796,7 @@ var ChannelDriver = class {
|
|
|
3161
3796
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3162
3797
|
* retry vehicle for the swap-to-running.
|
|
3163
3798
|
*/
|
|
3164
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3799
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3165
3800
|
const res = await this.fetchImpl(
|
|
3166
3801
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3167
3802
|
{
|
|
@@ -3170,7 +3805,8 @@ var ChannelDriver = class {
|
|
|
3170
3805
|
body: JSON.stringify({
|
|
3171
3806
|
status: "processing",
|
|
3172
3807
|
opencode_session_id: sessionId,
|
|
3173
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3808
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
|
+
...title ? { title } : {}
|
|
3174
3810
|
})
|
|
3175
3811
|
}
|
|
3176
3812
|
);
|
|
@@ -3209,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
3209
3845
|
* watcher retries next tick within the
|
|
3210
3846
|
* deadline, Finding 4).
|
|
3211
3847
|
*/
|
|
3212
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3848
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3213
3849
|
const res = await this.fetchImpl(
|
|
3214
3850
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3215
3851
|
{
|
|
@@ -3218,7 +3854,8 @@ var ChannelDriver = class {
|
|
|
3218
3854
|
body: JSON.stringify({
|
|
3219
3855
|
status: "done",
|
|
3220
3856
|
opencode_session_id: sessionId,
|
|
3221
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3857
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
+
...title ? { title } : {}
|
|
3222
3859
|
})
|
|
3223
3860
|
}
|
|
3224
3861
|
);
|
|
@@ -3260,6 +3897,12 @@ var ChannelDriver = class {
|
|
|
3260
3897
|
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
3261
3898
|
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
3262
3899
|
* context (no silent catch, per development-workflow).
|
|
3900
|
+
*
|
|
3901
|
+
* Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
|
|
3902
|
+
* telemetry), but the `paused` liveness-clear uses it to know whether to
|
|
3903
|
+
* RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
|
|
3904
|
+
* stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
|
|
3905
|
+
* leaves liveness").
|
|
3263
3906
|
*/
|
|
3264
3907
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3265
3908
|
try {
|
|
@@ -3273,19 +3916,22 @@ var ChannelDriver = class {
|
|
|
3273
3916
|
);
|
|
3274
3917
|
if (!res.ok) {
|
|
3275
3918
|
this.log({
|
|
3276
|
-
level: "
|
|
3919
|
+
level: "warn",
|
|
3277
3920
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3278
3921
|
conversation_id: conversationId,
|
|
3279
3922
|
message_id: messageId
|
|
3280
3923
|
});
|
|
3924
|
+
return false;
|
|
3281
3925
|
}
|
|
3926
|
+
return true;
|
|
3282
3927
|
} catch (err) {
|
|
3283
3928
|
this.log({
|
|
3284
|
-
level: "
|
|
3929
|
+
level: "warn",
|
|
3285
3930
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3286
3931
|
conversation_id: conversationId,
|
|
3287
3932
|
message_id: messageId
|
|
3288
3933
|
});
|
|
3934
|
+
return false;
|
|
3289
3935
|
}
|
|
3290
3936
|
}
|
|
3291
3937
|
async persistSession(conversationId, sessionId) {
|
|
@@ -3342,9 +3988,7 @@ var ChannelDriver = class {
|
|
|
3342
3988
|
return false;
|
|
3343
3989
|
}
|
|
3344
3990
|
}
|
|
3345
|
-
// -------------------------------------------------------------------------
|
|
3346
3991
|
// Retry wrapper
|
|
3347
|
-
// -------------------------------------------------------------------------
|
|
3348
3992
|
/**
|
|
3349
3993
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3350
3994
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3624,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
3624
4268
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3625
4269
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3626
4270
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3627
|
-
function
|
|
4271
|
+
function resolveLogLevel(options) {
|
|
4272
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4273
|
+
const validate = (value, source) => {
|
|
4274
|
+
const normalized = value.trim().toLowerCase();
|
|
4275
|
+
if (!accepted.includes(normalized)) {
|
|
4276
|
+
throw new Error(
|
|
4277
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
return normalized;
|
|
4281
|
+
};
|
|
4282
|
+
if (options.logLevel !== void 0) {
|
|
4283
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4284
|
+
}
|
|
4285
|
+
if (options.verbose) {
|
|
4286
|
+
return "debug";
|
|
4287
|
+
}
|
|
4288
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4289
|
+
if (env !== void 0 && env !== "") {
|
|
4290
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4291
|
+
}
|
|
4292
|
+
return "info";
|
|
4293
|
+
}
|
|
4294
|
+
function meetsThreshold(state, level) {
|
|
4295
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4296
|
+
}
|
|
4297
|
+
function log2(state, message, level = "info") {
|
|
4298
|
+
if (!meetsThreshold(state, level)) return;
|
|
3628
4299
|
if (state.json) {
|
|
3629
4300
|
console.log(
|
|
3630
4301
|
JSON.stringify({
|
|
3631
4302
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3632
|
-
level
|
|
4303
|
+
level,
|
|
3633
4304
|
message
|
|
3634
4305
|
})
|
|
3635
4306
|
);
|
|
3636
4307
|
} else if (!state.interactive) {
|
|
3637
|
-
const prefix =
|
|
4308
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3638
4309
|
console.log(`${prefix} ${message}`);
|
|
3639
4310
|
}
|
|
3640
4311
|
}
|
|
3641
4312
|
function logActivity(state, entry) {
|
|
4313
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
|
+
if (!meetsThreshold(state, level)) return;
|
|
3642
4315
|
const fullEntry = {
|
|
3643
4316
|
...entry,
|
|
4317
|
+
level,
|
|
3644
4318
|
timestamp: /* @__PURE__ */ new Date()
|
|
3645
4319
|
};
|
|
3646
4320
|
state.activityLog.push(fullEntry);
|
|
@@ -3649,9 +4323,9 @@ function logActivity(state, entry) {
|
|
|
3649
4323
|
}
|
|
3650
4324
|
if (!state.interactive) {
|
|
3651
4325
|
if (entry.type === "error") {
|
|
3652
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3653
|
-
} else if (entry.
|
|
3654
|
-
log2(state, entry.message);
|
|
4326
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4327
|
+
} else if (entry.message) {
|
|
4328
|
+
log2(state, entry.message, level);
|
|
3655
4329
|
}
|
|
3656
4330
|
}
|
|
3657
4331
|
}
|
|
@@ -3850,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
3850
4524
|
process.env
|
|
3851
4525
|
);
|
|
3852
4526
|
for (const warning2 of config2.warnings) {
|
|
3853
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4527
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
3854
4528
|
}
|
|
3855
4529
|
if (!config2.enabled) return;
|
|
3856
4530
|
logActivity(state, {
|
|
@@ -3922,6 +4596,20 @@ async function cleanup(state, opts = {}) {
|
|
|
3922
4596
|
}
|
|
3923
4597
|
async function run(options) {
|
|
3924
4598
|
const interactive = isInteractive(options.json);
|
|
4599
|
+
let logLevel;
|
|
4600
|
+
try {
|
|
4601
|
+
logLevel = resolveLogLevel(options);
|
|
4602
|
+
} catch (error2) {
|
|
4603
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4604
|
+
if (options.json) {
|
|
4605
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4606
|
+
} else {
|
|
4607
|
+
printError(message);
|
|
4608
|
+
}
|
|
4609
|
+
await shutdownTelemetry();
|
|
4610
|
+
process.exit(1);
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
3925
4613
|
const state = {
|
|
3926
4614
|
agentId: options.agent || "",
|
|
3927
4615
|
agentName: null,
|
|
@@ -3930,6 +4618,7 @@ async function run(options) {
|
|
|
3930
4618
|
idleTimeout: options.idleTimeout ?? null,
|
|
3931
4619
|
json: options.json ?? false,
|
|
3932
4620
|
interactive,
|
|
4621
|
+
logLevel,
|
|
3933
4622
|
connected: false,
|
|
3934
4623
|
opencodeConnected: false,
|
|
3935
4624
|
opencodeVersion: null,
|
|
@@ -3947,8 +4636,8 @@ async function run(options) {
|
|
|
3947
4636
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
3948
4637
|
log2(
|
|
3949
4638
|
state,
|
|
3950
|
-
"
|
|
3951
|
-
|
|
4639
|
+
"No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
|
|
4640
|
+
"warn"
|
|
3952
4641
|
);
|
|
3953
4642
|
}
|
|
3954
4643
|
const handleSignal = async () => {
|
|
@@ -4064,9 +4753,9 @@ async function run(options) {
|
|
|
4064
4753
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4065
4754
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4066
4755
|
if (versionWarning) {
|
|
4067
|
-
log2(state, versionWarning,
|
|
4756
|
+
log2(state, versionWarning, "warn");
|
|
4068
4757
|
if (state.interactive && !state.json) {
|
|
4069
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4758
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
4070
4759
|
}
|
|
4071
4760
|
}
|
|
4072
4761
|
} catch (error2) {
|
|
@@ -4081,11 +4770,17 @@ async function run(options) {
|
|
|
4081
4770
|
getAuthHeader: () => state.authHeader,
|
|
4082
4771
|
conversationFilter: state.conversationFilter,
|
|
4083
4772
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4084
|
-
log: (entry) =>
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4773
|
+
log: (entry) => (
|
|
4774
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4775
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4776
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4777
|
+
logActivity(state, {
|
|
4778
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4779
|
+
level: entry.level,
|
|
4780
|
+
message: entry.message,
|
|
4781
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4782
|
+
})
|
|
4783
|
+
)
|
|
4089
4784
|
});
|
|
4090
4785
|
state.channelDriver = channelDriver;
|
|
4091
4786
|
const connection = new RunnerConnection({
|
|
@@ -4239,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4239
4934
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
4240
4935
|
program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
|
|
4241
4936
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
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(
|
|
4937
|
+
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(
|
|
4938
|
+
"--log-level <level>",
|
|
4939
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
4940
|
+
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
|
|
4243
4941
|
"--session-cleanup-max-age <duration>",
|
|
4244
4942
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4245
4943
|
).option(
|
|
@@ -4253,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4253
4951
|
run({
|
|
4254
4952
|
agent: options.agent,
|
|
4255
4953
|
port: parseInt(options.port, 10),
|
|
4954
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
4955
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
4956
|
+
logLevel: options.logLevel,
|
|
4256
4957
|
verbose: options.verbose,
|
|
4257
4958
|
conversation: options.conversation,
|
|
4258
4959
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|