@evident-ai/cli 3.1.1-dev.fe0815c → 3.2.1-dev.5d79124
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 +22 -2
- package/dist/index.js +929 -139
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -212,6 +212,7 @@ var api = {
|
|
|
212
212
|
|
|
213
213
|
// src/lib/keychain.ts
|
|
214
214
|
var SERVICE_NAME = "evident-cli";
|
|
215
|
+
var keytarWarned = false;
|
|
215
216
|
async function getKeytar() {
|
|
216
217
|
try {
|
|
217
218
|
const keytar = await import("keytar");
|
|
@@ -219,7 +220,13 @@ async function getKeytar() {
|
|
|
219
220
|
return null;
|
|
220
221
|
}
|
|
221
222
|
return keytar;
|
|
222
|
-
} catch {
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (!keytarWarned) {
|
|
225
|
+
keytarWarned = true;
|
|
226
|
+
console.warn(
|
|
227
|
+
`System keychain unavailable, falling back to file-based credential storage: ${err instanceof Error ? err.message : String(err)}`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
223
230
|
return null;
|
|
224
231
|
}
|
|
225
232
|
}
|
|
@@ -368,8 +375,9 @@ async function deviceFlowLogin(options) {
|
|
|
368
375
|
await waitForEnter("Press Enter to open the browser...");
|
|
369
376
|
try {
|
|
370
377
|
await open(verification_uri);
|
|
371
|
-
} catch {
|
|
372
|
-
|
|
378
|
+
} catch (error2) {
|
|
379
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
380
|
+
console.log(chalk2.dim(`Could not open browser (${message}). Please visit the URL manually.`));
|
|
373
381
|
}
|
|
374
382
|
}
|
|
375
383
|
const spinner = ora("Waiting for authentication...").start();
|
|
@@ -797,6 +805,16 @@ async function checkStatus(jsonMode) {
|
|
|
797
805
|
exitCode: 1
|
|
798
806
|
};
|
|
799
807
|
}
|
|
808
|
+
if (response.status === 404) {
|
|
809
|
+
return {
|
|
810
|
+
ok: false,
|
|
811
|
+
endpoint: apiUrl,
|
|
812
|
+
authLabel: authLabelFor(credentials2),
|
|
813
|
+
reason: "endpoint_not_found",
|
|
814
|
+
error: `${apiUrl}/me returned HTTP 404 \u2014 that endpoint has no /me route, so it is probably missing the /v1 prefix. The credentials were NOT validated.`,
|
|
815
|
+
exitCode: 75
|
|
816
|
+
};
|
|
817
|
+
}
|
|
800
818
|
if (response.status >= 500) {
|
|
801
819
|
const serverMessage = await readErrorMessage(response);
|
|
802
820
|
return {
|
|
@@ -899,14 +917,25 @@ function readClaudeCliCredentials() {
|
|
|
899
917
|
{ encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
|
|
900
918
|
);
|
|
901
919
|
return parseClaudeCliCredentials(raw);
|
|
902
|
-
} catch {
|
|
920
|
+
} catch (err) {
|
|
921
|
+
if (err.status !== 44) {
|
|
922
|
+
console.warn(
|
|
923
|
+
`readClaudeCliCredentials: security find-generic-password failed: ${err instanceof Error ? err.message : String(err)}`
|
|
924
|
+
);
|
|
925
|
+
}
|
|
903
926
|
return null;
|
|
904
927
|
}
|
|
905
928
|
}
|
|
906
929
|
try {
|
|
907
930
|
const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
|
|
908
931
|
return parseClaudeCliCredentials(raw);
|
|
909
|
-
} catch {
|
|
932
|
+
} catch (err) {
|
|
933
|
+
const code = err.code;
|
|
934
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
935
|
+
console.warn(
|
|
936
|
+
`readClaudeCliCredentials: reading .claude/.credentials.json failed: ${err instanceof Error ? err.message : String(err)}`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
910
939
|
return null;
|
|
911
940
|
}
|
|
912
941
|
}
|
|
@@ -994,7 +1023,7 @@ async function claudeUsage() {
|
|
|
994
1023
|
|
|
995
1024
|
// src/commands/run.ts
|
|
996
1025
|
import { homedir as homedir3 } from "os";
|
|
997
|
-
import { isAbsolute as isAbsolute2, join as
|
|
1026
|
+
import { isAbsolute as isAbsolute2, join as join4, parse, resolve as resolvePath } from "path";
|
|
998
1027
|
import chalk6 from "chalk";
|
|
999
1028
|
|
|
1000
1029
|
// ../../packages/types/src/agents/index.ts
|
|
@@ -1404,7 +1433,10 @@ function findOpenCodeProcesses() {
|
|
|
1404
1433
|
}
|
|
1405
1434
|
}
|
|
1406
1435
|
}
|
|
1407
|
-
} catch {
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
console.warn(
|
|
1438
|
+
`findOpenCodeProcesses: ps fallback failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1439
|
+
);
|
|
1408
1440
|
}
|
|
1409
1441
|
}
|
|
1410
1442
|
for (const pid of pids) {
|
|
@@ -1427,7 +1459,10 @@ function findOpenCodeProcesses() {
|
|
|
1427
1459
|
}
|
|
1428
1460
|
}
|
|
1429
1461
|
}
|
|
1430
|
-
} catch {
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
console.warn(
|
|
1464
|
+
`findOpenCodeProcesses: process detection failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1465
|
+
);
|
|
1431
1466
|
}
|
|
1432
1467
|
return instances;
|
|
1433
1468
|
}
|
|
@@ -1501,7 +1536,12 @@ function stopOpenCode(opencodeProcess) {
|
|
|
1501
1536
|
} else {
|
|
1502
1537
|
process.kill(-opencodeProcess.pid, "SIGTERM");
|
|
1503
1538
|
}
|
|
1504
|
-
} catch {
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
if (err.code !== "ESRCH") {
|
|
1541
|
+
console.warn(
|
|
1542
|
+
`stopOpenCode: kill failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1505
1545
|
}
|
|
1506
1546
|
}
|
|
1507
1547
|
|
|
@@ -2066,7 +2106,9 @@ function messageRunState(messages, userMessageId) {
|
|
|
2066
2106
|
}
|
|
2067
2107
|
if (!reply) return "queued";
|
|
2068
2108
|
if (isAssistantInFlight(reply)) return "running";
|
|
2069
|
-
|
|
2109
|
+
if (errorOf(reply) != null) return "failed";
|
|
2110
|
+
if (isAmbiguousTerminalFinish(reply)) return "running";
|
|
2111
|
+
return "done";
|
|
2070
2112
|
}
|
|
2071
2113
|
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
2072
2114
|
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
@@ -2076,6 +2118,19 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
2076
2118
|
function isB2AbandonmentConfirmed(params) {
|
|
2077
2119
|
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
2078
2120
|
}
|
|
2121
|
+
function isAmbiguousTerminalFinish(m) {
|
|
2122
|
+
if (completedOf(m) == null) return false;
|
|
2123
|
+
if (errorOf(m) != null) return false;
|
|
2124
|
+
const finish = finishOf(m);
|
|
2125
|
+
return finish !== "tool-calls" && finish !== "stop";
|
|
2126
|
+
}
|
|
2127
|
+
function isAmbiguousFinishPinnedRunning(messages, userMessageId) {
|
|
2128
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2129
|
+
return isAmbiguousTerminalFinish(reply);
|
|
2130
|
+
}
|
|
2131
|
+
function isAmbiguousFinishResolved(params) {
|
|
2132
|
+
return params.sessionOngoing === false || params.pinnedForMs >= params.maxPinnedMs;
|
|
2133
|
+
}
|
|
2079
2134
|
function messageError(messages, userMessageId) {
|
|
2080
2135
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2081
2136
|
const error2 = errorOf(reply);
|
|
@@ -2089,6 +2144,21 @@ function messageError(messages, userMessageId) {
|
|
|
2089
2144
|
}
|
|
2090
2145
|
return "The agent run failed.";
|
|
2091
2146
|
}
|
|
2147
|
+
function isAbortedTerminalReply(messages, userMessageId) {
|
|
2148
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2149
|
+
const error2 = errorOf(reply);
|
|
2150
|
+
if (error2 == null) return false;
|
|
2151
|
+
if (typeof error2 === "string") return error2.trim() === "Aborted";
|
|
2152
|
+
if (typeof error2 === "object") {
|
|
2153
|
+
const e = error2;
|
|
2154
|
+
if (e.name === "MessageAbortedError") return true;
|
|
2155
|
+
if (e.name === "AbortError") return true;
|
|
2156
|
+
const dataMessage = e.data?.message;
|
|
2157
|
+
const rendered = typeof dataMessage === "string" ? dataMessage : typeof e.message === "string" ? e.message : null;
|
|
2158
|
+
return rendered != null && rendered.trim() === "Aborted";
|
|
2159
|
+
}
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2092
2162
|
function messageFailure(messages, userMessageId) {
|
|
2093
2163
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2094
2164
|
const error2 = errorOf(reply);
|
|
@@ -2267,6 +2337,149 @@ function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
|
2267
2337
|
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
2268
2338
|
}
|
|
2269
2339
|
|
|
2340
|
+
// src/lib/opencode/session-db-size.ts
|
|
2341
|
+
import { statSync as statSync2 } from "fs";
|
|
2342
|
+
import { join as join2 } from "path";
|
|
2343
|
+
var LARGE_DB_THRESHOLD_BYTES = 268435456;
|
|
2344
|
+
function statSessionDbBytes(homeDir) {
|
|
2345
|
+
const dbPath = join2(homeDir, ".local", "share", "opencode", "opencode.db");
|
|
2346
|
+
try {
|
|
2347
|
+
return statSync2(dbPath).size;
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
const isMissingFile = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
2350
|
+
if (!isMissingFile) {
|
|
2351
|
+
console.error(
|
|
2352
|
+
`[statSessionDbBytes] could not stat ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
return null;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
function buildSessionStoreSizeWarning(input) {
|
|
2359
|
+
const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
|
|
2360
|
+
if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
|
|
2361
|
+
const mib = Math.round(dbBytes / 1024 / 1024);
|
|
2362
|
+
if (!cleanupEnabled) {
|
|
2363
|
+
return `Session store is large: opencode.db is ${mib} MiB and automatic session cleanup is off. Enable it with --session-cleanup-max-age 24h (env EVIDENT_SESSION_CLEANUP_MAX_AGE) to stop it growing \u2014 a store much larger than this can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
|
|
2364
|
+
}
|
|
2365
|
+
if (reclaimSkipReason === "sqlite-unavailable" || reclaimSkipReason === "insufficient-disk-space") {
|
|
2366
|
+
const reasonText = reclaimSkipReason === "sqlite-unavailable" ? "this Node runtime lacks node:sqlite (needs Node >=22.5)" : "there is not enough free disk space to compact it";
|
|
2367
|
+
return `Session store is large: opencode.db is ${mib} MiB. Automatic session cleanup is on, but space cannot currently be reclaimed because ${reasonText} \u2014 a store this large can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
|
|
2368
|
+
}
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// src/lib/opencode/session-db-reclaim.ts
|
|
2373
|
+
import { statSync as statSync3, statfsSync } from "fs";
|
|
2374
|
+
import { dirname as dirname2 } from "path";
|
|
2375
|
+
function insufficientSpaceReason(dbPath, requiredBytes) {
|
|
2376
|
+
try {
|
|
2377
|
+
const fsStats = statfsSync(dirname2(dbPath));
|
|
2378
|
+
const availableBytes = fsStats.bavail * fsStats.bsize;
|
|
2379
|
+
if (availableBytes < requiredBytes) {
|
|
2380
|
+
return `only ${availableBytes} bytes free, need ${requiredBytes} for a second copy`;
|
|
2381
|
+
}
|
|
2382
|
+
return null;
|
|
2383
|
+
} catch (err) {
|
|
2384
|
+
return `could not check free space (${err instanceof Error ? err.message : String(err)}); refusing to guess`;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
function readLogicalBytes(db) {
|
|
2388
|
+
const pageCount = db.prepare("PRAGMA page_count").get().page_count;
|
|
2389
|
+
const pageSize = db.prepare("PRAGMA page_size").get().page_size;
|
|
2390
|
+
return pageCount * pageSize;
|
|
2391
|
+
}
|
|
2392
|
+
function readCheckpointResult(db) {
|
|
2393
|
+
const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
2394
|
+
return { busy: row.busy !== 0, log: row.log, checkpointed: row.checkpointed };
|
|
2395
|
+
}
|
|
2396
|
+
async function probeReclaimAvailability(input) {
|
|
2397
|
+
const { dbPath, requiredBytes } = input;
|
|
2398
|
+
let sqlite;
|
|
2399
|
+
try {
|
|
2400
|
+
sqlite = await import("sqlite");
|
|
2401
|
+
} catch (err) {
|
|
2402
|
+
console.warn(
|
|
2403
|
+
`[probeReclaimAvailability] node:sqlite unavailable for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2404
|
+
);
|
|
2405
|
+
return "sqlite-unavailable";
|
|
2406
|
+
}
|
|
2407
|
+
let autoVacuum = null;
|
|
2408
|
+
try {
|
|
2409
|
+
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
|
|
2410
|
+
try {
|
|
2411
|
+
autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
|
|
2412
|
+
} finally {
|
|
2413
|
+
db.close();
|
|
2414
|
+
}
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
console.warn(
|
|
2417
|
+
`[probeReclaimAvailability] could not read auto_vacuum mode for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
if (autoVacuum !== 0) return null;
|
|
2421
|
+
return insufficientSpaceReason(dbPath, requiredBytes) !== null ? "insufficient-disk-space" : null;
|
|
2422
|
+
}
|
|
2423
|
+
async function reclaimSessionDbSpace(input) {
|
|
2424
|
+
const { dbPath, maxPages, allowFullVacuum = true } = input;
|
|
2425
|
+
let sqlite;
|
|
2426
|
+
try {
|
|
2427
|
+
sqlite = await import("sqlite");
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
console.warn(
|
|
2430
|
+
`[reclaimSessionDbSpace] node:sqlite unavailable (need Node >=22.5, >=22.13 unflagged) for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2431
|
+
);
|
|
2432
|
+
return { ok: false, skipped: "sqlite-unavailable" };
|
|
2433
|
+
}
|
|
2434
|
+
const { DatabaseSync } = sqlite;
|
|
2435
|
+
let db;
|
|
2436
|
+
try {
|
|
2437
|
+
db = new DatabaseSync(dbPath);
|
|
2438
|
+
const autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
|
|
2439
|
+
if (autoVacuum === 0) {
|
|
2440
|
+
if (!allowFullVacuum) {
|
|
2441
|
+
console.warn(
|
|
2442
|
+
`[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: a session turn is live`
|
|
2443
|
+
);
|
|
2444
|
+
return { ok: false, skipped: "full-vacuum-blocked" };
|
|
2445
|
+
}
|
|
2446
|
+
const fileBytesForGuard = statSync3(dbPath).size;
|
|
2447
|
+
const skipReason = insufficientSpaceReason(dbPath, fileBytesForGuard);
|
|
2448
|
+
if (skipReason !== null) {
|
|
2449
|
+
console.warn(
|
|
2450
|
+
`[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: ${skipReason}`
|
|
2451
|
+
);
|
|
2452
|
+
return { ok: false, skipped: "insufficient-disk-space" };
|
|
2453
|
+
}
|
|
2454
|
+
const beforeBytes = readLogicalBytes(db);
|
|
2455
|
+
db.exec("PRAGMA auto_vacuum=INCREMENTAL");
|
|
2456
|
+
db.exec("VACUUM");
|
|
2457
|
+
const afterBytes = readLogicalBytes(db);
|
|
2458
|
+
const checkpoint = readCheckpointResult(db);
|
|
2459
|
+
return { ok: true, mode: "convert", beforeBytes, afterBytes, checkpoint };
|
|
2460
|
+
}
|
|
2461
|
+
if (autoVacuum === 2) {
|
|
2462
|
+
const beforeBytes = readLogicalBytes(db);
|
|
2463
|
+
const bound = Math.max(0, Math.trunc(maxPages));
|
|
2464
|
+
db.exec(`PRAGMA incremental_vacuum(${bound})`);
|
|
2465
|
+
const afterBytes = readLogicalBytes(db);
|
|
2466
|
+
const checkpoint = readCheckpointResult(db);
|
|
2467
|
+
return { ok: true, mode: "incremental", beforeBytes, afterBytes, checkpoint };
|
|
2468
|
+
}
|
|
2469
|
+
console.warn(
|
|
2470
|
+
`[reclaimSessionDbSpace] ${dbPath} has auto_vacuum=${autoVacuum} (neither NONE nor INCREMENTAL); nothing to reclaim`
|
|
2471
|
+
);
|
|
2472
|
+
return { ok: false, skipped: "auto-vacuum-not-applicable" };
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
console.error(
|
|
2475
|
+
`[reclaimSessionDbSpace] reclaim failed for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2476
|
+
);
|
|
2477
|
+
return { ok: false, skipped: "reclaim-error" };
|
|
2478
|
+
} finally {
|
|
2479
|
+
db?.close();
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2270
2483
|
// src/lib/tunnel/connection.ts
|
|
2271
2484
|
import WebSocket2 from "ws";
|
|
2272
2485
|
|
|
@@ -2702,7 +2915,7 @@ import { homedir as homedir2 } from "os";
|
|
|
2702
2915
|
// src/lib/file-push.ts
|
|
2703
2916
|
import { randomUUID } from "crypto";
|
|
2704
2917
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2705
|
-
import { basename, dirname as
|
|
2918
|
+
import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
|
|
2706
2919
|
var FILE_MODE = 384;
|
|
2707
2920
|
var DIRECTORY_MODE = 448;
|
|
2708
2921
|
async function writePushedFile(request) {
|
|
@@ -2733,9 +2946,9 @@ async function writePushedFile(request) {
|
|
|
2733
2946
|
}
|
|
2734
2947
|
try {
|
|
2735
2948
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2736
|
-
|
|
2949
|
+
dirname3(candidate)
|
|
2737
2950
|
);
|
|
2738
|
-
const realTarget =
|
|
2951
|
+
const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
|
|
2739
2952
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2740
2953
|
if (allowedDirectory === null) {
|
|
2741
2954
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
@@ -2745,8 +2958,8 @@ async function writePushedFile(request) {
|
|
|
2745
2958
|
}
|
|
2746
2959
|
if (missingSegments.length > 0) {
|
|
2747
2960
|
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2748
|
-
const realParent = await realpath(
|
|
2749
|
-
if (realParent !==
|
|
2961
|
+
const realParent = await realpath(dirname3(realTarget));
|
|
2962
|
+
if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2750
2963
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2751
2964
|
path: realTarget,
|
|
2752
2965
|
bytes,
|
|
@@ -2771,7 +2984,7 @@ function expandAndValidate(requestedPath, homeDir) {
|
|
|
2771
2984
|
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2772
2985
|
return null;
|
|
2773
2986
|
}
|
|
2774
|
-
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ?
|
|
2987
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join3(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2775
2988
|
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2776
2989
|
return null;
|
|
2777
2990
|
}
|
|
@@ -2789,7 +3002,7 @@ async function resolveNearestExistingAncestor(directory) {
|
|
|
2789
3002
|
try {
|
|
2790
3003
|
return { existingAncestor: await realpath(current), missingSegments };
|
|
2791
3004
|
} catch (err) {
|
|
2792
|
-
const parent =
|
|
3005
|
+
const parent = dirname3(current);
|
|
2793
3006
|
if (err.code !== "ENOENT" || parent === current) {
|
|
2794
3007
|
throw err;
|
|
2795
3008
|
}
|
|
@@ -2844,13 +3057,13 @@ function contains(realDirectory, realTarget) {
|
|
|
2844
3057
|
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2845
3058
|
let current = existingAncestor;
|
|
2846
3059
|
for (const segment of missingSegments) {
|
|
2847
|
-
current =
|
|
3060
|
+
current = join3(current, segment);
|
|
2848
3061
|
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2849
3062
|
await chmod(current, DIRECTORY_MODE);
|
|
2850
3063
|
}
|
|
2851
3064
|
}
|
|
2852
3065
|
async function writeAtomically(realTarget, content) {
|
|
2853
|
-
const temporaryPath =
|
|
3066
|
+
const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2854
3067
|
let handle;
|
|
2855
3068
|
try {
|
|
2856
3069
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -3123,9 +3336,11 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
|
3123
3336
|
var HEARTBEAT_MS = 6e4;
|
|
3124
3337
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
3125
3338
|
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
3339
|
+
var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
|
|
3126
3340
|
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
3127
3341
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
3128
3342
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
3343
|
+
var MAX_IDENTICAL_REDRIVE_POLL_FAILURES = 5;
|
|
3129
3344
|
var ChannelAuthError = class extends Error {
|
|
3130
3345
|
constructor(message) {
|
|
3131
3346
|
super(message);
|
|
@@ -3148,6 +3363,10 @@ function backoffDelay(attempt, policy) {
|
|
|
3148
3363
|
function isRetryableStatus(status2) {
|
|
3149
3364
|
return status2 === 429 || status2 >= 500 && status2 <= 599;
|
|
3150
3365
|
}
|
|
3366
|
+
var VOLATILE_BODY_FIELD_PATTERN = /("(?:ref|requestId|request_id|traceId|trace_id)"\s*:\s*)"[^"]*"/gi;
|
|
3367
|
+
function normalizeRedrivePollFailureBody(body) {
|
|
3368
|
+
return body.replace(VOLATILE_BODY_FIELD_PATTERN, '$1"<redacted>"').replace(/\s+/g, " ").trim().slice(0, 200);
|
|
3369
|
+
}
|
|
3151
3370
|
var ChannelDriver = class _ChannelDriver {
|
|
3152
3371
|
agentId;
|
|
3153
3372
|
port;
|
|
@@ -3164,6 +3383,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3164
3383
|
now;
|
|
3165
3384
|
fileSyncDirectories;
|
|
3166
3385
|
homeDir;
|
|
3386
|
+
maxActiveSessions;
|
|
3167
3387
|
/** Cache of conversationId → opencode sessionId. */
|
|
3168
3388
|
sessions = /* @__PURE__ */ new Map();
|
|
3169
3389
|
/**
|
|
@@ -3279,6 +3499,66 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3279
3499
|
* ADR-0047's own "unreachable ⇒ bounded" rule). Cleared on any other outcome.
|
|
3280
3500
|
*/
|
|
3281
3501
|
redriveUnresolvedSince = /* @__PURE__ */ new Map();
|
|
3502
|
+
/**
|
|
3503
|
+
* Consecutive-identical-poll-failure streak for the re-drive fence (#1348),
|
|
3504
|
+
* keyed by Evident **message id** (not session) so `clearRedriveUnresolved`
|
|
3505
|
+
* can drop it with the other two trackers and it cannot leak. `sessionId` is
|
|
3506
|
+
* carried inside the entry, not the key: a session change is a different
|
|
3507
|
+
* situation and resets the streak, which gives the `(sessionId, message.id)`
|
|
3508
|
+
* pairing #1348 asks for without a composite map key.
|
|
3509
|
+
*/
|
|
3510
|
+
redrivePollFailures = /* @__PURE__ */ new Map();
|
|
3511
|
+
/**
|
|
3512
|
+
* "Already emitted `redrive_outcome_unreported` for THIS (message, outcome)
|
|
3513
|
+
* streak" (Class B, #1340: the runner DECIDED reattach/settle/fail_permanent
|
|
3514
|
+
* but its own PATCH to record it failed — distinct from Class A's
|
|
3515
|
+
* `redrive_poll_failed`, where opencode itself can't be observed). Keyed by
|
|
3516
|
+
* message id, valued by the outcome currently failing to report, so a
|
|
3517
|
+
* change of outcome starts a fresh signal. Cleared by
|
|
3518
|
+
* `clearRedriveUnresolved` the instant either PATCH succeeds.
|
|
3519
|
+
*/
|
|
3520
|
+
redriveOutcomeUnreportedSignalled = /* @__PURE__ */ new Map();
|
|
3521
|
+
/**
|
|
3522
|
+
* First `now()` a Class B outcome PATCH (reattach/settle/fail_permanent) was
|
|
3523
|
+
* observed to fail for this message (#1366's failure-window trip arm,
|
|
3524
|
+
* `boundRedriveOutcome`). Duration, not a tick count — bounded by the
|
|
3525
|
+
* existing `pausedMaxWaitMs` window (reusing the knob, not a new constant).
|
|
3526
|
+
* Cleared by `clearRedriveUnresolved` the instant the original PATCH
|
|
3527
|
+
* succeeds.
|
|
3528
|
+
*/
|
|
3529
|
+
redriveOutcomeFailingSince = /* @__PURE__ */ new Map();
|
|
3530
|
+
/**
|
|
3531
|
+
* "Already posted `redrive_outcome_abandoned` with `reported: false` for this
|
|
3532
|
+
* row" (#1366) — the bound tripped but the terminal `markFailed` fallback ALSO
|
|
3533
|
+
* failed (the route-level fault of G2), so every following tick re-attempts
|
|
3534
|
+
* the same terminal PATCH. Guards that quiet retry from re-signalling on
|
|
3535
|
+
* every tick. Cleared by `clearRedriveUnresolved`.
|
|
3536
|
+
*/
|
|
3537
|
+
redriveOutcomeAbandonedSignalled = /* @__PURE__ */ new Set();
|
|
3538
|
+
/**
|
|
3539
|
+
* "Already emitted `dispatch_not_started` for THIS (message, branch) streak"
|
|
3540
|
+
* (#1340). Valued by the branch currently firing, so a row that moves between
|
|
3541
|
+
* exits re-signals — the move IS the finding. Cleared only on a CONFIRMED
|
|
3542
|
+
* dispatch, never on the fence's decision to dispatch: `clearRedriveUnresolved`
|
|
3543
|
+
* runs on that decision (`resolveRedriveUnresolved`), so clearing there would
|
|
3544
|
+
* re-signal on every one of the 15h of re-dispatch attempts #1110 made.
|
|
3545
|
+
*/
|
|
3546
|
+
dispatchNotStartedSignalled = /* @__PURE__ */ new Map();
|
|
3547
|
+
/**
|
|
3548
|
+
* Consecutive-UNCONFIRMED-dispatch streak for a `pending` row with NO stored
|
|
3549
|
+
* `opencode_message_id` yet — i.e. one that has never even reached the
|
|
3550
|
+
* re-drive fence above. `sendPromptAsync`'s POST may 2xx, but its own
|
|
3551
|
+
* read-back retries can never confirm the assigned id when the session's
|
|
3552
|
+
* message list is PERMANENTLY unreadable (e.g. a corrupted local opencode
|
|
3553
|
+
* SQLite DB, #1345/#1348's exact fault, just hit BEFORE the row is ever
|
|
3554
|
+
* dispatched instead of after). Unlike an already-dispatched row, THIS row has
|
|
3555
|
+
* no other safety net at all: the lifecycle cron only reclaims `status =
|
|
3556
|
+
* 'processing'` rows, and a row stuck here never reaches `processing`. Keyed
|
|
3557
|
+
* by message id, carrying `sessionId` so a session change (a fresh one bound
|
|
3558
|
+
* after abandonment) starts a new streak rather than inheriting the old
|
|
3559
|
+
* session's count — same shape as `redrivePollFailures` above.
|
|
3560
|
+
*/
|
|
3561
|
+
unconfirmedDispatchFailures = /* @__PURE__ */ new Map();
|
|
3282
3562
|
/**
|
|
3283
3563
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
3284
3564
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -3384,6 +3664,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3384
3664
|
this.now = config.now ?? (() => Date.now());
|
|
3385
3665
|
this.fileSyncDirectories = config.fileSyncDirectories ?? [];
|
|
3386
3666
|
this.homeDir = config.homeDir ?? homedir2();
|
|
3667
|
+
this.maxActiveSessions = config.maxActiveSessions;
|
|
3387
3668
|
}
|
|
3388
3669
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
3389
3670
|
get opencodeBase() {
|
|
@@ -3463,10 +3744,26 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3463
3744
|
message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
|
|
3464
3745
|
});
|
|
3465
3746
|
}
|
|
3747
|
+
let cappedSkips = 0;
|
|
3466
3748
|
for (const conv of conversations) {
|
|
3467
3749
|
if (this.stopped) break;
|
|
3750
|
+
if (this.maxActiveSessions !== void 0) {
|
|
3751
|
+
const activeSessionIds = this.activeSessionIdsForCap();
|
|
3752
|
+
const resolvedSessionId = this.sessions.get(conv.id) ?? conv.opencode_session_id;
|
|
3753
|
+
const alreadyActive = resolvedSessionId != null && activeSessionIds.has(resolvedSessionId);
|
|
3754
|
+
if (activeSessionIds.size >= this.maxActiveSessions && !alreadyActive) {
|
|
3755
|
+
cappedSkips++;
|
|
3756
|
+
continue;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3468
3759
|
dispatched += await this.processConversation(conv);
|
|
3469
3760
|
}
|
|
3761
|
+
if (cappedSkips > 0) {
|
|
3762
|
+
this.log({
|
|
3763
|
+
level: "warn",
|
|
3764
|
+
message: `max-active-sessions cap (${this.maxActiveSessions}) reached \u2014 skipped ${cappedSkips} pending conversation(s) this tick`
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3470
3767
|
await this.readoptProcessing();
|
|
3471
3768
|
} finally {
|
|
3472
3769
|
this.draining = false;
|
|
@@ -3485,6 +3782,22 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3485
3782
|
}
|
|
3486
3783
|
return false;
|
|
3487
3784
|
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Session ids active *for the `--max-active-sessions` cap*: in-flight work AND
|
|
3787
|
+
* a live watcher loop. Unlike `hasInFlightWatchers()` / `protectedSessionIds()`,
|
|
3788
|
+
* a ZOMBIE watcher (in-flight but `loop === null`, left by a non-auth failure
|
|
3789
|
+
* inside `runWatcherLoop`) does not count here — under a cap it would
|
|
3790
|
+
* permanently consume a slot, whereas cleanup/idle-exit should still treat it
|
|
3791
|
+
* as protected. One call per drain iteration serves both the cap check
|
|
3792
|
+
* (`.size`) and the already-active exemption (`.has`).
|
|
3793
|
+
*/
|
|
3794
|
+
activeSessionIdsForCap() {
|
|
3795
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3796
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
3797
|
+
if (watcher.inFlight.size > 0 && watcher.loop !== null) ids.add(sessionId);
|
|
3798
|
+
}
|
|
3799
|
+
return ids;
|
|
3800
|
+
}
|
|
3488
3801
|
/**
|
|
3489
3802
|
* File-pull work, for `run.ts`'s idle accounting (#559).
|
|
3490
3803
|
*
|
|
@@ -3603,7 +3916,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3603
3916
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
3604
3917
|
*/
|
|
3605
3918
|
async processConversation(conv) {
|
|
3606
|
-
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
3919
|
+
const { sessionId, refusedSessionId, created: sessionCreated } = await this.ensureSession(conv);
|
|
3607
3920
|
const messages = await this.getPendingMessages(conv.id);
|
|
3608
3921
|
let dispatched = 0;
|
|
3609
3922
|
let skippedAlreadyDispatched = 0;
|
|
@@ -3619,7 +3932,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3619
3932
|
continue;
|
|
3620
3933
|
}
|
|
3621
3934
|
if (message.opencode_message_id) {
|
|
3622
|
-
const outcome = await this.resolveRedrive(conv, sessionId, message,
|
|
3935
|
+
const outcome = await this.resolveRedrive(conv, sessionId, message, sessionCreated);
|
|
3936
|
+
if (outcome === "abandoned") {
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3623
3939
|
if (outcome !== "dispatch") {
|
|
3624
3940
|
break;
|
|
3625
3941
|
}
|
|
@@ -3653,6 +3969,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3653
3969
|
conversation_id: conv.id,
|
|
3654
3970
|
message_id: message.id
|
|
3655
3971
|
});
|
|
3972
|
+
this.signalDispatchNotStarted(conv, message, "session_deleted_race");
|
|
3656
3973
|
break;
|
|
3657
3974
|
}
|
|
3658
3975
|
if (exists === null) {
|
|
@@ -3662,6 +3979,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3662
3979
|
conversation_id: conv.id,
|
|
3663
3980
|
message_id: message.id
|
|
3664
3981
|
});
|
|
3982
|
+
this.signalDispatchNotStarted(conv, message, "session_existence_unknown");
|
|
3665
3983
|
break;
|
|
3666
3984
|
}
|
|
3667
3985
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
@@ -3680,6 +3998,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3680
3998
|
conversation_id: conv.id,
|
|
3681
3999
|
message_id: message.id
|
|
3682
4000
|
});
|
|
4001
|
+
this.signalDispatchNotStarted(conv, message, "failure_unreported");
|
|
3683
4002
|
});
|
|
3684
4003
|
this.log({
|
|
3685
4004
|
level: "error",
|
|
@@ -3690,14 +4009,40 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3690
4009
|
break;
|
|
3691
4010
|
}
|
|
3692
4011
|
if (opencodeMessageId === null) {
|
|
4012
|
+
const streak = this.recordUnconfirmedDispatch(message.id, sessionId);
|
|
4013
|
+
if (streak < MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
|
|
4014
|
+
this.log({
|
|
4015
|
+
level: "warn",
|
|
4016
|
+
message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next tick`,
|
|
4017
|
+
conversation_id: conv.id,
|
|
4018
|
+
message_id: message.id
|
|
4019
|
+
});
|
|
4020
|
+
this.signalDispatchNotStarted(conv, message, "readback_unconfirmed");
|
|
4021
|
+
continue;
|
|
4022
|
+
}
|
|
4023
|
+
this.unconfirmedDispatchFailures.delete(message.id);
|
|
4024
|
+
this.sessions.delete(conv.id);
|
|
4025
|
+
this.supersede(conv.id, sessionId);
|
|
4026
|
+
const errorMessage = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
|
|
3693
4027
|
this.log({
|
|
3694
|
-
level: "
|
|
3695
|
-
message:
|
|
4028
|
+
level: "error",
|
|
4029
|
+
message: errorMessage,
|
|
3696
4030
|
conversation_id: conv.id,
|
|
3697
4031
|
message_id: message.id
|
|
3698
4032
|
});
|
|
3699
|
-
|
|
4033
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
4034
|
+
this.log({
|
|
4035
|
+
level: "warn",
|
|
4036
|
+
message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
4037
|
+
conversation_id: conv.id,
|
|
4038
|
+
message_id: message.id
|
|
4039
|
+
});
|
|
4040
|
+
this.signalDispatchNotStarted(conv, message, "abandon_unreported");
|
|
4041
|
+
});
|
|
4042
|
+
break;
|
|
3700
4043
|
}
|
|
4044
|
+
this.unconfirmedDispatchFailures.delete(message.id);
|
|
4045
|
+
this.dispatchNotStartedSignalled.delete(message.id);
|
|
3701
4046
|
this.dispatched.add(message.id);
|
|
3702
4047
|
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
3703
4048
|
dispatched += 1;
|
|
@@ -3718,21 +4063,29 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3718
4063
|
* INJECTED `fetchImpl` — NOT the imported `getSessionMessages` helper, which
|
|
3719
4064
|
* hits the global `fetch` and would bypass the same override every other
|
|
3720
4065
|
* opencode poll in this file respects. Mirrors `readoptProcessing`'s own
|
|
3721
|
-
* snapshot fetch (`:3081-3111`).
|
|
3722
|
-
*
|
|
3723
|
-
*
|
|
4066
|
+
* snapshot fetch (`:3081-3111`).
|
|
4067
|
+
*
|
|
4068
|
+
* Returns `{ ok: true, messages }` on a readable snapshot, or
|
|
4069
|
+
* `{ ok: false, signature }` on failure — `signature` is a string that
|
|
4070
|
+
* repeats across attempts for the SAME underlying fault (used by the
|
|
4071
|
+
* consecutive-identical-failure bound, #1348), or `null` for a thrown
|
|
4072
|
+
* exception, which is NOT countable toward that bound (a network blip / an
|
|
4073
|
+
* opencode restart also throws identically every tick, and must keep
|
|
4074
|
+
* retrying unbounded rather than ever being treated as permanent).
|
|
3724
4075
|
*/
|
|
3725
4076
|
async pollSessionMessagesForRedrive(conv, message, sessionId) {
|
|
3726
4077
|
try {
|
|
3727
4078
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
3728
4079
|
if (!res.ok) {
|
|
4080
|
+
const rawBody = await res.text();
|
|
4081
|
+
const normalized = normalizeRedrivePollFailureBody(rawBody);
|
|
3729
4082
|
this.log({
|
|
3730
4083
|
level: "warn",
|
|
3731
|
-
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status} \u2014 treating as unreadable this tick`,
|
|
4084
|
+
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status}${normalized ? `: ${normalized}` : ""} \u2014 treating as unreadable this tick`,
|
|
3732
4085
|
conversation_id: conv.id,
|
|
3733
4086
|
message_id: message.id
|
|
3734
4087
|
});
|
|
3735
|
-
return
|
|
4088
|
+
return { ok: false, signature: `HTTP ${res.status}${normalized ? `: ${normalized}` : ""}` };
|
|
3736
4089
|
}
|
|
3737
4090
|
const body = await res.json();
|
|
3738
4091
|
if (!Array.isArray(body)) {
|
|
@@ -3742,9 +4095,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3742
4095
|
conversation_id: conv.id,
|
|
3743
4096
|
message_id: message.id
|
|
3744
4097
|
});
|
|
3745
|
-
return
|
|
4098
|
+
return { ok: false, signature: "non-array message body" };
|
|
3746
4099
|
}
|
|
3747
|
-
return body;
|
|
4100
|
+
return { ok: true, messages: body };
|
|
3748
4101
|
} catch (err) {
|
|
3749
4102
|
this.log({
|
|
3750
4103
|
level: "warn",
|
|
@@ -3752,7 +4105,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3752
4105
|
conversation_id: conv.id,
|
|
3753
4106
|
message_id: message.id
|
|
3754
4107
|
});
|
|
3755
|
-
return null;
|
|
4108
|
+
return { ok: false, signature: null };
|
|
3756
4109
|
}
|
|
3757
4110
|
}
|
|
3758
4111
|
/**
|
|
@@ -3764,24 +4117,51 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3764
4117
|
* without this fence the drain loop would re-`prompt_async` the SAME turn a
|
|
3765
4118
|
* second time against live GitHub state. Mirrors `readoptOne`'s job for the
|
|
3766
4119
|
* `processing` re-adopt path, but simpler: no b1/b2 preamble cross-check is
|
|
3767
|
-
* needed here because `
|
|
3768
|
-
*
|
|
4120
|
+
* needed here because `sessionCreated` already handles the cases (a #553
|
|
4121
|
+
* abandoned session, a #190 vanished one) that path exists for.
|
|
3769
4122
|
*
|
|
3770
|
-
* Only `ChannelAuthError` propagates
|
|
3771
|
-
* `
|
|
4123
|
+
* Only `ChannelAuthError` propagates. A poll that fails identically
|
|
4124
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row reports the message
|
|
4125
|
+
* failed instead of retrying it (#1348) — SEPARATE from, not a replacement
|
|
4126
|
+
* for, `resolveRedriveUnresolved`'s own `pausedMaxWaitMs` bound below. Every
|
|
4127
|
+
* other failure resolves to `unresolved` and is retried whole on the next
|
|
4128
|
+
* ~2s drain tick.
|
|
3772
4129
|
*/
|
|
3773
|
-
async resolveRedrive(conv, sessionId, message,
|
|
4130
|
+
async resolveRedrive(conv, sessionId, message, sessionCreated) {
|
|
3774
4131
|
const ocId = message.opencode_message_id ?? null;
|
|
3775
|
-
if (
|
|
4132
|
+
if (sessionCreated) {
|
|
3776
4133
|
this.clearRedriveUnresolved(message.id);
|
|
3777
4134
|
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3778
4135
|
return "dispatch";
|
|
3779
4136
|
}
|
|
3780
|
-
const
|
|
3781
|
-
if (
|
|
4137
|
+
const polled = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
|
|
4138
|
+
if (!polled.ok) {
|
|
4139
|
+
const streak = this.recordRedrivePollFailure(message.id, sessionId, polled.signature);
|
|
4140
|
+
if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES && polled.signature !== null) {
|
|
4141
|
+
return this.failRedrivePollPermanent(conv, sessionId, message, polled.signature, streak);
|
|
4142
|
+
}
|
|
4143
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
4144
|
+
}
|
|
4145
|
+
this.redrivePollFailures.delete(message.id);
|
|
4146
|
+
const messages = polled.messages;
|
|
4147
|
+
if (messages.length === 0) {
|
|
3782
4148
|
return this.resolveRedriveUnresolved(conv, message);
|
|
3783
4149
|
}
|
|
3784
4150
|
const state = messageRunState(messages, ocId ?? "");
|
|
4151
|
+
if (state === "failed" && isAbortedTerminalReply(messages, ocId ?? "")) {
|
|
4152
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
4153
|
+
if (ongoing === false) {
|
|
4154
|
+
this.log({
|
|
4155
|
+
level: "info",
|
|
4156
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
4157
|
+
conversation_id: conv.id,
|
|
4158
|
+
message_id: message.id
|
|
4159
|
+
});
|
|
4160
|
+
this.clearRedriveUnresolved(message.id);
|
|
4161
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
4162
|
+
return "dispatch";
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
3785
4165
|
if (state === "done" || state === "failed") {
|
|
3786
4166
|
return this.settleRedrive(conv, sessionId, message, ocId, messages, state);
|
|
3787
4167
|
}
|
|
@@ -3791,6 +4171,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3791
4171
|
return this.reattachRedrive(conv, sessionId, message, ocId);
|
|
3792
4172
|
}
|
|
3793
4173
|
if (ongoing === false) {
|
|
4174
|
+
if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
|
|
4175
|
+
return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
|
|
4176
|
+
}
|
|
3794
4177
|
this.clearRedriveUnresolved(message.id);
|
|
3795
4178
|
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3796
4179
|
return "dispatch";
|
|
@@ -3825,13 +4208,23 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3825
4208
|
await this.markProcessing(conv.id, message.id, sessionId, ocId, title);
|
|
3826
4209
|
} catch (err) {
|
|
3827
4210
|
if (err instanceof ChannelAuthError) throw err;
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4211
|
+
if (err instanceof ChannelTerminalError) {
|
|
4212
|
+
this.log({
|
|
4213
|
+
level: "error",
|
|
4214
|
+
message: `Re-drive: the server definitively refused to restore message ${message.id.slice(0, 8)} to processing (terminal HTTP ${err.status} \u2014 the row is gone or the update was rejected); NOT reporting a re-attach`,
|
|
4215
|
+
conversation_id: conv.id,
|
|
4216
|
+
message_id: message.id
|
|
4217
|
+
});
|
|
4218
|
+
} else {
|
|
4219
|
+
this.log({
|
|
4220
|
+
level: "warn",
|
|
4221
|
+
message: `Re-drive: failed to restore message ${message.id.slice(0, 8)} to processing (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4222
|
+
conversation_id: conv.id,
|
|
4223
|
+
message_id: message.id
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
const bound = await this.boundRedriveOutcome(conv, message, "reattach");
|
|
4227
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
3835
4228
|
}
|
|
3836
4229
|
this.clearRedriveUnresolved(message.id);
|
|
3837
4230
|
this.registerReadopted(conv, sessionId, message, ocId ?? "", anchorMs);
|
|
@@ -3855,7 +4248,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3855
4248
|
* errored) while nobody was watching — deliver/report it instead of re-running.
|
|
3856
4249
|
* Mirrors `readoptOne`'s `done`/`failed` branches' error discipline, simplified
|
|
3857
4250
|
* (no `doneUndeliverable` park: a terminal PATCH failure here just retries next
|
|
3858
|
-
* drain, same as any other non-auth failure).
|
|
4251
|
+
* drain, same as any other non-auth failure). The restart-abort carve-out that
|
|
4252
|
+
* keeps the two in step for `failed` lives in the caller (`resolveRedrive`, #1310),
|
|
4253
|
+
* so a row reaching this `failed` branch is a GENUINE failure.
|
|
3859
4254
|
*/
|
|
3860
4255
|
async settleRedrive(conv, sessionId, message, ocId, messages, state) {
|
|
3861
4256
|
try {
|
|
@@ -3889,19 +4284,62 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3889
4284
|
conversation_id: conv.id,
|
|
3890
4285
|
message_id: message.id
|
|
3891
4286
|
});
|
|
3892
|
-
|
|
4287
|
+
const bound = await this.boundRedriveOutcome(conv, message, "settle");
|
|
4288
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
3893
4289
|
}
|
|
3894
4290
|
this.clearRedriveUnresolved(message.id);
|
|
3895
4291
|
void this.postSignal(conv.id, message.id, "redrive_settled");
|
|
3896
4292
|
return "settled";
|
|
3897
4293
|
}
|
|
4294
|
+
/**
|
|
4295
|
+
* The permanent-failure outcome (#1348): the fence's own poll of this session
|
|
4296
|
+
* failed with the SAME opencode-answered signature
|
|
4297
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row — a transient blip
|
|
4298
|
+
* would have varied or eventually cleared (see `pollSessionMessagesForRedrive`
|
|
4299
|
+
* and `recordRedrivePollFailure`), so this is a durable fault (e.g. #1345's
|
|
4300
|
+
* corrupted opencode session) rather than something worth retrying forever.
|
|
4301
|
+
* Mirrors `settleRedrive`'s error discipline: no `usage`/`failure` args to
|
|
4302
|
+
* `markFailed` (no opencode snapshot to extract them from — this poll never
|
|
4303
|
+
* got a readable one).
|
|
4304
|
+
*/
|
|
4305
|
+
async failRedrivePollPermanent(conv, sessionId, message, signature, streak) {
|
|
4306
|
+
this.log({
|
|
4307
|
+
level: "error",
|
|
4308
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) failed to poll with the identical signature "${signature}" ${streak} times in a row \u2014 reporting the message failed instead of retrying forever`,
|
|
4309
|
+
conversation_id: conv.id,
|
|
4310
|
+
message_id: message.id
|
|
4311
|
+
});
|
|
4312
|
+
try {
|
|
4313
|
+
await this.markFailed(
|
|
4314
|
+
conv.id,
|
|
4315
|
+
message.id,
|
|
4316
|
+
sessionId,
|
|
4317
|
+
`The runner could not read this conversation's state from OpenCode (${signature}). The same failure repeated ${streak} times in a row, so the message was not retried further.`
|
|
4318
|
+
);
|
|
4319
|
+
} catch (err) {
|
|
4320
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4321
|
+
this.log({
|
|
4322
|
+
level: "warn",
|
|
4323
|
+
message: `Re-drive: failed to report message ${message.id.slice(0, 8)} permanently failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4324
|
+
conversation_id: conv.id,
|
|
4325
|
+
message_id: message.id
|
|
4326
|
+
});
|
|
4327
|
+
const bound = await this.boundRedriveOutcome(conv, message, "fail_permanent");
|
|
4328
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
4329
|
+
}
|
|
4330
|
+
this.clearRedriveUnresolved(message.id);
|
|
4331
|
+
void this.postSignal(conv.id, message.id, "redrive_poll_failed");
|
|
4332
|
+
return "settled";
|
|
4333
|
+
}
|
|
3898
4334
|
/**
|
|
3899
4335
|
* The bounded `unresolved` outcome (Task 3.4): opencode's state could not be
|
|
3900
4336
|
* observed (snapshot unreadable/empty, or `isSessionOngoing` returned `null`).
|
|
3901
|
-
* A `pending` row is
|
|
3902
|
-
*
|
|
3903
|
-
*
|
|
3904
|
-
*
|
|
4337
|
+
* A `pending` row is swept by the server's own `PENDING_MAX_AGE_MS` (24h,
|
|
4338
|
+
* #1368) cron arm, but that is a day-scale backstop — this local bound acts
|
|
4339
|
+
* in minutes so the row (and the conversation it starves, per the ordering
|
|
4340
|
+
* invariant below) isn't left stranded for that long. Bound to the existing
|
|
4341
|
+
* `pausedMaxWaitMs` window (reusing the knob, not a new constant); takes
|
|
4342
|
+
* `dispatch` once elapsed.
|
|
3905
4343
|
*/
|
|
3906
4344
|
resolveRedriveUnresolved(conv, message) {
|
|
3907
4345
|
const now = this.now();
|
|
@@ -3920,10 +4358,153 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3920
4358
|
}
|
|
3921
4359
|
return "unresolved";
|
|
3922
4360
|
}
|
|
3923
|
-
/** Clear
|
|
4361
|
+
/** Clear all `unresolved`/failure-streak trackers for a row (any non-`unresolved` outcome). */
|
|
3924
4362
|
clearRedriveUnresolved(messageId) {
|
|
3925
4363
|
this.redriveUnresolvedSince.delete(messageId);
|
|
3926
4364
|
this.redriveUnresolvedSignalled.delete(messageId);
|
|
4365
|
+
this.redrivePollFailures.delete(messageId);
|
|
4366
|
+
this.redriveOutcomeUnreportedSignalled.delete(messageId);
|
|
4367
|
+
this.redriveOutcomeFailingSince.delete(messageId);
|
|
4368
|
+
this.redriveOutcomeAbandonedSignalled.delete(messageId);
|
|
4369
|
+
}
|
|
4370
|
+
/**
|
|
4371
|
+
* #1340: the dispatch loop reached a message and did NOT start a turn. Fires at
|
|
4372
|
+
* most once per (message, branch) streak — a wedged row is re-tried every tick,
|
|
4373
|
+
* and the per-tick count is already carried by the co-occurring
|
|
4374
|
+
* `redrive_unresolved`/`redrive_redispatched` signals.
|
|
4375
|
+
*/
|
|
4376
|
+
signalDispatchNotStarted(conv, message, branch) {
|
|
4377
|
+
if (this.dispatchNotStartedSignalled.get(message.id) === branch) return;
|
|
4378
|
+
this.dispatchNotStartedSignalled.set(message.id, branch);
|
|
4379
|
+
void this.postSignal(conv.id, message.id, "dispatch_not_started", { branch });
|
|
4380
|
+
}
|
|
4381
|
+
/**
|
|
4382
|
+
* Class B (#1340): the runner DECIDED an outcome (reattach/settle/fail_permanent)
|
|
4383
|
+
* but its own PATCH to record it failed. Fires at most once per (message,
|
|
4384
|
+
* outcome) streak, and only while `boundRedriveOutcome` has not yet tripped —
|
|
4385
|
+
* once it trips, `redrive_outcome_abandoned` takes over reporting for the row
|
|
4386
|
+
* (#1366).
|
|
4387
|
+
*/
|
|
4388
|
+
signalRedriveOutcomeUnreported(conv, message, outcome) {
|
|
4389
|
+
if (this.redriveOutcomeUnreportedSignalled.get(message.id) === outcome) return;
|
|
4390
|
+
this.redriveOutcomeUnreportedSignalled.set(message.id, outcome);
|
|
4391
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_unreported", {
|
|
4392
|
+
attempted_outcome: outcome
|
|
4393
|
+
});
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* The runner-authored, honest error text for the terminal fallback a tripped
|
|
4397
|
+
* `boundRedriveOutcome` sends. Distinguishable per outcome and truthful about
|
|
4398
|
+
* what actually happened — the `settle`/done case must say the turn finished
|
|
4399
|
+
* but its result could not be recorded, never that the runner stopped
|
|
4400
|
+
* responding (that would be a lie for this shape, see #1366's "why this ships").
|
|
4401
|
+
*/
|
|
4402
|
+
static REDRIVE_ABANDON_ERROR = {
|
|
4403
|
+
reattach: "your runner could not record that this message had started, so it was given up on",
|
|
4404
|
+
settle: "your runner finished this message but could not record the result, so the reply could not be delivered",
|
|
4405
|
+
fail_permanent: "the runner could not read this conversation's state from OpenCode, and could not record that failure either, so the message was given up on"
|
|
4406
|
+
};
|
|
4407
|
+
/**
|
|
4408
|
+
* Bound for Class B (#1340, #1366): the runner DECIDED an outcome but its own
|
|
4409
|
+
* PATCH to record it failed. Two independent trip arms (either sufficient):
|
|
4410
|
+
* (1) this failure streak has lasted `pausedMaxWaitMs` — DURATION, not a tick
|
|
4411
|
+
* count, reusing the knob `resolveRedriveUnresolved` already established; (2)
|
|
4412
|
+
* the turn's `processing_started_at` age has crossed
|
|
4413
|
+
* `ABSOLUTE_MAX_PROCESSING_MS` — durable and restart-surviving, since arm (1)'s
|
|
4414
|
+
* in-memory streak resets on a scale-to-zero restart.
|
|
4415
|
+
*
|
|
4416
|
+
* INVARIANT — a tripped bound never suppresses the original outcome attempt;
|
|
4417
|
+
* it only adds a fallback after that attempt has failed again. This is only
|
|
4418
|
+
* ever reached from inside the catch of the ORIGINAL outcome PATCH, which is
|
|
4419
|
+
* attempted first on every tick whether or not this bound tripped before —
|
|
4420
|
+
* there is no give-up latch that would short-circuit it. That is what lets a
|
|
4421
|
+
* route-level fault that heals later still deliver the turn's real
|
|
4422
|
+
* `done`/`failed` payload: once the original PATCH succeeds again, this
|
|
4423
|
+
* helper is never entered and the row settles with its real result.
|
|
4424
|
+
*/
|
|
4425
|
+
async boundRedriveOutcome(conv, message, outcome) {
|
|
4426
|
+
const now = this.now();
|
|
4427
|
+
const since = this.redriveOutcomeFailingSince.get(message.id);
|
|
4428
|
+
if (since === void 0) this.redriveOutcomeFailingSince.set(message.id, now);
|
|
4429
|
+
const durationTripped = now - (since ?? now) >= this.pausedMaxWaitMs;
|
|
4430
|
+
const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
|
|
4431
|
+
const absoluteAgeTripped = !Number.isNaN(parsed) && now - parsed >= ABSOLUTE_MAX_PROCESSING_MS;
|
|
4432
|
+
if (!durationTripped && !absoluteAgeTripped) {
|
|
4433
|
+
this.signalRedriveOutcomeUnreported(conv, message, outcome);
|
|
4434
|
+
return "retry";
|
|
4435
|
+
}
|
|
4436
|
+
const arm = durationTripped ? "failure_window" : "absolute_age";
|
|
4437
|
+
try {
|
|
4438
|
+
await this.markFailed(
|
|
4439
|
+
conv.id,
|
|
4440
|
+
message.id,
|
|
4441
|
+
void 0,
|
|
4442
|
+
_ChannelDriver.REDRIVE_ABANDON_ERROR[outcome]
|
|
4443
|
+
);
|
|
4444
|
+
} catch (err) {
|
|
4445
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4446
|
+
this.log({
|
|
4447
|
+
level: "warn",
|
|
4448
|
+
message: `Re-drive bound: fallback markFailed for message ${message.id.slice(0, 8)} also failed (arm ${arm}, will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4449
|
+
conversation_id: conv.id,
|
|
4450
|
+
message_id: message.id
|
|
4451
|
+
});
|
|
4452
|
+
if (!this.redriveOutcomeAbandonedSignalled.has(message.id)) {
|
|
4453
|
+
this.redriveOutcomeAbandonedSignalled.add(message.id);
|
|
4454
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
|
|
4455
|
+
attempted_outcome: outcome,
|
|
4456
|
+
reported: false,
|
|
4457
|
+
arm
|
|
4458
|
+
});
|
|
4459
|
+
}
|
|
4460
|
+
return "retry";
|
|
4461
|
+
}
|
|
4462
|
+
this.clearRedriveUnresolved(message.id);
|
|
4463
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
|
|
4464
|
+
attempted_outcome: outcome,
|
|
4465
|
+
reported: true,
|
|
4466
|
+
arm
|
|
4467
|
+
});
|
|
4468
|
+
return "abandoned";
|
|
4469
|
+
}
|
|
4470
|
+
/**
|
|
4471
|
+
* Record one poll outcome toward the re-drive fence's consecutive-identical-
|
|
4472
|
+
* failure streak (#1348) and return the resulting count. `signature === null`
|
|
4473
|
+
* (a thrown exception, H1) always clears the streak and returns `0` — it is
|
|
4474
|
+
* never countable. Otherwise the streak continues only when BOTH the session
|
|
4475
|
+
* and the signature match the previous failure; anything else (a different
|
|
4476
|
+
* session, or the same session failing a DIFFERENT way) starts a fresh streak
|
|
4477
|
+
* at `1`.
|
|
4478
|
+
*/
|
|
4479
|
+
recordRedrivePollFailure(messageId, sessionId, signature) {
|
|
4480
|
+
if (signature === null) {
|
|
4481
|
+
this.redrivePollFailures.delete(messageId);
|
|
4482
|
+
return 0;
|
|
4483
|
+
}
|
|
4484
|
+
const existing = this.redrivePollFailures.get(messageId);
|
|
4485
|
+
if (existing && existing.sessionId === sessionId && existing.signature === signature) {
|
|
4486
|
+
existing.count += 1;
|
|
4487
|
+
return existing.count;
|
|
4488
|
+
}
|
|
4489
|
+
this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
|
|
4490
|
+
return 1;
|
|
4491
|
+
}
|
|
4492
|
+
/**
|
|
4493
|
+
* Record one UNCONFIRMED-dispatch outcome (a `pending` row with no stored
|
|
4494
|
+
* `opencode_message_id` whose `sendPromptAsync` returned `null`) toward the
|
|
4495
|
+
* bound in `processConversation`'s dispatch loop, and return the resulting
|
|
4496
|
+
* count. Mirrors `recordRedrivePollFailure`'s session-scoping: a session
|
|
4497
|
+
* change starts a fresh streak at `1` rather than inheriting the old one's
|
|
4498
|
+
* count, since a new session is a genuinely different attempt.
|
|
4499
|
+
*/
|
|
4500
|
+
recordUnconfirmedDispatch(messageId, sessionId) {
|
|
4501
|
+
const existing = this.unconfirmedDispatchFailures.get(messageId);
|
|
4502
|
+
if (existing && existing.sessionId === sessionId) {
|
|
4503
|
+
existing.count += 1;
|
|
4504
|
+
return existing.count;
|
|
4505
|
+
}
|
|
4506
|
+
this.unconfirmedDispatchFailures.set(messageId, { sessionId, count: 1 });
|
|
4507
|
+
return 1;
|
|
3927
4508
|
}
|
|
3928
4509
|
/**
|
|
3929
4510
|
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
@@ -3949,6 +4530,13 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3949
4530
|
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
3950
4531
|
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
3951
4532
|
* happened and a fresh session was bound instead. The caller reports it.
|
|
4533
|
+
*
|
|
4534
|
+
* `created` says the returned session was made JUST NOW, so it provably holds
|
|
4535
|
+
* no prior turn. The re-drive fence needs that as CONTRARY evidence ("nothing
|
|
4536
|
+
* to reconcile against") — distinct from the ambiguous "I polled and saw an
|
|
4537
|
+
* empty transcript", which stays a deferral. Keep it separate from
|
|
4538
|
+
* `refusedSessionId`: only the latter means a #553 resurrection happened, and
|
|
4539
|
+
* only it may drive the `session_superseded` signal.
|
|
3952
4540
|
*/
|
|
3953
4541
|
async ensureSession(conv) {
|
|
3954
4542
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
@@ -3959,7 +4547,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3959
4547
|
conversation_id: conv.id
|
|
3960
4548
|
});
|
|
3961
4549
|
this.sessions.delete(conv.id);
|
|
3962
|
-
return {
|
|
4550
|
+
return {
|
|
4551
|
+
sessionId: await this.createAndBindSession(conv.id),
|
|
4552
|
+
refusedSessionId: bound,
|
|
4553
|
+
created: true
|
|
4554
|
+
};
|
|
3963
4555
|
}
|
|
3964
4556
|
if (bound) {
|
|
3965
4557
|
const exists = await sessionExists(this.port, bound);
|
|
@@ -3970,12 +4562,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3970
4562
|
conversation_id: conv.id
|
|
3971
4563
|
});
|
|
3972
4564
|
this.sessions.delete(conv.id);
|
|
3973
|
-
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
4565
|
+
return { sessionId: await this.createAndBindSession(conv.id), created: true };
|
|
3974
4566
|
}
|
|
3975
4567
|
this.sessions.set(conv.id, bound);
|
|
3976
|
-
return { sessionId: bound };
|
|
4568
|
+
return { sessionId: bound, created: false };
|
|
3977
4569
|
}
|
|
3978
|
-
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
4570
|
+
return { sessionId: await this.createAndBindSession(conv.id), created: true };
|
|
3979
4571
|
}
|
|
3980
4572
|
/**
|
|
3981
4573
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -4193,7 +4785,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4193
4785
|
deliveryDeadlineAnchored: false,
|
|
4194
4786
|
b2PinnedSinceMs: 0,
|
|
4195
4787
|
b2LastDescendantCheckMs: 0,
|
|
4196
|
-
b2AbandonedSignalled: false
|
|
4788
|
+
b2AbandonedSignalled: false,
|
|
4789
|
+
ambiguousPinnedSinceMs: 0,
|
|
4790
|
+
ambiguousResolved: false
|
|
4197
4791
|
});
|
|
4198
4792
|
}
|
|
4199
4793
|
/**
|
|
@@ -4272,7 +4866,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4272
4866
|
deliveryDeadlineAnchored: false,
|
|
4273
4867
|
b2PinnedSinceMs: 0,
|
|
4274
4868
|
b2LastDescendantCheckMs: 0,
|
|
4275
|
-
b2AbandonedSignalled: false
|
|
4869
|
+
b2AbandonedSignalled: false,
|
|
4870
|
+
ambiguousPinnedSinceMs: 0,
|
|
4871
|
+
ambiguousResolved: false
|
|
4276
4872
|
});
|
|
4277
4873
|
}
|
|
4278
4874
|
/**
|
|
@@ -4404,9 +5000,8 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4404
5000
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
4405
5001
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
4406
5002
|
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
4407
|
-
let claimed;
|
|
4408
5003
|
try {
|
|
4409
|
-
|
|
5004
|
+
await this.markProcessing(
|
|
4410
5005
|
conv.id,
|
|
4411
5006
|
inFlight.evidentMessageId,
|
|
4412
5007
|
sessionId,
|
|
@@ -4415,23 +5010,24 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4415
5010
|
);
|
|
4416
5011
|
} catch (err) {
|
|
4417
5012
|
if (err instanceof ChannelAuthError) throw err;
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
5013
|
+
if (err instanceof ChannelTerminalError) {
|
|
5014
|
+
this.log({
|
|
5015
|
+
level: "error",
|
|
5016
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (terminal HTTP ${err.status}) \u2014 the server definitively refused the swap`,
|
|
5017
|
+
conversation_id: conv.id,
|
|
5018
|
+
message_id: inFlight.evidentMessageId
|
|
5019
|
+
});
|
|
5020
|
+
} else {
|
|
5021
|
+
this.log({
|
|
5022
|
+
level: "warn",
|
|
5023
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
5024
|
+
conversation_id: conv.id,
|
|
5025
|
+
message_id: inFlight.evidentMessageId
|
|
5026
|
+
});
|
|
5027
|
+
return;
|
|
5028
|
+
}
|
|
4425
5029
|
}
|
|
4426
5030
|
inFlight.started = true;
|
|
4427
|
-
if (!claimed) {
|
|
4428
|
-
this.log({
|
|
4429
|
-
level: "debug",
|
|
4430
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
4431
|
-
conversation_id: conv.id,
|
|
4432
|
-
message_id: inFlight.evidentMessageId
|
|
4433
|
-
});
|
|
4434
|
-
}
|
|
4435
5031
|
}
|
|
4436
5032
|
if (state === "done") {
|
|
4437
5033
|
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
@@ -4540,6 +5136,49 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4540
5136
|
}
|
|
4541
5137
|
}
|
|
4542
5138
|
}
|
|
5139
|
+
const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
|
|
5140
|
+
if (!ambiguousPinnedNow) {
|
|
5141
|
+
if (snapshotReadable) {
|
|
5142
|
+
inFlight.ambiguousPinnedSinceMs = 0;
|
|
5143
|
+
inFlight.ambiguousResolved = false;
|
|
5144
|
+
}
|
|
5145
|
+
} else {
|
|
5146
|
+
if (inFlight.ambiguousResolved) {
|
|
5147
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
5148
|
+
return;
|
|
5149
|
+
}
|
|
5150
|
+
if (inFlight.ambiguousPinnedSinceMs === 0) {
|
|
5151
|
+
inFlight.ambiguousPinnedSinceMs = this.now();
|
|
5152
|
+
const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
|
|
5153
|
+
const finish = reply?.info?.finish ?? reply?.finish;
|
|
5154
|
+
this.log({
|
|
5155
|
+
level: "warn",
|
|
5156
|
+
message: `Message ${id.slice(0, 8)} pinned running by an unrecognised finish ("${finish ?? "(absent)"}") \u2014 corroborating against opencode's session status before settling (issue #1493)`,
|
|
5157
|
+
conversation_id: conv.id,
|
|
5158
|
+
message_id: id
|
|
5159
|
+
});
|
|
5160
|
+
}
|
|
5161
|
+
const pinnedForMs = this.now() - inFlight.ambiguousPinnedSinceMs;
|
|
5162
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
5163
|
+
if (isAmbiguousFinishResolved({
|
|
5164
|
+
pinnedForMs,
|
|
5165
|
+
maxPinnedMs: AMBIGUOUS_FINISH_MAX_PINNED_MS,
|
|
5166
|
+
sessionOngoing: ongoing
|
|
5167
|
+
})) {
|
|
5168
|
+
inFlight.ambiguousResolved = true;
|
|
5169
|
+
this.log({
|
|
5170
|
+
level: "warn",
|
|
5171
|
+
message: `Message ${id.slice(0, 8)} ambiguous-finish-pinned for ${Math.round(pinnedForMs / 1e3)}s \u2014 resolved (${ongoing === false ? "session confirmed not-ongoing" : "pin exceeded the no-hang cap"}) \u2014 settling done`,
|
|
5172
|
+
conversation_id: conv.id,
|
|
5173
|
+
message_id: id
|
|
5174
|
+
});
|
|
5175
|
+
void this.postSignal(conv.id, id, "ambiguous_finish_resolved", {
|
|
5176
|
+
watched_for_ms: pinnedForMs
|
|
5177
|
+
});
|
|
5178
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
5179
|
+
return;
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
4543
5182
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
4544
5183
|
this.log({
|
|
4545
5184
|
level: "warn",
|
|
@@ -4771,7 +5410,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4771
5410
|
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
4772
5411
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
4773
5412
|
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
4774
|
-
* errored turn is reported failed on restart, NOT re-dispatched
|
|
5413
|
+
* errored turn is reported failed on restart, NOT re-dispatched —
|
|
5414
|
+
* EXCEPT a restart-ABORTED turn under a not-ongoing session,
|
|
5415
|
+
* which is a restart orphan wearing a terminal error and is
|
|
5416
|
+
* re-dispatched instead (issue #1310, see the branch below);
|
|
4775
5417
|
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
4776
5418
|
* tracking the stored id so the reply correlates by it;
|
|
4777
5419
|
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
@@ -4791,51 +5433,19 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4791
5433
|
const ocId = row.opencode_message_id;
|
|
4792
5434
|
const state = messageRunState(messages, ocId ?? "");
|
|
4793
5435
|
if (state === "done") {
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
message_id: row.id
|
|
4800
|
-
});
|
|
4801
|
-
return;
|
|
4802
|
-
}
|
|
5436
|
+
await this.deliverReadoptedDone(sessionId, row, messages, ocId);
|
|
5437
|
+
return;
|
|
5438
|
+
}
|
|
5439
|
+
const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
|
|
5440
|
+
if (restartAborted) {
|
|
4803
5441
|
this.log({
|
|
4804
5442
|
level: "info",
|
|
4805
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)}
|
|
5443
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
4806
5444
|
conversation_id: row.conversation_id,
|
|
4807
5445
|
message_id: row.id
|
|
4808
5446
|
});
|
|
4809
|
-
try {
|
|
4810
|
-
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
4811
|
-
const usage = messageUsage(messages, ocId ?? "");
|
|
4812
|
-
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
4813
|
-
} catch (err) {
|
|
4814
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
4815
|
-
if (err instanceof ChannelTerminalError) {
|
|
4816
|
-
this.doneUndeliverable.add(row.id);
|
|
4817
|
-
this.log({
|
|
4818
|
-
level: "warn",
|
|
4819
|
-
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}`,
|
|
4820
|
-
conversation_id: row.conversation_id,
|
|
4821
|
-
message_id: row.id
|
|
4822
|
-
});
|
|
4823
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
4824
|
-
return;
|
|
4825
|
-
}
|
|
4826
|
-
this.log({
|
|
4827
|
-
level: "warn",
|
|
4828
|
-
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4829
|
-
conversation_id: row.conversation_id,
|
|
4830
|
-
message_id: row.id
|
|
4831
|
-
});
|
|
4832
|
-
return;
|
|
4833
|
-
}
|
|
4834
|
-
this.dontRedispatch.delete(row.id);
|
|
4835
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
4836
|
-
return;
|
|
4837
5447
|
}
|
|
4838
|
-
if (state === "failed") {
|
|
5448
|
+
if (state === "failed" && !restartAborted) {
|
|
4839
5449
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
4840
5450
|
const usage = messageUsage(messages, ocId ?? "");
|
|
4841
5451
|
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
@@ -4888,6 +5498,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4888
5498
|
const ongoing = sessionOngoing;
|
|
4889
5499
|
statusReadableOngoing = ongoing;
|
|
4890
5500
|
if (ongoing === false) {
|
|
5501
|
+
if (isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
|
|
5502
|
+
const finish = reply?.info?.finish ?? reply?.finish;
|
|
5503
|
+
this.log({
|
|
5504
|
+
level: "info",
|
|
5505
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} has an ambiguous finish ("${finish ?? "(absent)"}") but session ${sessionId.slice(0, 8)} is confirmed not-ongoing per GET /session/status \u2014 delivering the existing reply instead of re-dispatching`,
|
|
5506
|
+
conversation_id: row.conversation_id,
|
|
5507
|
+
message_id: row.id
|
|
5508
|
+
});
|
|
5509
|
+
await this.deliverReadoptedDone(sessionId, row, messages, ocId);
|
|
5510
|
+
return;
|
|
5511
|
+
}
|
|
4891
5512
|
this.log({
|
|
4892
5513
|
level: "info",
|
|
4893
5514
|
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)`,
|
|
@@ -4964,6 +5585,63 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4964
5585
|
}
|
|
4965
5586
|
await this.forceReadoptRun(sessionId, row);
|
|
4966
5587
|
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Deliver a `processing` row whose correlated reply already completed while
|
|
5590
|
+
* nobody was watching (ADR-0046) — the `readoptOne` `state === 'done'` body,
|
|
5591
|
+
* extracted (#1493 Task 2.4) so the ambiguous-finish guard above can call the
|
|
5592
|
+
* SAME delivery instead of duplicating it.
|
|
5593
|
+
*
|
|
5594
|
+
* EVEN IF the row was previously parked in `dontRedispatch` (a give-up stops
|
|
5595
|
+
* re-dispatch, not delivery — Bugbot #202). Guarded EXACTLY like the watcher's
|
|
5596
|
+
* `settleMessageDone`: auth re-throws; terminal → park in `doneUndeliverable` +
|
|
5597
|
+
* leave for cron; transient → log + leave for the next drain (the still-
|
|
5598
|
+
* `processing` row is re-read and retried). markDone is idempotent server-side
|
|
5599
|
+
* (status-gated), so a repeat can never double-post.
|
|
5600
|
+
*/
|
|
5601
|
+
async deliverReadoptedDone(sessionId, row, messages, ocId) {
|
|
5602
|
+
if (this.doneUndeliverable.has(row.id)) {
|
|
5603
|
+
this.log({
|
|
5604
|
+
level: "debug",
|
|
5605
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
5606
|
+
conversation_id: row.conversation_id,
|
|
5607
|
+
message_id: row.id
|
|
5608
|
+
});
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
this.log({
|
|
5612
|
+
level: "info",
|
|
5613
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
|
|
5614
|
+
conversation_id: row.conversation_id,
|
|
5615
|
+
message_id: row.id
|
|
5616
|
+
});
|
|
5617
|
+
try {
|
|
5618
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
5619
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
5620
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
5621
|
+
} catch (err) {
|
|
5622
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
5623
|
+
if (err instanceof ChannelTerminalError) {
|
|
5624
|
+
this.doneUndeliverable.add(row.id);
|
|
5625
|
+
this.log({
|
|
5626
|
+
level: "warn",
|
|
5627
|
+
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}`,
|
|
5628
|
+
conversation_id: row.conversation_id,
|
|
5629
|
+
message_id: row.id
|
|
5630
|
+
});
|
|
5631
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
5632
|
+
return;
|
|
5633
|
+
}
|
|
5634
|
+
this.log({
|
|
5635
|
+
level: "warn",
|
|
5636
|
+
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
5637
|
+
conversation_id: row.conversation_id,
|
|
5638
|
+
message_id: row.id
|
|
5639
|
+
});
|
|
5640
|
+
return;
|
|
5641
|
+
}
|
|
5642
|
+
this.dontRedispatch.delete(row.id);
|
|
5643
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
5644
|
+
}
|
|
4967
5645
|
/**
|
|
4968
5646
|
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
4969
5647
|
*
|
|
@@ -5049,15 +5727,39 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5049
5727
|
}
|
|
5050
5728
|
if (ocId === null) {
|
|
5051
5729
|
this.awaitingReadopt.delete(row.id);
|
|
5730
|
+
const streak = this.recordUnconfirmedDispatch(row.id, sessionId);
|
|
5731
|
+
if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
|
|
5732
|
+
this.unconfirmedDispatchFailures.delete(row.id);
|
|
5733
|
+
this.sessions.delete(readoptConv.id);
|
|
5734
|
+
this.supersede(readoptConv.id, sessionId);
|
|
5735
|
+
const errorMessage = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
|
|
5736
|
+
this.log({
|
|
5737
|
+
level: "error",
|
|
5738
|
+
message: errorMessage,
|
|
5739
|
+
conversation_id: row.conversation_id,
|
|
5740
|
+
message_id: row.id
|
|
5741
|
+
});
|
|
5742
|
+
await this.markFailed(row.conversation_id, row.id, null, errorMessage).catch((markErr) => {
|
|
5743
|
+
this.log({
|
|
5744
|
+
level: "warn",
|
|
5745
|
+
message: `markFailed PATCH for message ${row.id.slice(0, 8)} (conversation ${row.conversation_id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
5746
|
+
conversation_id: row.conversation_id,
|
|
5747
|
+
message_id: row.id
|
|
5748
|
+
});
|
|
5749
|
+
});
|
|
5750
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
5751
|
+
return;
|
|
5752
|
+
}
|
|
5052
5753
|
this.log({
|
|
5053
5754
|
level: "warn",
|
|
5054
|
-
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`,
|
|
5755
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next drain`,
|
|
5055
5756
|
conversation_id: row.conversation_id,
|
|
5056
5757
|
message_id: row.id
|
|
5057
5758
|
});
|
|
5058
5759
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
5059
5760
|
return;
|
|
5060
5761
|
}
|
|
5762
|
+
this.unconfirmedDispatchFailures.delete(row.id);
|
|
5061
5763
|
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
5062
5764
|
this.dispatched.add(row.id);
|
|
5063
5765
|
this.readopted.add(row.id);
|
|
@@ -5569,17 +6271,25 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5569
6271
|
* the aborted-in-flight production bug after a restart.
|
|
5570
6272
|
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
5571
6273
|
* (the sub-agent preamble — #253's shape).
|
|
5572
|
-
* - `
|
|
5573
|
-
*
|
|
5574
|
-
*
|
|
5575
|
-
* `
|
|
6274
|
+
* - `ambiguous` — a COMPLETED, non-errored reply whose `finish` is neither
|
|
6275
|
+
* "tool-calls" nor "stop" (issue #1493, class 4 — see
|
|
6276
|
+
* `isAmbiguousFinishPinnedRunning`/Task 2.4).
|
|
6277
|
+
* - `other` — any other shape (defensive; a running row is normally b1, b2 or
|
|
6278
|
+
* ambiguous).
|
|
6279
|
+
* Reads `info.time.completed` / `info.finish` / `info.error` (tolerating the
|
|
6280
|
+
* legacy top-level shape) directly rather than re-importing the module-private
|
|
6281
|
+
* `completedOf`/`finishOf`/`errorOf` — this is a display label only, not a
|
|
6282
|
+
* correctness predicate (that is `isAmbiguousFinishPinnedRunning`'s job).
|
|
5576
6283
|
*/
|
|
5577
6284
|
replyCompletionShape(reply) {
|
|
5578
6285
|
if (!reply) return "other";
|
|
5579
6286
|
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
5580
6287
|
if (completed == null) return "b1";
|
|
5581
6288
|
const finish = reply.info?.finish ?? reply.finish;
|
|
5582
|
-
|
|
6289
|
+
if (finish === "tool-calls") return "b2";
|
|
6290
|
+
const error2 = reply.info?.error ?? reply.error;
|
|
6291
|
+
if (finish !== "stop" && error2 == null) return "ambiguous";
|
|
6292
|
+
return "other";
|
|
5583
6293
|
}
|
|
5584
6294
|
/**
|
|
5585
6295
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
@@ -5722,18 +6432,22 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5722
6432
|
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
5723
6433
|
* deep-linked "View in Evident" notice).
|
|
5724
6434
|
*
|
|
5725
|
-
*
|
|
5726
|
-
* -
|
|
5727
|
-
*
|
|
5728
|
-
*
|
|
5729
|
-
*
|
|
5730
|
-
*
|
|
5731
|
-
*
|
|
5732
|
-
*
|
|
5733
|
-
*
|
|
5734
|
-
*
|
|
5735
|
-
*
|
|
5736
|
-
*
|
|
6435
|
+
* Outcome contract (consumed by the watcher's swap-to-running guard):
|
|
6436
|
+
* - resolves (`void`) → the server transitioned the row to
|
|
6437
|
+
* processing (or idempotently confirmed
|
|
6438
|
+
* already-processing — that answer is
|
|
6439
|
+
* still a 200, never a refusal);
|
|
6440
|
+
* - throws `ChannelAuthError` → 401/403 (terminal auth failure);
|
|
6441
|
+
* - throws `ChannelTerminalError` → a definitive non-retryable, non-auth 4xx
|
|
6442
|
+
* (404 the row or its conversation is
|
|
6443
|
+
* gone, 400 the update was rejected).
|
|
6444
|
+
* Retrying cannot help;
|
|
6445
|
+
* - throws a plain `Error` → a TRANSIENT failure (retryable 5xx/429
|
|
6446
|
+
* status, or a network-level error from
|
|
6447
|
+
* `fetch`) — i.e. NO definitive server
|
|
6448
|
+
* response — so the caller leaves the
|
|
6449
|
+
* message un-started and retries the swap
|
|
6450
|
+
* on the next tick.
|
|
5737
6451
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
5738
6452
|
* retry vehicle for the swap-to-running.
|
|
5739
6453
|
*/
|
|
@@ -5752,11 +6466,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5752
6466
|
}
|
|
5753
6467
|
);
|
|
5754
6468
|
this.assertAuth(res, "marking message as processing");
|
|
5755
|
-
if (res.ok) return
|
|
6469
|
+
if (res.ok) return;
|
|
5756
6470
|
if (isRetryableStatus(res.status)) {
|
|
5757
6471
|
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
5758
6472
|
}
|
|
5759
|
-
|
|
6473
|
+
throw new ChannelTerminalError(`marking message as processing: HTTP ${res.status}`, res.status);
|
|
5760
6474
|
}
|
|
5761
6475
|
/**
|
|
5762
6476
|
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
|
|
@@ -6192,7 +6906,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
6192
6906
|
if (trimmed === "") {
|
|
6193
6907
|
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
6194
6908
|
}
|
|
6195
|
-
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ?
|
|
6909
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join4(homeDir, trimmed.slice(2)) : trimmed;
|
|
6196
6910
|
if (!isAbsolute2(expanded)) {
|
|
6197
6911
|
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
6198
6912
|
}
|
|
@@ -6242,6 +6956,32 @@ function resolveOpenCodeStartTimeoutMs(options, env = process.env) {
|
|
|
6242
6956
|
}
|
|
6243
6957
|
return { timeoutMs: seconds * 1e3, warnings: [] };
|
|
6244
6958
|
}
|
|
6959
|
+
var MAX_ACTIVE_SESSIONS_ENV = "EVIDENT_MAX_ACTIVE_SESSIONS";
|
|
6960
|
+
function resolveMaxActiveSessions(options, env = process.env) {
|
|
6961
|
+
let raw;
|
|
6962
|
+
let source;
|
|
6963
|
+
if (options.maxActiveSessions !== void 0) {
|
|
6964
|
+
raw = options.maxActiveSessions;
|
|
6965
|
+
source = "--max-active-sessions";
|
|
6966
|
+
} else if (env[MAX_ACTIVE_SESSIONS_ENV] !== void 0 && env[MAX_ACTIVE_SESSIONS_ENV] !== "") {
|
|
6967
|
+
raw = env[MAX_ACTIVE_SESSIONS_ENV];
|
|
6968
|
+
source = MAX_ACTIVE_SESSIONS_ENV;
|
|
6969
|
+
} else {
|
|
6970
|
+
return { value: void 0, warnings: [] };
|
|
6971
|
+
}
|
|
6972
|
+
const trimmed = raw.trim();
|
|
6973
|
+
const count = Number(trimmed);
|
|
6974
|
+
const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(count) && count > 0;
|
|
6975
|
+
if (!isPositiveInteger) {
|
|
6976
|
+
return {
|
|
6977
|
+
value: void 0,
|
|
6978
|
+
warnings: [
|
|
6979
|
+
`Ignoring invalid ${source} "${raw}": expected a positive integer; using unlimited`
|
|
6980
|
+
]
|
|
6981
|
+
};
|
|
6982
|
+
}
|
|
6983
|
+
return { value: count, warnings: [] };
|
|
6984
|
+
}
|
|
6245
6985
|
function meetsThreshold(state, level) {
|
|
6246
6986
|
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
6247
6987
|
}
|
|
@@ -6360,7 +7100,9 @@ async function handleAuthError(state, error2) {
|
|
|
6360
7100
|
);
|
|
6361
7101
|
const newAuthHeader = getAuthHeader(credentials2);
|
|
6362
7102
|
return { success: true, newAuthHeader };
|
|
6363
|
-
} catch {
|
|
7103
|
+
} catch (error3) {
|
|
7104
|
+
const message = error3 instanceof Error ? error3.message : String(error3);
|
|
7105
|
+
logActivity(state, { type: "error", error: `Re-authentication failed: ${message}` });
|
|
6364
7106
|
return { success: false };
|
|
6365
7107
|
}
|
|
6366
7108
|
}
|
|
@@ -6465,6 +7207,10 @@ async function driveChannels(state, driver) {
|
|
|
6465
7207
|
}
|
|
6466
7208
|
}
|
|
6467
7209
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
7210
|
+
var SESSION_DB_RECLAIM_MAX_PAGES = 2e3;
|
|
7211
|
+
function sessionDbPath() {
|
|
7212
|
+
return join4(homedir3(), ".local", "share", "opencode", "opencode.db");
|
|
7213
|
+
}
|
|
6468
7214
|
async function runSweep(state, driver, config) {
|
|
6469
7215
|
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
6470
7216
|
try {
|
|
@@ -6507,6 +7253,25 @@ async function runSweep(state, driver, config) {
|
|
|
6507
7253
|
type: "info",
|
|
6508
7254
|
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
6509
7255
|
});
|
|
7256
|
+
const reclaimResult = await reclaimSessionDbSpace({
|
|
7257
|
+
dbPath: sessionDbPath(),
|
|
7258
|
+
maxPages: SESSION_DB_RECLAIM_MAX_PAGES,
|
|
7259
|
+
allowFullVacuum: protectedNow.size === 0
|
|
7260
|
+
});
|
|
7261
|
+
if (reclaimResult.ok) {
|
|
7262
|
+
const beforeMib = (reclaimResult.beforeBytes / 1024 / 1024).toFixed(1);
|
|
7263
|
+
const afterMib = (reclaimResult.afterBytes / 1024 / 1024).toFixed(1);
|
|
7264
|
+
const checkpointNote = reclaimResult.checkpoint.busy ? ` (on-disk file truncation deferred: checkpoint busy, ${reclaimResult.checkpoint.log} WAL frames pending)` : "";
|
|
7265
|
+
logActivity(state, {
|
|
7266
|
+
type: "info",
|
|
7267
|
+
message: `Session cleanup: reclaimed session-db space (${reclaimResult.mode}): ${beforeMib} MiB -> ${afterMib} MiB${checkpointNote}`
|
|
7268
|
+
});
|
|
7269
|
+
} else {
|
|
7270
|
+
logActivity(state, {
|
|
7271
|
+
type: "info",
|
|
7272
|
+
message: `Session cleanup: session-db space reclaim skipped (${reclaimResult.skipped})`
|
|
7273
|
+
});
|
|
7274
|
+
}
|
|
6510
7275
|
} catch (error2) {
|
|
6511
7276
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
6512
7277
|
logActivity(state, {
|
|
@@ -6527,6 +7292,22 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
6527
7292
|
for (const warning2 of config.warnings) {
|
|
6528
7293
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
6529
7294
|
}
|
|
7295
|
+
const dbBytes = statSessionDbBytes(homedir3());
|
|
7296
|
+
void (async () => {
|
|
7297
|
+
const reclaimSkipReason = dbBytes !== null && config.enabled ? await probeReclaimAvailability({ dbPath: sessionDbPath(), requiredBytes: dbBytes }) : null;
|
|
7298
|
+
const sizeWarning = buildSessionStoreSizeWarning({
|
|
7299
|
+
dbBytes,
|
|
7300
|
+
cleanupEnabled: config.enabled,
|
|
7301
|
+
reclaimSkipReason
|
|
7302
|
+
});
|
|
7303
|
+
if (sizeWarning !== null) {
|
|
7304
|
+
logActivity(state, { type: "info", level: "warn", message: sizeWarning });
|
|
7305
|
+
}
|
|
7306
|
+
})().catch((err) => {
|
|
7307
|
+
console.error(
|
|
7308
|
+
`[scheduleSessionCleanup] size-warning preflight failed: ${err instanceof Error ? err.message : String(err)}`
|
|
7309
|
+
);
|
|
7310
|
+
});
|
|
6530
7311
|
if (!config.enabled) return;
|
|
6531
7312
|
logActivity(state, {
|
|
6532
7313
|
type: "info",
|
|
@@ -6967,6 +7748,10 @@ async function run(options) {
|
|
|
6967
7748
|
for (const warning2 of opencodeStartTimeoutWarnings) {
|
|
6968
7749
|
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
6969
7750
|
}
|
|
7751
|
+
const { value: maxActiveSessions, warnings: maxActiveSessionsWarnings } = resolveMaxActiveSessions(options, process.env);
|
|
7752
|
+
for (const warning2 of maxActiveSessionsWarnings) {
|
|
7753
|
+
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
7754
|
+
}
|
|
6970
7755
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
6971
7756
|
try {
|
|
6972
7757
|
const oc = await ensureOpenCodeRunning({
|
|
@@ -7027,6 +7812,7 @@ async function run(options) {
|
|
|
7027
7812
|
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
7028
7813
|
fileSyncDirectories,
|
|
7029
7814
|
homeDir: homedir3(),
|
|
7815
|
+
maxActiveSessions,
|
|
7030
7816
|
log: (entry) => (
|
|
7031
7817
|
// Thread the driver's real level straight through so `debug`/`warn`
|
|
7032
7818
|
// survive the sink filter (they no longer collapse to info). `type`
|
|
@@ -7227,6 +8013,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
7227
8013
|
).option(
|
|
7228
8014
|
"--session-cleanup-max-count <n>",
|
|
7229
8015
|
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
8016
|
+
).option(
|
|
8017
|
+
"--max-active-sessions <n>",
|
|
8018
|
+
"Cap how many sessions this runner works on at once (default: unlimited). Env: EVIDENT_MAX_ACTIVE_SESSIONS"
|
|
7230
8019
|
).option(
|
|
7231
8020
|
"--session-cleanup-interval <duration>",
|
|
7232
8021
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
@@ -7260,6 +8049,7 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
7260
8049
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
7261
8050
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
7262
8051
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
8052
|
+
maxActiveSessions: options.maxActiveSessions,
|
|
7263
8053
|
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
7264
8054
|
// Raw string — the resolver in run.ts single-sources parsing
|
|
7265
8055
|
// (resolveClaudeUsageReportingMode).
|