@evident-ai/cli 3.0.1-dev.7272711 → 3.0.1-dev.79d2a02
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 +551 -88
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1126,6 +1126,36 @@ async function sessionExists(port, id) {
|
|
|
1126
1126
|
return null;
|
|
1127
1127
|
}
|
|
1128
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
|
+
}
|
|
1129
1159
|
async function createOpenCodeSession(port, directory) {
|
|
1130
1160
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1131
1161
|
if (directory && directory.trim()) {
|
|
@@ -1143,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1143
1173
|
const data = await response.json();
|
|
1144
1174
|
return data.id;
|
|
1145
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
|
+
}
|
|
1146
1260
|
function messageText(m) {
|
|
1147
1261
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1148
1262
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1149
1263
|
}
|
|
1150
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1264
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1151
1265
|
const before = await getSessionMessages(port, sessionId);
|
|
1152
1266
|
const knownUserIds = new Set(
|
|
1153
1267
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1154
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
|
+
}
|
|
1155
1281
|
const body = {
|
|
1156
|
-
parts
|
|
1282
|
+
parts
|
|
1157
1283
|
};
|
|
1158
1284
|
if (options?.agent) {
|
|
1159
1285
|
body.agent = options.agent;
|
|
@@ -1192,7 +1318,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1192
1318
|
best = { id, created };
|
|
1193
1319
|
}
|
|
1194
1320
|
}
|
|
1195
|
-
if (best)
|
|
1321
|
+
if (best) {
|
|
1322
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1323
|
+
return best.id;
|
|
1324
|
+
}
|
|
1196
1325
|
}
|
|
1197
1326
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1198
1327
|
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
@@ -1781,6 +1910,17 @@ function messageIdOf(m) {
|
|
|
1781
1910
|
const infoId = m.info?.id;
|
|
1782
1911
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1783
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
|
+
};
|
|
1784
1924
|
var DEFAULT_RETRY_POLICY = {
|
|
1785
1925
|
maxAttempts: 6,
|
|
1786
1926
|
baseDelayMs: 500,
|
|
@@ -1790,6 +1930,7 @@ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
|
1790
1930
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1791
1931
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1792
1932
|
var HEARTBEAT_MS = 6e4;
|
|
1933
|
+
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1793
1934
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
1794
1935
|
var ChannelAuthError = class extends Error {
|
|
1795
1936
|
constructor(message) {
|
|
@@ -1887,6 +2028,15 @@ var ChannelDriver = class {
|
|
|
1887
2028
|
* the row leaves the processing list, exactly like `dontRedispatch`.
|
|
1888
2029
|
*/
|
|
1889
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();
|
|
1890
2040
|
/**
|
|
1891
2041
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
1892
2042
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -1901,6 +2051,15 @@ var ChannelDriver = class {
|
|
|
1901
2051
|
* so the NEXT tick may retry exactly once more).
|
|
1902
2052
|
*/
|
|
1903
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();
|
|
1904
2063
|
/**
|
|
1905
2064
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1906
2065
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1918,6 +2077,16 @@ var ChannelDriver = class {
|
|
|
1918
2077
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1919
2078
|
*/
|
|
1920
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();
|
|
1921
2090
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1922
2091
|
draining = false;
|
|
1923
2092
|
/**
|
|
@@ -1955,9 +2124,6 @@ var ChannelDriver = class {
|
|
|
1955
2124
|
get opencodeBase() {
|
|
1956
2125
|
return `http://127.0.0.1:${this.port}`;
|
|
1957
2126
|
}
|
|
1958
|
-
// -------------------------------------------------------------------------
|
|
1959
|
-
// Public API
|
|
1960
|
-
// -------------------------------------------------------------------------
|
|
1961
2127
|
/**
|
|
1962
2128
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1963
2129
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2099,9 +2265,7 @@ var ChannelDriver = class {
|
|
|
2099
2265
|
if (!stillLive) return;
|
|
2100
2266
|
}
|
|
2101
2267
|
}
|
|
2102
|
-
// -------------------------------------------------------------------------
|
|
2103
2268
|
// Conversation processing (WI-3 — async dispatch)
|
|
2104
|
-
// -------------------------------------------------------------------------
|
|
2105
2269
|
/**
|
|
2106
2270
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2107
2271
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2133,9 +2297,10 @@ var ChannelDriver = class {
|
|
|
2133
2297
|
conversation_id: conv.id,
|
|
2134
2298
|
message_id: message.id
|
|
2135
2299
|
});
|
|
2300
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2136
2301
|
opencodeMessageId = await this.dispatchLocked(
|
|
2137
2302
|
sessionId,
|
|
2138
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2303
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2139
2304
|
);
|
|
2140
2305
|
} catch (err) {
|
|
2141
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
@@ -2143,7 +2308,7 @@ var ChannelDriver = class {
|
|
|
2143
2308
|
if (await sessionExists(this.port, sessionId) === false) {
|
|
2144
2309
|
this.sessions.delete(conv.id);
|
|
2145
2310
|
this.log({
|
|
2146
|
-
level: "
|
|
2311
|
+
level: "warn",
|
|
2147
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.`,
|
|
2148
2313
|
conversation_id: conv.id,
|
|
2149
2314
|
message_id: message.id
|
|
@@ -2162,7 +2327,7 @@ var ChannelDriver = class {
|
|
|
2162
2327
|
}
|
|
2163
2328
|
if (opencodeMessageId === null) {
|
|
2164
2329
|
this.log({
|
|
2165
|
-
level: "
|
|
2330
|
+
level: "warn",
|
|
2166
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`,
|
|
2167
2332
|
conversation_id: conv.id,
|
|
2168
2333
|
message_id: message.id
|
|
@@ -2176,7 +2341,7 @@ var ChannelDriver = class {
|
|
|
2176
2341
|
}
|
|
2177
2342
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2178
2343
|
this.log({
|
|
2179
|
-
level: "
|
|
2344
|
+
level: "warn",
|
|
2180
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).`,
|
|
2181
2346
|
conversation_id: conv.id
|
|
2182
2347
|
});
|
|
@@ -2190,7 +2355,7 @@ var ChannelDriver = class {
|
|
|
2190
2355
|
const exists = await sessionExists(this.port, bound);
|
|
2191
2356
|
if (exists === false) {
|
|
2192
2357
|
this.log({
|
|
2193
|
-
level: "
|
|
2358
|
+
level: "debug",
|
|
2194
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.`,
|
|
2195
2360
|
conversation_id: conv.id
|
|
2196
2361
|
});
|
|
@@ -2225,15 +2390,13 @@ var ChannelDriver = class {
|
|
|
2225
2390
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2226
2391
|
if (!this.opencodeDirectory) {
|
|
2227
2392
|
this.log({
|
|
2228
|
-
level: "
|
|
2393
|
+
level: "warn",
|
|
2229
2394
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2230
2395
|
});
|
|
2231
2396
|
}
|
|
2232
2397
|
return this.opencodeDirectory;
|
|
2233
2398
|
}
|
|
2234
|
-
// -------------------------------------------------------------------------
|
|
2235
2399
|
// Per-session watcher (WI-3)
|
|
2236
|
-
// -------------------------------------------------------------------------
|
|
2237
2400
|
/**
|
|
2238
2401
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2239
2402
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2253,6 +2416,103 @@ var ChannelDriver = class {
|
|
|
2253
2416
|
);
|
|
2254
2417
|
return run2;
|
|
2255
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
|
+
}
|
|
2256
2516
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2257
2517
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2258
2518
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2274,6 +2534,7 @@ var ChannelDriver = class {
|
|
|
2274
2534
|
opencodeMessageId,
|
|
2275
2535
|
message,
|
|
2276
2536
|
dispatchedAt: now,
|
|
2537
|
+
processingAnchorMs: now,
|
|
2277
2538
|
deadline: now + this.pausedMaxWaitMs,
|
|
2278
2539
|
started: false,
|
|
2279
2540
|
done: false,
|
|
@@ -2332,6 +2593,10 @@ var ChannelDriver = class {
|
|
|
2332
2593
|
opencodeMessageId,
|
|
2333
2594
|
message,
|
|
2334
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,
|
|
2335
2600
|
deadline: processedAtMs + this.pausedMaxWaitMs,
|
|
2336
2601
|
// The server row is ALREADY `processing`; do not re-fire markProcessing.
|
|
2337
2602
|
started: true,
|
|
@@ -2485,18 +2750,20 @@ var ChannelDriver = class {
|
|
|
2485
2750
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2486
2751
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2487
2752
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2753
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2488
2754
|
let claimed;
|
|
2489
2755
|
try {
|
|
2490
2756
|
claimed = await this.markProcessing(
|
|
2491
2757
|
conv.id,
|
|
2492
2758
|
inFlight.evidentMessageId,
|
|
2493
2759
|
sessionId,
|
|
2494
|
-
inFlight.opencodeMessageId
|
|
2760
|
+
inFlight.opencodeMessageId,
|
|
2761
|
+
title
|
|
2495
2762
|
);
|
|
2496
2763
|
} catch (err) {
|
|
2497
2764
|
if (err instanceof ChannelAuthError) throw err;
|
|
2498
2765
|
this.log({
|
|
2499
|
-
level: "
|
|
2766
|
+
level: "warn",
|
|
2500
2767
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2501
2768
|
conversation_id: conv.id,
|
|
2502
2769
|
message_id: inFlight.evidentMessageId
|
|
@@ -2506,7 +2773,7 @@ var ChannelDriver = class {
|
|
|
2506
2773
|
inFlight.started = true;
|
|
2507
2774
|
if (!claimed) {
|
|
2508
2775
|
this.log({
|
|
2509
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2510
2777
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2511
2778
|
conversation_id: conv.id,
|
|
2512
2779
|
message_id: inFlight.evidentMessageId
|
|
@@ -2522,18 +2789,20 @@ var ChannelDriver = class {
|
|
|
2522
2789
|
conversation_id: conv.id,
|
|
2523
2790
|
message_id: inFlight.evidentMessageId
|
|
2524
2791
|
});
|
|
2792
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2525
2793
|
try {
|
|
2526
2794
|
await this.markDone(
|
|
2527
2795
|
conv.id,
|
|
2528
2796
|
inFlight.evidentMessageId,
|
|
2529
2797
|
sessionId,
|
|
2530
|
-
inFlight.opencodeMessageId
|
|
2798
|
+
inFlight.opencodeMessageId,
|
|
2799
|
+
title
|
|
2531
2800
|
);
|
|
2532
2801
|
} catch (err) {
|
|
2533
2802
|
if (err instanceof ChannelAuthError) throw err;
|
|
2534
2803
|
if (err instanceof ChannelTerminalError) {
|
|
2535
2804
|
this.log({
|
|
2536
|
-
level: "
|
|
2805
|
+
level: "warn",
|
|
2537
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}`,
|
|
2538
2807
|
conversation_id: conv.id,
|
|
2539
2808
|
message_id: inFlight.evidentMessageId
|
|
@@ -2543,7 +2812,7 @@ var ChannelDriver = class {
|
|
|
2543
2812
|
}
|
|
2544
2813
|
if (this.now() >= inFlight.deadline) {
|
|
2545
2814
|
this.log({
|
|
2546
|
-
level: "
|
|
2815
|
+
level: "warn",
|
|
2547
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)}`,
|
|
2548
2817
|
conversation_id: conv.id,
|
|
2549
2818
|
message_id: inFlight.evidentMessageId
|
|
@@ -2552,7 +2821,7 @@ var ChannelDriver = class {
|
|
|
2552
2821
|
return;
|
|
2553
2822
|
}
|
|
2554
2823
|
this.log({
|
|
2555
|
-
level: "
|
|
2824
|
+
level: "warn",
|
|
2556
2825
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2557
2826
|
conversation_id: conv.id,
|
|
2558
2827
|
message_id: inFlight.evidentMessageId
|
|
@@ -2580,7 +2849,7 @@ var ChannelDriver = class {
|
|
|
2580
2849
|
if (err instanceof ChannelAuthError) throw err;
|
|
2581
2850
|
if (err instanceof ChannelTerminalError) {
|
|
2582
2851
|
this.log({
|
|
2583
|
-
level: "
|
|
2852
|
+
level: "warn",
|
|
2584
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}`,
|
|
2585
2854
|
conversation_id: conv.id,
|
|
2586
2855
|
message_id: inFlight.evidentMessageId
|
|
@@ -2590,7 +2859,7 @@ var ChannelDriver = class {
|
|
|
2590
2859
|
}
|
|
2591
2860
|
if (this.now() >= inFlight.deadline) {
|
|
2592
2861
|
this.log({
|
|
2593
|
-
level: "
|
|
2862
|
+
level: "warn",
|
|
2594
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)}`,
|
|
2595
2864
|
conversation_id: conv.id,
|
|
2596
2865
|
message_id: inFlight.evidentMessageId
|
|
@@ -2599,7 +2868,7 @@ var ChannelDriver = class {
|
|
|
2599
2868
|
return;
|
|
2600
2869
|
}
|
|
2601
2870
|
this.log({
|
|
2602
|
-
level: "
|
|
2871
|
+
level: "warn",
|
|
2603
2872
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2604
2873
|
conversation_id: conv.id,
|
|
2605
2874
|
message_id: inFlight.evidentMessageId
|
|
@@ -2620,6 +2889,19 @@ var ChannelDriver = class {
|
|
|
2620
2889
|
});
|
|
2621
2890
|
}
|
|
2622
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
|
+
}
|
|
2623
2905
|
if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
|
|
2624
2906
|
inFlight.aliveInFlight = true;
|
|
2625
2907
|
void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
|
|
@@ -2652,7 +2934,7 @@ var ChannelDriver = class {
|
|
|
2652
2934
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2653
2935
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2654
2936
|
this.log({
|
|
2655
|
-
level: "
|
|
2937
|
+
level: "debug",
|
|
2656
2938
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2657
2939
|
conversation_id: conv.id,
|
|
2658
2940
|
message_id: inFlight.evidentMessageId
|
|
@@ -2663,9 +2945,7 @@ var ChannelDriver = class {
|
|
|
2663
2945
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2664
2946
|
}
|
|
2665
2947
|
}
|
|
2666
|
-
// -------------------------------------------------------------------------
|
|
2667
2948
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2668
|
-
// -------------------------------------------------------------------------
|
|
2669
2949
|
/**
|
|
2670
2950
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2671
2951
|
*
|
|
@@ -2682,15 +2962,20 @@ var ChannelDriver = class {
|
|
|
2682
2962
|
*/
|
|
2683
2963
|
async readoptProcessing() {
|
|
2684
2964
|
const rows = await this.getProcessingMessages();
|
|
2685
|
-
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
|
|
2965
|
+
if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
|
|
2686
2966
|
const stillProcessing = new Set(rows.map((r) => r.id));
|
|
2687
|
-
for (const id of [
|
|
2967
|
+
for (const id of [
|
|
2968
|
+
...this.dontRedispatch,
|
|
2969
|
+
...this.doneUndeliverable,
|
|
2970
|
+
...this.readoptPollUnresolvedSignalled
|
|
2971
|
+
]) {
|
|
2688
2972
|
if (!stillProcessing.has(id)) {
|
|
2689
2973
|
const cleared = this.dontRedispatch.delete(id);
|
|
2690
2974
|
const clearedUndeliverable = this.doneUndeliverable.delete(id);
|
|
2975
|
+
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2691
2976
|
if (cleared || clearedUndeliverable) {
|
|
2692
2977
|
this.log({
|
|
2693
|
-
level: "
|
|
2978
|
+
level: "debug",
|
|
2694
2979
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2695
2980
|
message_id: id
|
|
2696
2981
|
});
|
|
@@ -2703,7 +2988,7 @@ var ChannelDriver = class {
|
|
|
2703
2988
|
for (const row of rows) {
|
|
2704
2989
|
if (!row.opencode_session_id) {
|
|
2705
2990
|
this.log({
|
|
2706
|
-
level: "
|
|
2991
|
+
level: "warn",
|
|
2707
2992
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2708
2993
|
conversation_id: row.conversation_id,
|
|
2709
2994
|
message_id: row.id
|
|
@@ -2720,7 +3005,7 @@ var ChannelDriver = class {
|
|
|
2720
3005
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2721
3006
|
if (!res.ok) {
|
|
2722
3007
|
this.log({
|
|
2723
|
-
level: "
|
|
3008
|
+
level: "warn",
|
|
2724
3009
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2725
3010
|
});
|
|
2726
3011
|
continue;
|
|
@@ -2728,7 +3013,7 @@ var ChannelDriver = class {
|
|
|
2728
3013
|
const body = await res.json();
|
|
2729
3014
|
if (!Array.isArray(body)) {
|
|
2730
3015
|
this.log({
|
|
2731
|
-
level: "
|
|
3016
|
+
level: "warn",
|
|
2732
3017
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2733
3018
|
});
|
|
2734
3019
|
continue;
|
|
@@ -2736,13 +3021,15 @@ var ChannelDriver = class {
|
|
|
2736
3021
|
messages = body;
|
|
2737
3022
|
} catch (err) {
|
|
2738
3023
|
this.log({
|
|
2739
|
-
level: "
|
|
3024
|
+
level: "warn",
|
|
2740
3025
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2741
3026
|
});
|
|
2742
3027
|
continue;
|
|
2743
3028
|
}
|
|
3029
|
+
const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
|
|
3030
|
+
const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
|
|
2744
3031
|
for (const row of sessionRows) {
|
|
2745
|
-
await this.readoptOne(sessionId, row, messages);
|
|
3032
|
+
await this.readoptOne(sessionId, row, messages, sessionOngoing);
|
|
2746
3033
|
}
|
|
2747
3034
|
}
|
|
2748
3035
|
}
|
|
@@ -2764,10 +3051,10 @@ var ChannelDriver = class {
|
|
|
2764
3051
|
*
|
|
2765
3052
|
* Only `ChannelAuthError` propagates.
|
|
2766
3053
|
*/
|
|
2767
|
-
async readoptOne(sessionId, row, messages) {
|
|
3054
|
+
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2768
3055
|
if (this.isTracked(sessionId, row.id)) {
|
|
2769
3056
|
this.log({
|
|
2770
|
-
level: "
|
|
3057
|
+
level: "debug",
|
|
2771
3058
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2772
3059
|
conversation_id: row.conversation_id,
|
|
2773
3060
|
message_id: row.id
|
|
@@ -2779,7 +3066,7 @@ var ChannelDriver = class {
|
|
|
2779
3066
|
if (state === "done") {
|
|
2780
3067
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2781
3068
|
this.log({
|
|
2782
|
-
level: "
|
|
3069
|
+
level: "debug",
|
|
2783
3070
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2784
3071
|
conversation_id: row.conversation_id,
|
|
2785
3072
|
message_id: row.id
|
|
@@ -2793,21 +3080,23 @@ var ChannelDriver = class {
|
|
|
2793
3080
|
message_id: row.id
|
|
2794
3081
|
});
|
|
2795
3082
|
try {
|
|
2796
|
-
await this.
|
|
3083
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2797
3085
|
} catch (err) {
|
|
2798
3086
|
if (err instanceof ChannelAuthError) throw err;
|
|
2799
3087
|
if (err instanceof ChannelTerminalError) {
|
|
2800
3088
|
this.doneUndeliverable.add(row.id);
|
|
2801
3089
|
this.log({
|
|
2802
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2803
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}`,
|
|
2804
3092
|
conversation_id: row.conversation_id,
|
|
2805
3093
|
message_id: row.id
|
|
2806
3094
|
});
|
|
3095
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2807
3096
|
return;
|
|
2808
3097
|
}
|
|
2809
3098
|
this.log({
|
|
2810
|
-
level: "
|
|
3099
|
+
level: "warn",
|
|
2811
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)}`,
|
|
2812
3101
|
conversation_id: row.conversation_id,
|
|
2813
3102
|
message_id: row.id
|
|
@@ -2815,6 +3104,7 @@ var ChannelDriver = class {
|
|
|
2815
3104
|
return;
|
|
2816
3105
|
}
|
|
2817
3106
|
this.dontRedispatch.delete(row.id);
|
|
3107
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
2818
3108
|
return;
|
|
2819
3109
|
}
|
|
2820
3110
|
if (state === "failed") {
|
|
@@ -2832,15 +3122,16 @@ var ChannelDriver = class {
|
|
|
2832
3122
|
if (err instanceof ChannelTerminalError) {
|
|
2833
3123
|
this.doneUndeliverable.add(row.id);
|
|
2834
3124
|
this.log({
|
|
2835
|
-
level: "
|
|
3125
|
+
level: "warn",
|
|
2836
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}`,
|
|
2837
3127
|
conversation_id: row.conversation_id,
|
|
2838
3128
|
message_id: row.id
|
|
2839
3129
|
});
|
|
3130
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
2840
3131
|
return;
|
|
2841
3132
|
}
|
|
2842
3133
|
this.log({
|
|
2843
|
-
level: "
|
|
3134
|
+
level: "warn",
|
|
2844
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)}`,
|
|
2845
3136
|
conversation_id: row.conversation_id,
|
|
2846
3137
|
message_id: row.id
|
|
@@ -2848,22 +3139,68 @@ var ChannelDriver = class {
|
|
|
2848
3139
|
return;
|
|
2849
3140
|
}
|
|
2850
3141
|
this.dontRedispatch.delete(row.id);
|
|
3142
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_failed");
|
|
2851
3143
|
return;
|
|
2852
3144
|
}
|
|
2853
3145
|
if (this.dontRedispatch.has(row.id)) {
|
|
2854
3146
|
this.log({
|
|
2855
|
-
level: "
|
|
3147
|
+
level: "debug",
|
|
2856
3148
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2857
3149
|
conversation_id: row.conversation_id,
|
|
2858
3150
|
message_id: row.id
|
|
2859
3151
|
});
|
|
2860
3152
|
return;
|
|
2861
3153
|
}
|
|
2862
|
-
|
|
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)) {
|
|
2863
3200
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2864
3201
|
if (descendantAlive === true) {
|
|
2865
3202
|
this.log({
|
|
2866
|
-
level: "
|
|
3203
|
+
level: "debug",
|
|
2867
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)`,
|
|
2868
3205
|
conversation_id: row.conversation_id,
|
|
2869
3206
|
message_id: row.id
|
|
@@ -2887,11 +3224,12 @@ var ChannelDriver = class {
|
|
|
2887
3224
|
this.readopted.add(row.id);
|
|
2888
3225
|
this.ensureWatcherRunning(sessionId);
|
|
2889
3226
|
this.log({
|
|
2890
|
-
level: "
|
|
3227
|
+
level: "debug",
|
|
2891
3228
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
2892
3229
|
conversation_id: row.conversation_id,
|
|
2893
3230
|
message_id: row.id
|
|
2894
3231
|
});
|
|
3232
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
|
|
2895
3233
|
return;
|
|
2896
3234
|
}
|
|
2897
3235
|
await this.forceReadoptRun(sessionId, row);
|
|
@@ -2920,7 +3258,7 @@ var ChannelDriver = class {
|
|
|
2920
3258
|
async forceReadoptRun(sessionId, row) {
|
|
2921
3259
|
if (this.stopped) {
|
|
2922
3260
|
this.log({
|
|
2923
|
-
level: "
|
|
3261
|
+
level: "debug",
|
|
2924
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`,
|
|
2925
3263
|
conversation_id: row.conversation_id,
|
|
2926
3264
|
message_id: row.id
|
|
@@ -2929,7 +3267,7 @@ var ChannelDriver = class {
|
|
|
2929
3267
|
}
|
|
2930
3268
|
if (this.awaitingReadopt.has(row.id)) {
|
|
2931
3269
|
this.log({
|
|
2932
|
-
level: "
|
|
3270
|
+
level: "debug",
|
|
2933
3271
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
2934
3272
|
conversation_id: row.conversation_id,
|
|
2935
3273
|
message_id: row.id
|
|
@@ -2939,11 +3277,12 @@ var ChannelDriver = class {
|
|
|
2939
3277
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
2940
3278
|
this.dontRedispatch.add(row.id);
|
|
2941
3279
|
this.log({
|
|
2942
|
-
level: "
|
|
3280
|
+
level: "debug",
|
|
2943
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)`,
|
|
2944
3282
|
conversation_id: row.conversation_id,
|
|
2945
3283
|
message_id: row.id
|
|
2946
3284
|
});
|
|
3285
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
|
|
2947
3286
|
return;
|
|
2948
3287
|
}
|
|
2949
3288
|
const options = {
|
|
@@ -2957,40 +3296,44 @@ var ChannelDriver = class {
|
|
|
2957
3296
|
message_id: row.id
|
|
2958
3297
|
});
|
|
2959
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);
|
|
2960
3302
|
let ocId;
|
|
2961
3303
|
try {
|
|
2962
3304
|
ocId = await this.dispatchLocked(
|
|
2963
3305
|
sessionId,
|
|
2964
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3306
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
2965
3307
|
);
|
|
2966
3308
|
} catch (err) {
|
|
2967
3309
|
this.awaitingReadopt.delete(row.id);
|
|
2968
3310
|
if (err instanceof ChannelAuthError) throw err;
|
|
2969
3311
|
this.log({
|
|
2970
|
-
level: "
|
|
3312
|
+
level: "warn",
|
|
2971
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)}`,
|
|
2972
3314
|
conversation_id: row.conversation_id,
|
|
2973
3315
|
message_id: row.id
|
|
2974
3316
|
});
|
|
3317
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2975
3318
|
return;
|
|
2976
3319
|
}
|
|
2977
3320
|
if (ocId === null) {
|
|
2978
3321
|
this.awaitingReadopt.delete(row.id);
|
|
2979
3322
|
this.log({
|
|
2980
|
-
level: "
|
|
3323
|
+
level: "warn",
|
|
2981
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`,
|
|
2982
3325
|
conversation_id: row.conversation_id,
|
|
2983
3326
|
message_id: row.id
|
|
2984
3327
|
});
|
|
3328
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
2985
3329
|
return;
|
|
2986
3330
|
}
|
|
2987
|
-
|
|
2988
|
-
const message = this.queuedMessageForRow(row);
|
|
2989
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3331
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
2990
3332
|
this.dispatched.add(row.id);
|
|
2991
3333
|
this.readopted.add(row.id);
|
|
2992
3334
|
this.awaitingReadopt.delete(row.id);
|
|
2993
3335
|
this.ensureWatcherRunning(sessionId);
|
|
3336
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
|
|
2994
3337
|
}
|
|
2995
3338
|
/**
|
|
2996
3339
|
* True if `evidentMessageId` is already being driven — either in the
|
|
@@ -3039,7 +3382,8 @@ var ChannelDriver = class {
|
|
|
3039
3382
|
opencode_agent: row.opencode_agent,
|
|
3040
3383
|
opencode_model: row.opencode_model,
|
|
3041
3384
|
source_message_id: row.source_message_id,
|
|
3042
|
-
slack_user_id: row.slack_user_id
|
|
3385
|
+
slack_user_id: row.slack_user_id,
|
|
3386
|
+
attachments: row.attachments ?? null
|
|
3043
3387
|
};
|
|
3044
3388
|
}
|
|
3045
3389
|
/**
|
|
@@ -3060,7 +3404,7 @@ var ChannelDriver = class {
|
|
|
3060
3404
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3061
3405
|
this.dontRedispatch.add(evidentMessageId);
|
|
3062
3406
|
this.log({
|
|
3063
|
-
level: "
|
|
3407
|
+
level: "debug",
|
|
3064
3408
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3065
3409
|
conversation_id: watcher.conv.id,
|
|
3066
3410
|
message_id: evidentMessageId
|
|
@@ -3200,6 +3544,51 @@ var ChannelDriver = class {
|
|
|
3200
3544
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3201
3545
|
return parent;
|
|
3202
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
|
+
}
|
|
3203
3592
|
/**
|
|
3204
3593
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3205
3594
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3243,7 +3632,7 @@ var ChannelDriver = class {
|
|
|
3243
3632
|
const sessions = await listSessions(this.port);
|
|
3244
3633
|
if (!sessions) {
|
|
3245
3634
|
this.log({
|
|
3246
|
-
level: "
|
|
3635
|
+
level: "warn",
|
|
3247
3636
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3248
3637
|
});
|
|
3249
3638
|
return null;
|
|
@@ -3258,6 +3647,25 @@ var ChannelDriver = class {
|
|
|
3258
3647
|
}
|
|
3259
3648
|
return false;
|
|
3260
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
|
+
}
|
|
3261
3669
|
/**
|
|
3262
3670
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
3263
3671
|
*
|
|
@@ -3308,9 +3716,7 @@ var ChannelDriver = class {
|
|
|
3308
3716
|
}
|
|
3309
3717
|
return inFlight.sort(byOldest)[0];
|
|
3310
3718
|
}
|
|
3311
|
-
// -------------------------------------------------------------------------
|
|
3312
3719
|
// Evident API calls (combinedAuth thread routes)
|
|
3313
|
-
// -------------------------------------------------------------------------
|
|
3314
3720
|
async getPendingConversations() {
|
|
3315
3721
|
const res = await this.fetchImpl(
|
|
3316
3722
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -3390,7 +3796,7 @@ var ChannelDriver = class {
|
|
|
3390
3796
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3391
3797
|
* retry vehicle for the swap-to-running.
|
|
3392
3798
|
*/
|
|
3393
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3799
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3394
3800
|
const res = await this.fetchImpl(
|
|
3395
3801
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3396
3802
|
{
|
|
@@ -3399,7 +3805,8 @@ var ChannelDriver = class {
|
|
|
3399
3805
|
body: JSON.stringify({
|
|
3400
3806
|
status: "processing",
|
|
3401
3807
|
opencode_session_id: sessionId,
|
|
3402
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3808
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
|
+
...title ? { title } : {}
|
|
3403
3810
|
})
|
|
3404
3811
|
}
|
|
3405
3812
|
);
|
|
@@ -3438,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
3438
3845
|
* watcher retries next tick within the
|
|
3439
3846
|
* deadline, Finding 4).
|
|
3440
3847
|
*/
|
|
3441
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3848
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3442
3849
|
const res = await this.fetchImpl(
|
|
3443
3850
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3444
3851
|
{
|
|
@@ -3447,7 +3854,8 @@ var ChannelDriver = class {
|
|
|
3447
3854
|
body: JSON.stringify({
|
|
3448
3855
|
status: "done",
|
|
3449
3856
|
opencode_session_id: sessionId,
|
|
3450
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3857
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
+
...title ? { title } : {}
|
|
3451
3859
|
})
|
|
3452
3860
|
}
|
|
3453
3861
|
);
|
|
@@ -3508,7 +3916,7 @@ var ChannelDriver = class {
|
|
|
3508
3916
|
);
|
|
3509
3917
|
if (!res.ok) {
|
|
3510
3918
|
this.log({
|
|
3511
|
-
level: "
|
|
3919
|
+
level: "warn",
|
|
3512
3920
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3513
3921
|
conversation_id: conversationId,
|
|
3514
3922
|
message_id: messageId
|
|
@@ -3518,7 +3926,7 @@ var ChannelDriver = class {
|
|
|
3518
3926
|
return true;
|
|
3519
3927
|
} catch (err) {
|
|
3520
3928
|
this.log({
|
|
3521
|
-
level: "
|
|
3929
|
+
level: "warn",
|
|
3522
3930
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3523
3931
|
conversation_id: conversationId,
|
|
3524
3932
|
message_id: messageId
|
|
@@ -3580,9 +3988,7 @@ var ChannelDriver = class {
|
|
|
3580
3988
|
return false;
|
|
3581
3989
|
}
|
|
3582
3990
|
}
|
|
3583
|
-
// -------------------------------------------------------------------------
|
|
3584
3991
|
// Retry wrapper
|
|
3585
|
-
// -------------------------------------------------------------------------
|
|
3586
3992
|
/**
|
|
3587
3993
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3588
3994
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3862,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
3862
4268
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
3863
4269
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
3864
4270
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
3865
|
-
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;
|
|
3866
4299
|
if (state.json) {
|
|
3867
4300
|
console.log(
|
|
3868
4301
|
JSON.stringify({
|
|
3869
4302
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3870
|
-
level
|
|
4303
|
+
level,
|
|
3871
4304
|
message
|
|
3872
4305
|
})
|
|
3873
4306
|
);
|
|
3874
4307
|
} else if (!state.interactive) {
|
|
3875
|
-
const prefix =
|
|
4308
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
3876
4309
|
console.log(`${prefix} ${message}`);
|
|
3877
4310
|
}
|
|
3878
4311
|
}
|
|
3879
4312
|
function logActivity(state, entry) {
|
|
4313
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
|
+
if (!meetsThreshold(state, level)) return;
|
|
3880
4315
|
const fullEntry = {
|
|
3881
4316
|
...entry,
|
|
4317
|
+
level,
|
|
3882
4318
|
timestamp: /* @__PURE__ */ new Date()
|
|
3883
4319
|
};
|
|
3884
4320
|
state.activityLog.push(fullEntry);
|
|
@@ -3887,9 +4323,9 @@ function logActivity(state, entry) {
|
|
|
3887
4323
|
}
|
|
3888
4324
|
if (!state.interactive) {
|
|
3889
4325
|
if (entry.type === "error") {
|
|
3890
|
-
log2(state, entry.error ?? "Unknown error",
|
|
3891
|
-
} else if (entry.
|
|
3892
|
-
log2(state, entry.message);
|
|
4326
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4327
|
+
} else if (entry.message) {
|
|
4328
|
+
log2(state, entry.message, level);
|
|
3893
4329
|
}
|
|
3894
4330
|
}
|
|
3895
4331
|
}
|
|
@@ -4088,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4088
4524
|
process.env
|
|
4089
4525
|
);
|
|
4090
4526
|
for (const warning2 of config2.warnings) {
|
|
4091
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4527
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4092
4528
|
}
|
|
4093
4529
|
if (!config2.enabled) return;
|
|
4094
4530
|
logActivity(state, {
|
|
@@ -4160,6 +4596,20 @@ async function cleanup(state, opts = {}) {
|
|
|
4160
4596
|
}
|
|
4161
4597
|
async function run(options) {
|
|
4162
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
|
+
}
|
|
4163
4613
|
const state = {
|
|
4164
4614
|
agentId: options.agent || "",
|
|
4165
4615
|
agentName: null,
|
|
@@ -4168,6 +4618,7 @@ async function run(options) {
|
|
|
4168
4618
|
idleTimeout: options.idleTimeout ?? null,
|
|
4169
4619
|
json: options.json ?? false,
|
|
4170
4620
|
interactive,
|
|
4621
|
+
logLevel,
|
|
4171
4622
|
connected: false,
|
|
4172
4623
|
opencodeConnected: false,
|
|
4173
4624
|
opencodeVersion: null,
|
|
@@ -4185,8 +4636,8 @@ async function run(options) {
|
|
|
4185
4636
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4186
4637
|
log2(
|
|
4187
4638
|
state,
|
|
4188
|
-
"
|
|
4189
|
-
|
|
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"
|
|
4190
4641
|
);
|
|
4191
4642
|
}
|
|
4192
4643
|
const handleSignal = async () => {
|
|
@@ -4302,9 +4753,9 @@ async function run(options) {
|
|
|
4302
4753
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4303
4754
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4304
4755
|
if (versionWarning) {
|
|
4305
|
-
log2(state, versionWarning,
|
|
4756
|
+
log2(state, versionWarning, "warn");
|
|
4306
4757
|
if (state.interactive && !state.json) {
|
|
4307
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4758
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
4308
4759
|
}
|
|
4309
4760
|
}
|
|
4310
4761
|
} catch (error2) {
|
|
@@ -4319,11 +4770,17 @@ async function run(options) {
|
|
|
4319
4770
|
getAuthHeader: () => state.authHeader,
|
|
4320
4771
|
conversationFilter: state.conversationFilter,
|
|
4321
4772
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4322
|
-
log: (entry) =>
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
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
|
+
)
|
|
4327
4784
|
});
|
|
4328
4785
|
state.channelDriver = channelDriver;
|
|
4329
4786
|
const connection = new RunnerConnection({
|
|
@@ -4477,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4477
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);
|
|
4478
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 }));
|
|
4479
4936
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4480
|
-
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
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(
|
|
4481
4941
|
"--session-cleanup-max-age <duration>",
|
|
4482
4942
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4483
4943
|
).option(
|
|
@@ -4491,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4491
4951
|
run({
|
|
4492
4952
|
agent: options.agent,
|
|
4493
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,
|
|
4494
4957
|
verbose: options.verbose,
|
|
4495
4958
|
conversation: options.conversation,
|
|
4496
4959
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|