@evident-ai/cli 3.0.1-dev.39d9b5f → 3.0.1-dev.3bffad2
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 +637 -85
- 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,
|
|
@@ -1779,6 +1930,7 @@ 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;
|
|
1781
1932
|
var HEARTBEAT_MS = 6e4;
|
|
1933
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1782
1934
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1783
1935
|
var ChannelAuthError = class extends Error {
|
|
1784
1936
|
constructor(message) {
|
|
@@ -1876,6 +2028,15 @@ var ChannelDriver = class {
|
|
|
1876
2028
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1877
2029
|
*/
|
|
1878
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();
|
|
1879
2040
|
/**
|
|
1880
2041
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1881
2042
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -1890,6 +2051,15 @@ var ChannelDriver = class {
|
|
|
1890
2051
|
* so the NEXT tick may retry exactly once more).
|
|
1891
2052
|
*/
|
|
1892
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();
|
|
1893
2063
|
/**
|
|
1894
2064
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1895
2065
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1907,6 +2077,16 @@ var ChannelDriver = class {
|
|
|
1907
2077
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1908
2078
|
*/
|
|
1909
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();
|
|
1910
2090
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1911
2091
|
draining = false;
|
|
1912
2092
|
/**
|
|
@@ -1944,9 +2124,6 @@ var ChannelDriver = class {
|
|
|
1944
2124
|
get opencodeBase() {
|
|
1945
2125
|
return `http://127.0.0.1:${this.port}`;
|
|
1946
2126
|
}
|
|
1947
|
-
// -------------------------------------------------------------------------
|
|
1948
|
-
// Public API
|
|
1949
|
-
// -------------------------------------------------------------------------
|
|
1950
2127
|
/**
|
|
1951
2128
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1952
2129
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2088,9 +2265,7 @@ var ChannelDriver = class {
|
|
|
2088
2265
|
if (!stillLive) return;
|
|
2089
2266
|
}
|
|
2090
2267
|
}
|
|
2091
|
-
// -------------------------------------------------------------------------
|
|
2092
2268
|
// Conversation processing (WI-3 — async dispatch)
|
|
2093
|
-
// -------------------------------------------------------------------------
|
|
2094
2269
|
/**
|
|
2095
2270
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2096
2271
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2122,9 +2297,10 @@ var ChannelDriver = class {
|
|
|
2122
2297
|
conversation_id: conv.id,
|
|
2123
2298
|
message_id: message.id
|
|
2124
2299
|
});
|
|
2300
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2125
2301
|
opencodeMessageId = await this.dispatchLocked(
|
|
2126
2302
|
sessionId,
|
|
2127
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2303
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2128
2304
|
);
|
|
2129
2305
|
} catch (err) {
|
|
2130
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
@@ -2132,7 +2308,7 @@ var ChannelDriver = class {
|
|
|
2132
2308
|
if (await sessionExists(this.port, sessionId) === false) {
|
|
2133
2309
|
this.sessions.delete(conv.id);
|
|
2134
2310
|
this.log({
|
|
2135
|
-
level: "
|
|
2311
|
+
level: "warn",
|
|
2136
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.`,
|
|
2137
2313
|
conversation_id: conv.id,
|
|
2138
2314
|
message_id: message.id
|
|
@@ -2151,7 +2327,7 @@ var ChannelDriver = class {
|
|
|
2151
2327
|
}
|
|
2152
2328
|
if (opencodeMessageId === null) {
|
|
2153
2329
|
this.log({
|
|
2154
|
-
level: "
|
|
2330
|
+
level: "warn",
|
|
2155
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`,
|
|
2156
2332
|
conversation_id: conv.id,
|
|
2157
2333
|
message_id: message.id
|
|
@@ -2165,7 +2341,7 @@ var ChannelDriver = class {
|
|
|
2165
2341
|
}
|
|
2166
2342
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2167
2343
|
this.log({
|
|
2168
|
-
level: "
|
|
2344
|
+
level: "warn",
|
|
2169
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).`,
|
|
2170
2346
|
conversation_id: conv.id
|
|
2171
2347
|
});
|
|
@@ -2179,7 +2355,7 @@ var ChannelDriver = class {
|
|
|
2179
2355
|
const exists = await sessionExists(this.port, bound);
|
|
2180
2356
|
if (exists === false) {
|
|
2181
2357
|
this.log({
|
|
2182
|
-
level: "
|
|
2358
|
+
level: "debug",
|
|
2183
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.`,
|
|
2184
2360
|
conversation_id: conv.id
|
|
2185
2361
|
});
|
|
@@ -2214,15 +2390,13 @@ var ChannelDriver = class {
|
|
|
2214
2390
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2215
2391
|
if (!this.opencodeDirectory) {
|
|
2216
2392
|
this.log({
|
|
2217
|
-
level: "
|
|
2393
|
+
level: "warn",
|
|
2218
2394
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2219
2395
|
});
|
|
2220
2396
|
}
|
|
2221
2397
|
return this.opencodeDirectory;
|
|
2222
2398
|
}
|
|
2223
|
-
// -------------------------------------------------------------------------
|
|
2224
2399
|
// Per-session watcher (WI-3)
|
|
2225
|
-
// -------------------------------------------------------------------------
|
|
2226
2400
|
/**
|
|
2227
2401
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2228
2402
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2242,6 +2416,103 @@ var ChannelDriver = class {
|
|
|
2242
2416
|
);
|
|
2243
2417
|
return run2;
|
|
2244
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
|
+
}
|
|
2245
2516
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2246
2517
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2247
2518
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2263,6 +2534,7 @@ var ChannelDriver = class {
|
|
|
2263
2534
|
opencodeMessageId,
|
|
2264
2535
|
message,
|
|
2265
2536
|
dispatchedAt: now,
|
|
2537
|
+
processingAnchorMs: now,
|
|
2266
2538
|
deadline: now + this.pausedMaxWaitMs,
|
|
2267
2539
|
started: false,
|
|
2268
2540
|
done: false,
|
|
@@ -2321,6 +2593,10 @@ var ChannelDriver = class {
|
|
|
2321
2593
|
opencodeMessageId,
|
|
2322
2594
|
message,
|
|
2323
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,
|
|
2324
2600
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
2325
2601
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
2326
2602
|
started: true,
|
|
@@ -2474,18 +2750,20 @@ var ChannelDriver = class {
|
|
|
2474
2750
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2475
2751
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2476
2752
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2753
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2477
2754
|
let claimed;
|
|
2478
2755
|
try {
|
|
2479
2756
|
claimed = await this.markProcessing(
|
|
2480
2757
|
conv.id,
|
|
2481
2758
|
inFlight.evidentMessageId,
|
|
2482
2759
|
sessionId,
|
|
2483
|
-
inFlight.opencodeMessageId
|
|
2760
|
+
inFlight.opencodeMessageId,
|
|
2761
|
+
title
|
|
2484
2762
|
);
|
|
2485
2763
|
} catch (err) {
|
|
2486
2764
|
if (err instanceof ChannelAuthError) throw err;
|
|
2487
2765
|
this.log({
|
|
2488
|
-
level: "
|
|
2766
|
+
level: "warn",
|
|
2489
2767
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2490
2768
|
conversation_id: conv.id,
|
|
2491
2769
|
message_id: inFlight.evidentMessageId
|
|
@@ -2495,7 +2773,7 @@ var ChannelDriver = class {
|
|
|
2495
2773
|
inFlight.started = true;
|
|
2496
2774
|
if (!claimed) {
|
|
2497
2775
|
this.log({
|
|
2498
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2499
2777
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2500
2778
|
conversation_id: conv.id,
|
|
2501
2779
|
message_id: inFlight.evidentMessageId
|
|
@@ -2511,18 +2789,20 @@ var ChannelDriver = class {
|
|
|
2511
2789
|
conversation_id: conv.id,
|
|
2512
2790
|
message_id: inFlight.evidentMessageId
|
|
2513
2791
|
});
|
|
2792
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2514
2793
|
try {
|
|
2515
2794
|
await this.markDone(
|
|
2516
2795
|
conv.id,
|
|
2517
2796
|
inFlight.evidentMessageId,
|
|
2518
2797
|
sessionId,
|
|
2519
|
-
inFlight.opencodeMessageId
|
|
2798
|
+
inFlight.opencodeMessageId,
|
|
2799
|
+
title
|
|
2520
2800
|
);
|
|
2521
2801
|
} catch (err) {
|
|
2522
2802
|
if (err instanceof ChannelAuthError) throw err;
|
|
2523
2803
|
if (err instanceof ChannelTerminalError) {
|
|
2524
2804
|
this.log({
|
|
2525
|
-
level: "
|
|
2805
|
+
level: "warn",
|
|
2526
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}`,
|
|
2527
2807
|
conversation_id: conv.id,
|
|
2528
2808
|
message_id: inFlight.evidentMessageId
|
|
@@ -2532,7 +2812,7 @@ var ChannelDriver = class {
|
|
|
2532
2812
|
}
|
|
2533
2813
|
if (this.now() >= inFlight.deadline) {
|
|
2534
2814
|
this.log({
|
|
2535
|
-
level: "
|
|
2815
|
+
level: "warn",
|
|
2536
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)}`,
|
|
2537
2817
|
conversation_id: conv.id,
|
|
2538
2818
|
message_id: inFlight.evidentMessageId
|
|
@@ -2541,7 +2821,7 @@ var ChannelDriver = class {
|
|
|
2541
2821
|
return;
|
|
2542
2822
|
}
|
|
2543
2823
|
this.log({
|
|
2544
|
-
level: "
|
|
2824
|
+
level: "warn",
|
|
2545
2825
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
2826
|
conversation_id: conv.id,
|
|
2547
2827
|
message_id: inFlight.evidentMessageId
|
|
@@ -2569,7 +2849,7 @@ var ChannelDriver = class {
|
|
|
2569
2849
|
if (err instanceof ChannelAuthError) throw err;
|
|
2570
2850
|
if (err instanceof ChannelTerminalError) {
|
|
2571
2851
|
this.log({
|
|
2572
|
-
level: "
|
|
2852
|
+
level: "warn",
|
|
2573
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}`,
|
|
2574
2854
|
conversation_id: conv.id,
|
|
2575
2855
|
message_id: inFlight.evidentMessageId
|
|
@@ -2579,7 +2859,7 @@ var ChannelDriver = class {
|
|
|
2579
2859
|
}
|
|
2580
2860
|
if (this.now() >= inFlight.deadline) {
|
|
2581
2861
|
this.log({
|
|
2582
|
-
level: "
|
|
2862
|
+
level: "warn",
|
|
2583
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)}`,
|
|
2584
2864
|
conversation_id: conv.id,
|
|
2585
2865
|
message_id: inFlight.evidentMessageId
|
|
@@ -2588,7 +2868,7 @@ var ChannelDriver = class {
|
|
|
2588
2868
|
return;
|
|
2589
2869
|
}
|
|
2590
2870
|
this.log({
|
|
2591
|
-
level: "
|
|
2871
|
+
level: "warn",
|
|
2592
2872
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2593
2873
|
conversation_id: conv.id,
|
|
2594
2874
|
message_id: inFlight.evidentMessageId
|
|
@@ -2609,6 +2889,19 @@ var ChannelDriver = class {
|
|
|
2609
2889
|
});
|
|
2610
2890
|
}
|
|
2611
2891
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2892
|
+
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2893
|
+
this.log({
|
|
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
|
+
}
|
|
2612
2905
|
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2613
2906
|
inFlight.aliveInFlight = true;
|
|
2614
2907
|
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
@@ -2641,7 +2934,7 @@ var ChannelDriver = class {
|
|
|
2641
2934
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2642
2935
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2643
2936
|
this.log({
|
|
2644
|
-
level: "
|
|
2937
|
+
level: "debug",
|
|
2645
2938
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2646
2939
|
conversation_id: conv.id,
|
|
2647
2940
|
message_id: inFlight.evidentMessageId
|
|
@@ -2652,9 +2945,7 @@ var ChannelDriver = class {
|
|
|
2652
2945
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2653
2946
|
}
|
|
2654
2947
|
}
|
|
2655
|
-
// -------------------------------------------------------------------------
|
|
2656
2948
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2657
|
-
// -------------------------------------------------------------------------
|
|
2658
2949
|
/**
|
|
2659
2950
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2660
2951
|
*
|
|
@@ -2671,15 +2962,20 @@ var ChannelDriver = class {
|
|
|
2671
2962
|
*/
|
|
2672
2963
|
async readoptProcessing() {
|
|
2673
2964
|
const rows = await this.getProcessingMessages();
|
|
2674
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
2965
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2675
2966
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2676
|
-
for (const id of [
|
|
2967
|
+
for (const id of [
|
|
2968
|
+
...this.dontRedispatch,
|
|
2969
|
+
...this.doneUndeliverable,
|
|
2970
|
+
...this.readoptPollUnresolvedSignalled
|
|
2971
|
+
]) {
|
|
2677
2972
|
if (!stillProcessing.has(id)) {
|
|
2678
2973
|
const cleared = this.dontRedispatch.delete(id);
|
|
2679
2974
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
2975
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2680
2976
|
if (cleared || clearedUndeliverable) {
|
|
2681
2977
|
this.log({
|
|
2682
|
-
level: "
|
|
2978
|
+
level: "debug",
|
|
2683
2979
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2684
2980
|
message_id: id
|
|
2685
2981
|
});
|
|
@@ -2692,7 +2988,7 @@ var ChannelDriver = class {
|
|
|
2692
2988
|
for (const row of rows) {
|
|
2693
2989
|
if (!row.opencode_session_id) {
|
|
2694
2990
|
this.log({
|
|
2695
|
-
level: "
|
|
2991
|
+
level: "warn",
|
|
2696
2992
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2697
2993
|
conversation_id: row.conversation_id,
|
|
2698
2994
|
message_id: row.id
|
|
@@ -2709,7 +3005,7 @@ var ChannelDriver = class {
|
|
|
2709
3005
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2710
3006
|
if (!res.ok) {
|
|
2711
3007
|
this.log({
|
|
2712
|
-
level: "
|
|
3008
|
+
level: "warn",
|
|
2713
3009
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2714
3010
|
});
|
|
2715
3011
|
continue;
|
|
@@ -2717,7 +3013,7 @@ var ChannelDriver = class {
|
|
|
2717
3013
|
const body = await res.json();
|
|
2718
3014
|
if (!Array.isArray(body)) {
|
|
2719
3015
|
this.log({
|
|
2720
|
-
level: "
|
|
3016
|
+
level: "warn",
|
|
2721
3017
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2722
3018
|
});
|
|
2723
3019
|
continue;
|
|
@@ -2725,13 +3021,15 @@ var ChannelDriver = class {
|
|
|
2725
3021
|
messages = body;
|
|
2726
3022
|
} catch (err) {
|
|
2727
3023
|
this.log({
|
|
2728
|
-
level: "
|
|
3024
|
+
level: "warn",
|
|
2729
3025
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2730
3026
|
});
|
|
2731
3027
|
continue;
|
|
2732
3028
|
}
|
|
3029
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
3030
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2733
3031
|
for (const row of sessionRows) {
|
|
2734
|
-
await this.readoptOne(sessionId, row, messages);
|
|
3032
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2735
3033
|
}
|
|
2736
3034
|
}
|
|
2737
3035
|
}
|
|
@@ -2753,10 +3051,10 @@ var ChannelDriver = class {
|
|
|
2753
3051
|
*
|
|
2754
3052
|
* Only `ChannelAuthError` propagates.
|
|
2755
3053
|
*/
|
|
2756
|
-
async readoptOne(sessionId, row, messages) {
|
|
3054
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2757
3055
|
if (this.isTracked(sessionId, row.id)) {
|
|
2758
3056
|
this.log({
|
|
2759
|
-
level: "
|
|
3057
|
+
level: "debug",
|
|
2760
3058
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2761
3059
|
conversation_id: row.conversation_id,
|
|
2762
3060
|
message_id: row.id
|
|
@@ -2768,7 +3066,7 @@ var ChannelDriver = class {
|
|
|
2768
3066
|
if (state === "done") {
|
|
2769
3067
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2770
3068
|
this.log({
|
|
2771
|
-
level: "
|
|
3069
|
+
level: "debug",
|
|
2772
3070
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2773
3071
|
conversation_id: row.conversation_id,
|
|
2774
3072
|
message_id: row.id
|
|
@@ -2782,21 +3080,23 @@ var ChannelDriver = class {
|
|
|
2782
3080
|
message_id: row.id
|
|
2783
3081
|
});
|
|
2784
3082
|
try {
|
|
2785
|
-
await this.
|
|
3083
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2786
3085
|
} catch (err) {
|
|
2787
3086
|
if (err instanceof ChannelAuthError) throw err;
|
|
2788
3087
|
if (err instanceof ChannelTerminalError) {
|
|
2789
3088
|
this.doneUndeliverable.add(row.id);
|
|
2790
3089
|
this.log({
|
|
2791
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2792
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}`,
|
|
2793
3092
|
conversation_id: row.conversation_id,
|
|
2794
3093
|
message_id: row.id
|
|
2795
3094
|
});
|
|
3095
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2796
3096
|
return;
|
|
2797
3097
|
}
|
|
2798
3098
|
this.log({
|
|
2799
|
-
level: "
|
|
3099
|
+
level: "warn",
|
|
2800
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)}`,
|
|
2801
3101
|
conversation_id: row.conversation_id,
|
|
2802
3102
|
message_id: row.id
|
|
@@ -2804,6 +3104,7 @@ var ChannelDriver = class {
|
|
|
2804
3104
|
return;
|
|
2805
3105
|
}
|
|
2806
3106
|
this.dontRedispatch.delete(row.id);
|
|
3107
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2807
3108
|
return;
|
|
2808
3109
|
}
|
|
2809
3110
|
if (state === "failed") {
|
|
@@ -2821,15 +3122,16 @@ var ChannelDriver = class {
|
|
|
2821
3122
|
if (err instanceof ChannelTerminalError) {
|
|
2822
3123
|
this.doneUndeliverable.add(row.id);
|
|
2823
3124
|
this.log({
|
|
2824
|
-
level: "
|
|
3125
|
+
level: "warn",
|
|
2825
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}`,
|
|
2826
3127
|
conversation_id: row.conversation_id,
|
|
2827
3128
|
message_id: row.id
|
|
2828
3129
|
});
|
|
3130
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2829
3131
|
return;
|
|
2830
3132
|
}
|
|
2831
3133
|
this.log({
|
|
2832
|
-
level: "
|
|
3134
|
+
level: "warn",
|
|
2833
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)}`,
|
|
2834
3136
|
conversation_id: row.conversation_id,
|
|
2835
3137
|
message_id: row.id
|
|
@@ -2837,17 +3139,83 @@ var ChannelDriver = class {
|
|
|
2837
3139
|
return;
|
|
2838
3140
|
}
|
|
2839
3141
|
this.dontRedispatch.delete(row.id);
|
|
3142
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2840
3143
|
return;
|
|
2841
3144
|
}
|
|
2842
3145
|
if (this.dontRedispatch.has(row.id)) {
|
|
2843
3146
|
this.log({
|
|
2844
|
-
level: "
|
|
3147
|
+
level: "debug",
|
|
2845
3148
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2846
3149
|
conversation_id: row.conversation_id,
|
|
2847
3150
|
message_id: row.id
|
|
2848
3151
|
});
|
|
2849
3152
|
return;
|
|
2850
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
|
+
}
|
|
2851
3219
|
if ((state === "running" || state === "queued") && ocId) {
|
|
2852
3220
|
const conv = this.convForRow(sessionId, row);
|
|
2853
3221
|
const message = this.queuedMessageForRow(row);
|
|
@@ -2856,11 +3224,12 @@ var ChannelDriver = class {
|
|
|
2856
3224
|
this.readopted.add(row.id);
|
|
2857
3225
|
this.ensureWatcherRunning(sessionId);
|
|
2858
3226
|
this.log({
|
|
2859
|
-
level: "
|
|
3227
|
+
level: "debug",
|
|
2860
3228
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2861
3229
|
conversation_id: row.conversation_id,
|
|
2862
3230
|
message_id: row.id
|
|
2863
3231
|
});
|
|
3232
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2864
3233
|
return;
|
|
2865
3234
|
}
|
|
2866
3235
|
await this.forceReadoptRun(sessionId, row);
|
|
@@ -2889,7 +3258,7 @@ var ChannelDriver = class {
|
|
|
2889
3258
|
async forceReadoptRun(sessionId, row) {
|
|
2890
3259
|
if (this.stopped) {
|
|
2891
3260
|
this.log({
|
|
2892
|
-
level: "
|
|
3261
|
+
level: "debug",
|
|
2893
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`,
|
|
2894
3263
|
conversation_id: row.conversation_id,
|
|
2895
3264
|
message_id: row.id
|
|
@@ -2898,7 +3267,7 @@ var ChannelDriver = class {
|
|
|
2898
3267
|
}
|
|
2899
3268
|
if (this.awaitingReadopt.has(row.id)) {
|
|
2900
3269
|
this.log({
|
|
2901
|
-
level: "
|
|
3270
|
+
level: "debug",
|
|
2902
3271
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2903
3272
|
conversation_id: row.conversation_id,
|
|
2904
3273
|
message_id: row.id
|
|
@@ -2908,11 +3277,12 @@ var ChannelDriver = class {
|
|
|
2908
3277
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2909
3278
|
this.dontRedispatch.add(row.id);
|
|
2910
3279
|
this.log({
|
|
2911
|
-
level: "
|
|
3280
|
+
level: "debug",
|
|
2912
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)`,
|
|
2913
3282
|
conversation_id: row.conversation_id,
|
|
2914
3283
|
message_id: row.id
|
|
2915
3284
|
});
|
|
3285
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2916
3286
|
return;
|
|
2917
3287
|
}
|
|
2918
3288
|
const options = {
|
|
@@ -2926,40 +3296,44 @@ var ChannelDriver = class {
|
|
|
2926
3296
|
message_id: row.id
|
|
2927
3297
|
});
|
|
2928
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);
|
|
2929
3302
|
let ocId;
|
|
2930
3303
|
try {
|
|
2931
3304
|
ocId = await this.dispatchLocked(
|
|
2932
3305
|
sessionId,
|
|
2933
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3306
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
2934
3307
|
);
|
|
2935
3308
|
} catch (err) {
|
|
2936
3309
|
this.awaitingReadopt.delete(row.id);
|
|
2937
3310
|
if (err instanceof ChannelAuthError) throw err;
|
|
2938
3311
|
this.log({
|
|
2939
|
-
level: "
|
|
3312
|
+
level: "warn",
|
|
2940
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)}`,
|
|
2941
3314
|
conversation_id: row.conversation_id,
|
|
2942
3315
|
message_id: row.id
|
|
2943
3316
|
});
|
|
3317
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2944
3318
|
return;
|
|
2945
3319
|
}
|
|
2946
3320
|
if (ocId === null) {
|
|
2947
3321
|
this.awaitingReadopt.delete(row.id);
|
|
2948
3322
|
this.log({
|
|
2949
|
-
level: "
|
|
3323
|
+
level: "warn",
|
|
2950
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`,
|
|
2951
3325
|
conversation_id: row.conversation_id,
|
|
2952
3326
|
message_id: row.id
|
|
2953
3327
|
});
|
|
3328
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2954
3329
|
return;
|
|
2955
3330
|
}
|
|
2956
|
-
|
|
2957
|
-
const message = this.queuedMessageForRow(row);
|
|
2958
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3331
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
2959
3332
|
this.dispatched.add(row.id);
|
|
2960
3333
|
this.readopted.add(row.id);
|
|
2961
3334
|
this.awaitingReadopt.delete(row.id);
|
|
2962
3335
|
this.ensureWatcherRunning(sessionId);
|
|
3336
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2963
3337
|
}
|
|
2964
3338
|
/**
|
|
2965
3339
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -3008,7 +3382,8 @@ var ChannelDriver = class {
|
|
|
3008
3382
|
opencode_agent: row.opencode_agent,
|
|
3009
3383
|
opencode_model: row.opencode_model,
|
|
3010
3384
|
source_message_id: row.source_message_id,
|
|
3011
|
-
slack_user_id: row.slack_user_id
|
|
3385
|
+
slack_user_id: row.slack_user_id,
|
|
3386
|
+
attachments: row.attachments ?? null
|
|
3012
3387
|
};
|
|
3013
3388
|
}
|
|
3014
3389
|
/**
|
|
@@ -3029,7 +3404,7 @@ var ChannelDriver = class {
|
|
|
3029
3404
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3030
3405
|
this.dontRedispatch.add(evidentMessageId);
|
|
3031
3406
|
this.log({
|
|
3032
|
-
level: "
|
|
3407
|
+
level: "debug",
|
|
3033
3408
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3034
3409
|
conversation_id: watcher.conv.id,
|
|
3035
3410
|
message_id: evidentMessageId
|
|
@@ -3169,6 +3544,128 @@ var ChannelDriver = class {
|
|
|
3169
3544
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3170
3545
|
return parent;
|
|
3171
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
|
+
}
|
|
3172
3669
|
/**
|
|
3173
3670
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
3174
3671
|
*
|
|
@@ -3219,9 +3716,7 @@ var ChannelDriver = class {
|
|
|
3219
3716
|
}
|
|
3220
3717
|
return inFlight.sort(byOldest)[0];
|
|
3221
3718
|
}
|
|
3222
|
-
// -------------------------------------------------------------------------
|
|
3223
3719
|
// Evident API calls (combinedAuth thread routes)
|
|
3224
|
-
// -------------------------------------------------------------------------
|
|
3225
3720
|
async getPendingConversations() {
|
|
3226
3721
|
const res = await this.fetchImpl(
|
|
3227
3722
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -3301,7 +3796,7 @@ var ChannelDriver = class {
|
|
|
3301
3796
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3302
3797
|
* retry vehicle for the swap-to-running.
|
|
3303
3798
|
*/
|
|
3304
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3799
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3305
3800
|
const res = await this.fetchImpl(
|
|
3306
3801
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3307
3802
|
{
|
|
@@ -3310,7 +3805,8 @@ var ChannelDriver = class {
|
|
|
3310
3805
|
body: JSON.stringify({
|
|
3311
3806
|
status: "processing",
|
|
3312
3807
|
opencode_session_id: sessionId,
|
|
3313
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3808
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
|
+
...title ? { title } : {}
|
|
3314
3810
|
})
|
|
3315
3811
|
}
|
|
3316
3812
|
);
|
|
@@ -3349,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
3349
3845
|
* watcher retries next tick within the
|
|
3350
3846
|
* deadline, Finding 4).
|
|
3351
3847
|
*/
|
|
3352
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3848
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3353
3849
|
const res = await this.fetchImpl(
|
|
3354
3850
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3355
3851
|
{
|
|
@@ -3358,7 +3854,8 @@ var ChannelDriver = class {
|
|
|
3358
3854
|
body: JSON.stringify({
|
|
3359
3855
|
status: "done",
|
|
3360
3856
|
opencode_session_id: sessionId,
|
|
3361
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3857
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
+
...title ? { title } : {}
|
|
3362
3859
|
})
|
|
3363
3860
|
}
|
|
3364
3861
|
);
|
|
@@ -3419,7 +3916,7 @@ var ChannelDriver = class {
|
|
|
3419
3916
|
);
|
|
3420
3917
|
if (!res.ok) {
|
|
3421
3918
|
this.log({
|
|
3422
|
-
level: "
|
|
3919
|
+
level: "warn",
|
|
3423
3920
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3424
3921
|
conversation_id: conversationId,
|
|
3425
3922
|
message_id: messageId
|
|
@@ -3429,7 +3926,7 @@ var ChannelDriver = class {
|
|
|
3429
3926
|
return true;
|
|
3430
3927
|
} catch (err) {
|
|
3431
3928
|
this.log({
|
|
3432
|
-
level: "
|
|
3929
|
+
level: "warn",
|
|
3433
3930
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3434
3931
|
conversation_id: conversationId,
|
|
3435
3932
|
message_id: messageId
|
|
@@ -3491,9 +3988,7 @@ var ChannelDriver = class {
|
|
|
3491
3988
|
return false;
|
|
3492
3989
|
}
|
|
3493
3990
|
}
|
|
3494
|
-
// -------------------------------------------------------------------------
|
|
3495
3991
|
// Retry wrapper
|
|
3496
|
-
// -------------------------------------------------------------------------
|
|
3497
3992
|
/**
|
|
3498
3993
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3499
3994
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3773,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
3773
4268
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3774
4269
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3775
4270
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3776
|
-
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;
|
|
3777
4299
|
if (state.json) {
|
|
3778
4300
|
console.log(
|
|
3779
4301
|
JSON.stringify({
|
|
3780
4302
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3781
|
-
level
|
|
4303
|
+
level,
|
|
3782
4304
|
message
|
|
3783
4305
|
})
|
|
3784
4306
|
);
|
|
3785
4307
|
} else if (!state.interactive) {
|
|
3786
|
-
const prefix =
|
|
4308
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3787
4309
|
console.log(`${prefix} ${message}`);
|
|
3788
4310
|
}
|
|
3789
4311
|
}
|
|
3790
4312
|
function logActivity(state, entry) {
|
|
4313
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
|
+
if (!meetsThreshold(state, level)) return;
|
|
3791
4315
|
const fullEntry = {
|
|
3792
4316
|
...entry,
|
|
4317
|
+
level,
|
|
3793
4318
|
timestamp: /* @__PURE__ */ new Date()
|
|
3794
4319
|
};
|
|
3795
4320
|
state.activityLog.push(fullEntry);
|
|
@@ -3798,9 +4323,9 @@ function logActivity(state, entry) {
|
|
|
3798
4323
|
}
|
|
3799
4324
|
if (!state.interactive) {
|
|
3800
4325
|
if (entry.type === "error") {
|
|
3801
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3802
|
-
} else if (entry.
|
|
3803
|
-
log2(state, entry.message);
|
|
4326
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4327
|
+
} else if (entry.message) {
|
|
4328
|
+
log2(state, entry.message, level);
|
|
3804
4329
|
}
|
|
3805
4330
|
}
|
|
3806
4331
|
}
|
|
@@ -3999,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
3999
4524
|
process.env
|
|
4000
4525
|
);
|
|
4001
4526
|
for (const warning2 of config2.warnings) {
|
|
4002
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4527
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4003
4528
|
}
|
|
4004
4529
|
if (!config2.enabled) return;
|
|
4005
4530
|
logActivity(state, {
|
|
@@ -4071,6 +4596,20 @@ async function cleanup(state, opts = {}) {
|
|
|
4071
4596
|
}
|
|
4072
4597
|
async function run(options) {
|
|
4073
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
|
+
}
|
|
4074
4613
|
const state = {
|
|
4075
4614
|
agentId: options.agent || "",
|
|
4076
4615
|
agentName: null,
|
|
@@ -4079,6 +4618,7 @@ async function run(options) {
|
|
|
4079
4618
|
idleTimeout: options.idleTimeout ?? null,
|
|
4080
4619
|
json: options.json ?? false,
|
|
4081
4620
|
interactive,
|
|
4621
|
+
logLevel,
|
|
4082
4622
|
connected: false,
|
|
4083
4623
|
opencodeConnected: false,
|
|
4084
4624
|
opencodeVersion: null,
|
|
@@ -4096,8 +4636,8 @@ async function run(options) {
|
|
|
4096
4636
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4097
4637
|
log2(
|
|
4098
4638
|
state,
|
|
4099
|
-
"
|
|
4100
|
-
|
|
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"
|
|
4101
4641
|
);
|
|
4102
4642
|
}
|
|
4103
4643
|
const handleSignal = async () => {
|
|
@@ -4213,9 +4753,9 @@ async function run(options) {
|
|
|
4213
4753
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4214
4754
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4215
4755
|
if (versionWarning) {
|
|
4216
|
-
log2(state, versionWarning,
|
|
4756
|
+
log2(state, versionWarning, "warn");
|
|
4217
4757
|
if (state.interactive && !state.json) {
|
|
4218
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4758
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
4219
4759
|
}
|
|
4220
4760
|
}
|
|
4221
4761
|
} catch (error2) {
|
|
@@ -4230,11 +4770,17 @@ async function run(options) {
|
|
|
4230
4770
|
getAuthHeader: () => state.authHeader,
|
|
4231
4771
|
conversationFilter: state.conversationFilter,
|
|
4232
4772
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4233
|
-
log: (entry) =>
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
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
|
+
)
|
|
4238
4784
|
});
|
|
4239
4785
|
state.channelDriver = channelDriver;
|
|
4240
4786
|
const connection = new RunnerConnection({
|
|
@@ -4388,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4388
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);
|
|
4389
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 }));
|
|
4390
4936
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4391
|
-
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(
|
|
4392
4941
|
"--session-cleanup-max-age <duration>",
|
|
4393
4942
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4394
4943
|
).option(
|
|
@@ -4402,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4402
4951
|
run({
|
|
4403
4952
|
agent: options.agent,
|
|
4404
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,
|
|
4405
4957
|
verbose: options.verbose,
|
|
4406
4958
|
conversation: options.conversation,
|
|
4407
4959
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|