@evident-ai/cli 3.2.1-dev.6d96a16 → 3.2.1-dev.73d539a
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 +7 -2
- package/dist/index.js +391 -67
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1023,7 +1023,7 @@ async function claudeUsage() {
|
|
|
1023
1023
|
|
|
1024
1024
|
// src/commands/run.ts
|
|
1025
1025
|
import { homedir as homedir3 } from "os";
|
|
1026
|
-
import { isAbsolute as isAbsolute2, join as
|
|
1026
|
+
import { isAbsolute as isAbsolute2, join as join4, parse, resolve as resolvePath } from "path";
|
|
1027
1027
|
import chalk6 from "chalk";
|
|
1028
1028
|
|
|
1029
1029
|
// ../../packages/types/src/agents/index.ts
|
|
@@ -1052,6 +1052,7 @@ var MAX_FILE_SYNC_DIRECTORIES = 16;
|
|
|
1052
1052
|
|
|
1053
1053
|
// ../../packages/types/src/logging/index.ts
|
|
1054
1054
|
var CORRELATION_ID_HEADER = "x-evident-correlation-id";
|
|
1055
|
+
var FORWARD_FAILURE_REASON_HEADER = "X-Evident-Failure-Reason";
|
|
1055
1056
|
function log(level, event, fields) {
|
|
1056
1057
|
const method = level === "debug" ? "log" : level;
|
|
1057
1058
|
try {
|
|
@@ -2106,7 +2107,9 @@ function messageRunState(messages, userMessageId) {
|
|
|
2106
2107
|
}
|
|
2107
2108
|
if (!reply) return "queued";
|
|
2108
2109
|
if (isAssistantInFlight(reply)) return "running";
|
|
2109
|
-
|
|
2110
|
+
if (errorOf(reply) != null) return "failed";
|
|
2111
|
+
if (isAmbiguousTerminalFinish(reply)) return "running";
|
|
2112
|
+
return "done";
|
|
2110
2113
|
}
|
|
2111
2114
|
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
2112
2115
|
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
@@ -2116,6 +2119,19 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
2116
2119
|
function isB2AbandonmentConfirmed(params) {
|
|
2117
2120
|
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
2118
2121
|
}
|
|
2122
|
+
function isAmbiguousTerminalFinish(m) {
|
|
2123
|
+
if (completedOf(m) == null) return false;
|
|
2124
|
+
if (errorOf(m) != null) return false;
|
|
2125
|
+
const finish = finishOf(m);
|
|
2126
|
+
return finish !== "tool-calls" && finish !== "stop";
|
|
2127
|
+
}
|
|
2128
|
+
function isAmbiguousFinishPinnedRunning(messages, userMessageId) {
|
|
2129
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2130
|
+
return isAmbiguousTerminalFinish(reply);
|
|
2131
|
+
}
|
|
2132
|
+
function isAmbiguousFinishResolved(params) {
|
|
2133
|
+
return params.sessionOngoing === false || params.pinnedForMs >= params.maxPinnedMs;
|
|
2134
|
+
}
|
|
2119
2135
|
function messageError(messages, userMessageId) {
|
|
2120
2136
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2121
2137
|
const error2 = errorOf(reply);
|
|
@@ -2322,6 +2338,149 @@ function resolveSessionCleanupConfig(flags, env = process.env) {
|
|
|
2322
2338
|
return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
|
|
2323
2339
|
}
|
|
2324
2340
|
|
|
2341
|
+
// src/lib/opencode/session-db-size.ts
|
|
2342
|
+
import { statSync as statSync2 } from "fs";
|
|
2343
|
+
import { join as join2 } from "path";
|
|
2344
|
+
var LARGE_DB_THRESHOLD_BYTES = 268435456;
|
|
2345
|
+
function statSessionDbBytes(homeDir) {
|
|
2346
|
+
const dbPath = join2(homeDir, ".local", "share", "opencode", "opencode.db");
|
|
2347
|
+
try {
|
|
2348
|
+
return statSync2(dbPath).size;
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
const isMissingFile = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
2351
|
+
if (!isMissingFile) {
|
|
2352
|
+
console.error(
|
|
2353
|
+
`[statSessionDbBytes] could not stat ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
return null;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
function buildSessionStoreSizeWarning(input) {
|
|
2360
|
+
const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
|
|
2361
|
+
if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
|
|
2362
|
+
const mib = Math.round(dbBytes / 1024 / 1024);
|
|
2363
|
+
if (!cleanupEnabled) {
|
|
2364
|
+
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.`;
|
|
2365
|
+
}
|
|
2366
|
+
if (reclaimSkipReason === "sqlite-unavailable" || reclaimSkipReason === "insufficient-disk-space") {
|
|
2367
|
+
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";
|
|
2368
|
+
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.`;
|
|
2369
|
+
}
|
|
2370
|
+
return null;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// src/lib/opencode/session-db-reclaim.ts
|
|
2374
|
+
import { statSync as statSync3, statfsSync } from "fs";
|
|
2375
|
+
import { dirname as dirname2 } from "path";
|
|
2376
|
+
function insufficientSpaceReason(dbPath, requiredBytes) {
|
|
2377
|
+
try {
|
|
2378
|
+
const fsStats = statfsSync(dirname2(dbPath));
|
|
2379
|
+
const availableBytes = fsStats.bavail * fsStats.bsize;
|
|
2380
|
+
if (availableBytes < requiredBytes) {
|
|
2381
|
+
return `only ${availableBytes} bytes free, need ${requiredBytes} for a second copy`;
|
|
2382
|
+
}
|
|
2383
|
+
return null;
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
return `could not check free space (${err instanceof Error ? err.message : String(err)}); refusing to guess`;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
function readLogicalBytes(db) {
|
|
2389
|
+
const pageCount = db.prepare("PRAGMA page_count").get().page_count;
|
|
2390
|
+
const pageSize = db.prepare("PRAGMA page_size").get().page_size;
|
|
2391
|
+
return pageCount * pageSize;
|
|
2392
|
+
}
|
|
2393
|
+
function readCheckpointResult(db) {
|
|
2394
|
+
const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
2395
|
+
return { busy: row.busy !== 0, log: row.log, checkpointed: row.checkpointed };
|
|
2396
|
+
}
|
|
2397
|
+
async function probeReclaimAvailability(input) {
|
|
2398
|
+
const { dbPath, requiredBytes } = input;
|
|
2399
|
+
let sqlite;
|
|
2400
|
+
try {
|
|
2401
|
+
sqlite = await import("sqlite");
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
console.warn(
|
|
2404
|
+
`[probeReclaimAvailability] node:sqlite unavailable for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2405
|
+
);
|
|
2406
|
+
return "sqlite-unavailable";
|
|
2407
|
+
}
|
|
2408
|
+
let autoVacuum = null;
|
|
2409
|
+
try {
|
|
2410
|
+
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
|
|
2411
|
+
try {
|
|
2412
|
+
autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
|
|
2413
|
+
} finally {
|
|
2414
|
+
db.close();
|
|
2415
|
+
}
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
console.warn(
|
|
2418
|
+
`[probeReclaimAvailability] could not read auto_vacuum mode for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2421
|
+
if (autoVacuum !== 0) return null;
|
|
2422
|
+
return insufficientSpaceReason(dbPath, requiredBytes) !== null ? "insufficient-disk-space" : null;
|
|
2423
|
+
}
|
|
2424
|
+
async function reclaimSessionDbSpace(input) {
|
|
2425
|
+
const { dbPath, maxPages, allowFullVacuum = true } = input;
|
|
2426
|
+
let sqlite;
|
|
2427
|
+
try {
|
|
2428
|
+
sqlite = await import("sqlite");
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
console.warn(
|
|
2431
|
+
`[reclaimSessionDbSpace] node:sqlite unavailable (need Node >=22.5, >=22.13 unflagged) for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2432
|
+
);
|
|
2433
|
+
return { ok: false, skipped: "sqlite-unavailable" };
|
|
2434
|
+
}
|
|
2435
|
+
const { DatabaseSync } = sqlite;
|
|
2436
|
+
let db;
|
|
2437
|
+
try {
|
|
2438
|
+
db = new DatabaseSync(dbPath);
|
|
2439
|
+
const autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
|
|
2440
|
+
if (autoVacuum === 0) {
|
|
2441
|
+
if (!allowFullVacuum) {
|
|
2442
|
+
console.warn(
|
|
2443
|
+
`[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: a session turn is live`
|
|
2444
|
+
);
|
|
2445
|
+
return { ok: false, skipped: "full-vacuum-blocked" };
|
|
2446
|
+
}
|
|
2447
|
+
const fileBytesForGuard = statSync3(dbPath).size;
|
|
2448
|
+
const skipReason = insufficientSpaceReason(dbPath, fileBytesForGuard);
|
|
2449
|
+
if (skipReason !== null) {
|
|
2450
|
+
console.warn(
|
|
2451
|
+
`[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: ${skipReason}`
|
|
2452
|
+
);
|
|
2453
|
+
return { ok: false, skipped: "insufficient-disk-space" };
|
|
2454
|
+
}
|
|
2455
|
+
const beforeBytes = readLogicalBytes(db);
|
|
2456
|
+
db.exec("PRAGMA auto_vacuum=INCREMENTAL");
|
|
2457
|
+
db.exec("VACUUM");
|
|
2458
|
+
const afterBytes = readLogicalBytes(db);
|
|
2459
|
+
const checkpoint = readCheckpointResult(db);
|
|
2460
|
+
return { ok: true, mode: "convert", beforeBytes, afterBytes, checkpoint };
|
|
2461
|
+
}
|
|
2462
|
+
if (autoVacuum === 2) {
|
|
2463
|
+
const beforeBytes = readLogicalBytes(db);
|
|
2464
|
+
const bound = Math.max(0, Math.trunc(maxPages));
|
|
2465
|
+
db.exec(`PRAGMA incremental_vacuum(${bound})`);
|
|
2466
|
+
const afterBytes = readLogicalBytes(db);
|
|
2467
|
+
const checkpoint = readCheckpointResult(db);
|
|
2468
|
+
return { ok: true, mode: "incremental", beforeBytes, afterBytes, checkpoint };
|
|
2469
|
+
}
|
|
2470
|
+
console.warn(
|
|
2471
|
+
`[reclaimSessionDbSpace] ${dbPath} has auto_vacuum=${autoVacuum} (neither NONE nor INCREMENTAL); nothing to reclaim`
|
|
2472
|
+
);
|
|
2473
|
+
return { ok: false, skipped: "auto-vacuum-not-applicable" };
|
|
2474
|
+
} catch (err) {
|
|
2475
|
+
console.error(
|
|
2476
|
+
`[reclaimSessionDbSpace] reclaim failed for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
2477
|
+
);
|
|
2478
|
+
return { ok: false, skipped: "reclaim-error" };
|
|
2479
|
+
} finally {
|
|
2480
|
+
db?.close();
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2325
2484
|
// src/lib/tunnel/connection.ts
|
|
2326
2485
|
import WebSocket2 from "ws";
|
|
2327
2486
|
|
|
@@ -2487,6 +2646,17 @@ var StreamForwarder = class {
|
|
|
2487
2646
|
};
|
|
2488
2647
|
|
|
2489
2648
|
// src/lib/tunnel/connection.ts
|
|
2649
|
+
var FAILURE_REASON_HEADER_LC = FORWARD_FAILURE_REASON_HEADER.toLowerCase();
|
|
2650
|
+
var TunnelUpgradeRejectedError = class extends Error {
|
|
2651
|
+
constructor(message, reason) {
|
|
2652
|
+
super(message);
|
|
2653
|
+
this.reason = reason;
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
function classifyUpgradeRejection(headers) {
|
|
2657
|
+
const value = headers[FAILURE_REASON_HEADER_LC];
|
|
2658
|
+
return value === "do_code_updated" ? "do_code_updated" : "unknown";
|
|
2659
|
+
}
|
|
2490
2660
|
var MAX_RECONNECT_DELAY = 3e4;
|
|
2491
2661
|
var BASE_RECONNECT_DELAY = 500;
|
|
2492
2662
|
function getReconnectDelay(attempt) {
|
|
@@ -2531,6 +2701,7 @@ function connectTunnel(options) {
|
|
|
2531
2701
|
onError,
|
|
2532
2702
|
onResponse,
|
|
2533
2703
|
onInfo,
|
|
2704
|
+
onWarning,
|
|
2534
2705
|
onDrainPing
|
|
2535
2706
|
} = options;
|
|
2536
2707
|
const tunnelUrl = getTunnelUrlConfig();
|
|
@@ -2550,8 +2721,11 @@ function connectTunnel(options) {
|
|
|
2550
2721
|
reject(new Error("Connection timeout"));
|
|
2551
2722
|
}, 3e4);
|
|
2552
2723
|
let upgradeRejection = null;
|
|
2724
|
+
let upgradeRejectionReason = null;
|
|
2553
2725
|
ws.on("unexpected-response", (_req, res) => {
|
|
2554
2726
|
clearTimeout(connectionTimeout);
|
|
2727
|
+
const reason = classifyUpgradeRejection(res.headers);
|
|
2728
|
+
upgradeRejectionReason = reason;
|
|
2555
2729
|
const chunks = [];
|
|
2556
2730
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
2557
2731
|
res.on("end", () => {
|
|
@@ -2565,8 +2739,14 @@ function connectTunnel(options) {
|
|
|
2565
2739
|
}
|
|
2566
2740
|
const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
|
|
2567
2741
|
upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
|
|
2568
|
-
|
|
2569
|
-
|
|
2742
|
+
if (reason === "do_code_updated") {
|
|
2743
|
+
onWarning?.("Relay redeployed \u2014 reconnecting");
|
|
2744
|
+
} else {
|
|
2745
|
+
onError?.(`Tunnel refused by relay (${upgradeRejection})`);
|
|
2746
|
+
}
|
|
2747
|
+
reject(
|
|
2748
|
+
new TunnelUpgradeRejectedError(`Tunnel handshake rejected: ${upgradeRejection}`, reason)
|
|
2749
|
+
);
|
|
2570
2750
|
});
|
|
2571
2751
|
});
|
|
2572
2752
|
ws.on("open", () => {
|
|
@@ -2612,8 +2792,14 @@ function connectTunnel(options) {
|
|
|
2612
2792
|
ws.on("error", (error2) => {
|
|
2613
2793
|
clearTimeout(connectionTimeout);
|
|
2614
2794
|
const detail = upgradeRejection ?? describeSocketError(error2, url);
|
|
2615
|
-
|
|
2616
|
-
|
|
2795
|
+
if (upgradeRejectionReason === "do_code_updated") {
|
|
2796
|
+
onWarning?.("Relay redeployed \u2014 reconnecting");
|
|
2797
|
+
} else {
|
|
2798
|
+
onError?.(`Connection error: ${detail}`);
|
|
2799
|
+
}
|
|
2800
|
+
reject(
|
|
2801
|
+
upgradeRejectionReason !== null ? new TunnelUpgradeRejectedError(detail, upgradeRejectionReason) : new Error(detail)
|
|
2802
|
+
);
|
|
2617
2803
|
});
|
|
2618
2804
|
ws.on("close", (code, reason) => {
|
|
2619
2805
|
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
@@ -2689,7 +2875,8 @@ var RunnerConnection = class {
|
|
|
2689
2875
|
onError: (error2) => events.onError?.(error2),
|
|
2690
2876
|
onResponse: () => events.onResponse?.(),
|
|
2691
2877
|
onDrainPing: () => events.onDrainPing?.(),
|
|
2692
|
-
onInfo: (message) => events.onInfo?.(message)
|
|
2878
|
+
onInfo: (message) => events.onInfo?.(message),
|
|
2879
|
+
onWarning: (message) => events.onWarning?.(message)
|
|
2693
2880
|
});
|
|
2694
2881
|
return;
|
|
2695
2882
|
} catch (error2) {
|
|
@@ -2700,7 +2887,12 @@ var RunnerConnection = class {
|
|
|
2700
2887
|
}
|
|
2701
2888
|
const delay = getReconnectDelay(this.reconnectAttempt);
|
|
2702
2889
|
events.onReconnecting?.(this.reconnectAttempt);
|
|
2703
|
-
|
|
2890
|
+
const retryMessage = `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`;
|
|
2891
|
+
if (error2 instanceof TunnelUpgradeRejectedError && error2.reason === "do_code_updated") {
|
|
2892
|
+
events.onWarning?.(retryMessage);
|
|
2893
|
+
} else {
|
|
2894
|
+
events.onError?.(retryMessage);
|
|
2895
|
+
}
|
|
2704
2896
|
await this.sleep(delay);
|
|
2705
2897
|
}
|
|
2706
2898
|
}
|
|
@@ -2757,7 +2949,7 @@ import { homedir as homedir2 } from "os";
|
|
|
2757
2949
|
// src/lib/file-push.ts
|
|
2758
2950
|
import { randomUUID } from "crypto";
|
|
2759
2951
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2760
|
-
import { basename, dirname as
|
|
2952
|
+
import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
|
|
2761
2953
|
var FILE_MODE = 384;
|
|
2762
2954
|
var DIRECTORY_MODE = 448;
|
|
2763
2955
|
async function writePushedFile(request) {
|
|
@@ -2788,9 +2980,9 @@ async function writePushedFile(request) {
|
|
|
2788
2980
|
}
|
|
2789
2981
|
try {
|
|
2790
2982
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2791
|
-
|
|
2983
|
+
dirname3(candidate)
|
|
2792
2984
|
);
|
|
2793
|
-
const realTarget =
|
|
2985
|
+
const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
|
|
2794
2986
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2795
2987
|
if (allowedDirectory === null) {
|
|
2796
2988
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
@@ -2800,8 +2992,8 @@ async function writePushedFile(request) {
|
|
|
2800
2992
|
}
|
|
2801
2993
|
if (missingSegments.length > 0) {
|
|
2802
2994
|
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2803
|
-
const realParent = await realpath(
|
|
2804
|
-
if (realParent !==
|
|
2995
|
+
const realParent = await realpath(dirname3(realTarget));
|
|
2996
|
+
if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2805
2997
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2806
2998
|
path: realTarget,
|
|
2807
2999
|
bytes,
|
|
@@ -2826,7 +3018,7 @@ function expandAndValidate(requestedPath, homeDir) {
|
|
|
2826
3018
|
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2827
3019
|
return null;
|
|
2828
3020
|
}
|
|
2829
|
-
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ?
|
|
3021
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join3(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2830
3022
|
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2831
3023
|
return null;
|
|
2832
3024
|
}
|
|
@@ -2844,7 +3036,7 @@ async function resolveNearestExistingAncestor(directory) {
|
|
|
2844
3036
|
try {
|
|
2845
3037
|
return { existingAncestor: await realpath(current), missingSegments };
|
|
2846
3038
|
} catch (err) {
|
|
2847
|
-
const parent =
|
|
3039
|
+
const parent = dirname3(current);
|
|
2848
3040
|
if (err.code !== "ENOENT" || parent === current) {
|
|
2849
3041
|
throw err;
|
|
2850
3042
|
}
|
|
@@ -2899,13 +3091,13 @@ function contains(realDirectory, realTarget) {
|
|
|
2899
3091
|
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2900
3092
|
let current = existingAncestor;
|
|
2901
3093
|
for (const segment of missingSegments) {
|
|
2902
|
-
current =
|
|
3094
|
+
current = join3(current, segment);
|
|
2903
3095
|
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2904
3096
|
await chmod(current, DIRECTORY_MODE);
|
|
2905
3097
|
}
|
|
2906
3098
|
}
|
|
2907
3099
|
async function writeAtomically(realTarget, content) {
|
|
2908
|
-
const temporaryPath =
|
|
3100
|
+
const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2909
3101
|
let handle;
|
|
2910
3102
|
try {
|
|
2911
3103
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -3178,6 +3370,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
|
3178
3370
|
var HEARTBEAT_MS = 6e4;
|
|
3179
3371
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
3180
3372
|
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
3373
|
+
var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
|
|
3181
3374
|
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
3182
3375
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
3183
3376
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
@@ -4012,6 +4205,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4012
4205
|
return this.reattachRedrive(conv, sessionId, message, ocId);
|
|
4013
4206
|
}
|
|
4014
4207
|
if (ongoing === false) {
|
|
4208
|
+
if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
|
|
4209
|
+
return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
|
|
4210
|
+
}
|
|
4015
4211
|
this.clearRedriveUnresolved(message.id);
|
|
4016
4212
|
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
4017
4213
|
return "dispatch";
|
|
@@ -4623,7 +4819,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4623
4819
|
deliveryDeadlineAnchored: false,
|
|
4624
4820
|
b2PinnedSinceMs: 0,
|
|
4625
4821
|
b2LastDescendantCheckMs: 0,
|
|
4626
|
-
b2AbandonedSignalled: false
|
|
4822
|
+
b2AbandonedSignalled: false,
|
|
4823
|
+
ambiguousPinnedSinceMs: 0,
|
|
4824
|
+
ambiguousResolved: false
|
|
4627
4825
|
});
|
|
4628
4826
|
}
|
|
4629
4827
|
/**
|
|
@@ -4702,7 +4900,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4702
4900
|
deliveryDeadlineAnchored: false,
|
|
4703
4901
|
b2PinnedSinceMs: 0,
|
|
4704
4902
|
b2LastDescendantCheckMs: 0,
|
|
4705
|
-
b2AbandonedSignalled: false
|
|
4903
|
+
b2AbandonedSignalled: false,
|
|
4904
|
+
ambiguousPinnedSinceMs: 0,
|
|
4905
|
+
ambiguousResolved: false
|
|
4706
4906
|
});
|
|
4707
4907
|
}
|
|
4708
4908
|
/**
|
|
@@ -4970,6 +5170,49 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4970
5170
|
}
|
|
4971
5171
|
}
|
|
4972
5172
|
}
|
|
5173
|
+
const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
|
|
5174
|
+
if (!ambiguousPinnedNow) {
|
|
5175
|
+
if (snapshotReadable) {
|
|
5176
|
+
inFlight.ambiguousPinnedSinceMs = 0;
|
|
5177
|
+
inFlight.ambiguousResolved = false;
|
|
5178
|
+
}
|
|
5179
|
+
} else {
|
|
5180
|
+
if (inFlight.ambiguousResolved) {
|
|
5181
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
5182
|
+
return;
|
|
5183
|
+
}
|
|
5184
|
+
if (inFlight.ambiguousPinnedSinceMs === 0) {
|
|
5185
|
+
inFlight.ambiguousPinnedSinceMs = this.now();
|
|
5186
|
+
const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
|
|
5187
|
+
const finish = reply?.info?.finish ?? reply?.finish;
|
|
5188
|
+
this.log({
|
|
5189
|
+
level: "warn",
|
|
5190
|
+
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)`,
|
|
5191
|
+
conversation_id: conv.id,
|
|
5192
|
+
message_id: id
|
|
5193
|
+
});
|
|
5194
|
+
}
|
|
5195
|
+
const pinnedForMs = this.now() - inFlight.ambiguousPinnedSinceMs;
|
|
5196
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
5197
|
+
if (isAmbiguousFinishResolved({
|
|
5198
|
+
pinnedForMs,
|
|
5199
|
+
maxPinnedMs: AMBIGUOUS_FINISH_MAX_PINNED_MS,
|
|
5200
|
+
sessionOngoing: ongoing
|
|
5201
|
+
})) {
|
|
5202
|
+
inFlight.ambiguousResolved = true;
|
|
5203
|
+
this.log({
|
|
5204
|
+
level: "warn",
|
|
5205
|
+
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`,
|
|
5206
|
+
conversation_id: conv.id,
|
|
5207
|
+
message_id: id
|
|
5208
|
+
});
|
|
5209
|
+
void this.postSignal(conv.id, id, "ambiguous_finish_resolved", {
|
|
5210
|
+
watched_for_ms: pinnedForMs
|
|
5211
|
+
});
|
|
5212
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
}
|
|
4973
5216
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
4974
5217
|
this.log({
|
|
4975
5218
|
level: "warn",
|
|
@@ -5224,48 +5467,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5224
5467
|
const ocId = row.opencode_message_id;
|
|
5225
5468
|
const state = messageRunState(messages, ocId ?? "");
|
|
5226
5469
|
if (state === "done") {
|
|
5227
|
-
|
|
5228
|
-
this.log({
|
|
5229
|
-
level: "debug",
|
|
5230
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
5231
|
-
conversation_id: row.conversation_id,
|
|
5232
|
-
message_id: row.id
|
|
5233
|
-
});
|
|
5234
|
-
return;
|
|
5235
|
-
}
|
|
5236
|
-
this.log({
|
|
5237
|
-
level: "info",
|
|
5238
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
|
|
5239
|
-
conversation_id: row.conversation_id,
|
|
5240
|
-
message_id: row.id
|
|
5241
|
-
});
|
|
5242
|
-
try {
|
|
5243
|
-
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
5244
|
-
const usage = messageUsage(messages, ocId ?? "");
|
|
5245
|
-
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
5246
|
-
} catch (err) {
|
|
5247
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
5248
|
-
if (err instanceof ChannelTerminalError) {
|
|
5249
|
-
this.doneUndeliverable.add(row.id);
|
|
5250
|
-
this.log({
|
|
5251
|
-
level: "warn",
|
|
5252
|
-
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}`,
|
|
5253
|
-
conversation_id: row.conversation_id,
|
|
5254
|
-
message_id: row.id
|
|
5255
|
-
});
|
|
5256
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
5257
|
-
return;
|
|
5258
|
-
}
|
|
5259
|
-
this.log({
|
|
5260
|
-
level: "warn",
|
|
5261
|
-
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
5262
|
-
conversation_id: row.conversation_id,
|
|
5263
|
-
message_id: row.id
|
|
5264
|
-
});
|
|
5265
|
-
return;
|
|
5266
|
-
}
|
|
5267
|
-
this.dontRedispatch.delete(row.id);
|
|
5268
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
5470
|
+
await this.deliverReadoptedDone(sessionId, row, messages, ocId);
|
|
5269
5471
|
return;
|
|
5270
5472
|
}
|
|
5271
5473
|
const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
|
|
@@ -5330,6 +5532,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5330
5532
|
const ongoing = sessionOngoing;
|
|
5331
5533
|
statusReadableOngoing = ongoing;
|
|
5332
5534
|
if (ongoing === false) {
|
|
5535
|
+
if (isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
|
|
5536
|
+
const finish = reply?.info?.finish ?? reply?.finish;
|
|
5537
|
+
this.log({
|
|
5538
|
+
level: "info",
|
|
5539
|
+
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`,
|
|
5540
|
+
conversation_id: row.conversation_id,
|
|
5541
|
+
message_id: row.id
|
|
5542
|
+
});
|
|
5543
|
+
await this.deliverReadoptedDone(sessionId, row, messages, ocId);
|
|
5544
|
+
return;
|
|
5545
|
+
}
|
|
5333
5546
|
this.log({
|
|
5334
5547
|
level: "info",
|
|
5335
5548
|
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)`,
|
|
@@ -5406,6 +5619,63 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5406
5619
|
}
|
|
5407
5620
|
await this.forceReadoptRun(sessionId, row);
|
|
5408
5621
|
}
|
|
5622
|
+
/**
|
|
5623
|
+
* Deliver a `processing` row whose correlated reply already completed while
|
|
5624
|
+
* nobody was watching (ADR-0046) — the `readoptOne` `state === 'done'` body,
|
|
5625
|
+
* extracted (#1493 Task 2.4) so the ambiguous-finish guard above can call the
|
|
5626
|
+
* SAME delivery instead of duplicating it.
|
|
5627
|
+
*
|
|
5628
|
+
* EVEN IF the row was previously parked in `dontRedispatch` (a give-up stops
|
|
5629
|
+
* re-dispatch, not delivery — Bugbot #202). Guarded EXACTLY like the watcher's
|
|
5630
|
+
* `settleMessageDone`: auth re-throws; terminal → park in `doneUndeliverable` +
|
|
5631
|
+
* leave for cron; transient → log + leave for the next drain (the still-
|
|
5632
|
+
* `processing` row is re-read and retried). markDone is idempotent server-side
|
|
5633
|
+
* (status-gated), so a repeat can never double-post.
|
|
5634
|
+
*/
|
|
5635
|
+
async deliverReadoptedDone(sessionId, row, messages, ocId) {
|
|
5636
|
+
if (this.doneUndeliverable.has(row.id)) {
|
|
5637
|
+
this.log({
|
|
5638
|
+
level: "debug",
|
|
5639
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
5640
|
+
conversation_id: row.conversation_id,
|
|
5641
|
+
message_id: row.id
|
|
5642
|
+
});
|
|
5643
|
+
return;
|
|
5644
|
+
}
|
|
5645
|
+
this.log({
|
|
5646
|
+
level: "info",
|
|
5647
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
|
|
5648
|
+
conversation_id: row.conversation_id,
|
|
5649
|
+
message_id: row.id
|
|
5650
|
+
});
|
|
5651
|
+
try {
|
|
5652
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
5653
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
5654
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
5655
|
+
} catch (err) {
|
|
5656
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
5657
|
+
if (err instanceof ChannelTerminalError) {
|
|
5658
|
+
this.doneUndeliverable.add(row.id);
|
|
5659
|
+
this.log({
|
|
5660
|
+
level: "warn",
|
|
5661
|
+
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}`,
|
|
5662
|
+
conversation_id: row.conversation_id,
|
|
5663
|
+
message_id: row.id
|
|
5664
|
+
});
|
|
5665
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
5666
|
+
return;
|
|
5667
|
+
}
|
|
5668
|
+
this.log({
|
|
5669
|
+
level: "warn",
|
|
5670
|
+
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
5671
|
+
conversation_id: row.conversation_id,
|
|
5672
|
+
message_id: row.id
|
|
5673
|
+
});
|
|
5674
|
+
return;
|
|
5675
|
+
}
|
|
5676
|
+
this.dontRedispatch.delete(row.id);
|
|
5677
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
5678
|
+
}
|
|
5409
5679
|
/**
|
|
5410
5680
|
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
5411
5681
|
*
|
|
@@ -6035,17 +6305,25 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
6035
6305
|
* the aborted-in-flight production bug after a restart.
|
|
6036
6306
|
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
6037
6307
|
* (the sub-agent preamble — #253's shape).
|
|
6038
|
-
* - `
|
|
6039
|
-
*
|
|
6040
|
-
*
|
|
6041
|
-
* `
|
|
6308
|
+
* - `ambiguous` — a COMPLETED, non-errored reply whose `finish` is neither
|
|
6309
|
+
* "tool-calls" nor "stop" (issue #1493, class 4 — see
|
|
6310
|
+
* `isAmbiguousFinishPinnedRunning`/Task 2.4).
|
|
6311
|
+
* - `other` — any other shape (defensive; a running row is normally b1, b2 or
|
|
6312
|
+
* ambiguous).
|
|
6313
|
+
* Reads `info.time.completed` / `info.finish` / `info.error` (tolerating the
|
|
6314
|
+
* legacy top-level shape) directly rather than re-importing the module-private
|
|
6315
|
+
* `completedOf`/`finishOf`/`errorOf` — this is a display label only, not a
|
|
6316
|
+
* correctness predicate (that is `isAmbiguousFinishPinnedRunning`'s job).
|
|
6042
6317
|
*/
|
|
6043
6318
|
replyCompletionShape(reply) {
|
|
6044
6319
|
if (!reply) return "other";
|
|
6045
6320
|
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
6046
6321
|
if (completed == null) return "b1";
|
|
6047
6322
|
const finish = reply.info?.finish ?? reply.finish;
|
|
6048
|
-
|
|
6323
|
+
if (finish === "tool-calls") return "b2";
|
|
6324
|
+
const error2 = reply.info?.error ?? reply.error;
|
|
6325
|
+
if (finish !== "stop" && error2 == null) return "ambiguous";
|
|
6326
|
+
return "other";
|
|
6049
6327
|
}
|
|
6050
6328
|
/**
|
|
6051
6329
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
@@ -6662,7 +6940,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
6662
6940
|
if (trimmed === "") {
|
|
6663
6941
|
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
6664
6942
|
}
|
|
6665
|
-
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ?
|
|
6943
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join4(homeDir, trimmed.slice(2)) : trimmed;
|
|
6666
6944
|
if (!isAbsolute2(expanded)) {
|
|
6667
6945
|
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
6668
6946
|
}
|
|
@@ -6963,6 +7241,10 @@ async function driveChannels(state, driver) {
|
|
|
6963
7241
|
}
|
|
6964
7242
|
}
|
|
6965
7243
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
7244
|
+
var SESSION_DB_RECLAIM_MAX_PAGES = 2e3;
|
|
7245
|
+
function sessionDbPath() {
|
|
7246
|
+
return join4(homedir3(), ".local", "share", "opencode", "opencode.db");
|
|
7247
|
+
}
|
|
6966
7248
|
async function runSweep(state, driver, config) {
|
|
6967
7249
|
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
6968
7250
|
try {
|
|
@@ -7005,6 +7287,25 @@ async function runSweep(state, driver, config) {
|
|
|
7005
7287
|
type: "info",
|
|
7006
7288
|
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
7007
7289
|
});
|
|
7290
|
+
const reclaimResult = await reclaimSessionDbSpace({
|
|
7291
|
+
dbPath: sessionDbPath(),
|
|
7292
|
+
maxPages: SESSION_DB_RECLAIM_MAX_PAGES,
|
|
7293
|
+
allowFullVacuum: protectedNow.size === 0
|
|
7294
|
+
});
|
|
7295
|
+
if (reclaimResult.ok) {
|
|
7296
|
+
const beforeMib = (reclaimResult.beforeBytes / 1024 / 1024).toFixed(1);
|
|
7297
|
+
const afterMib = (reclaimResult.afterBytes / 1024 / 1024).toFixed(1);
|
|
7298
|
+
const checkpointNote = reclaimResult.checkpoint.busy ? ` (on-disk file truncation deferred: checkpoint busy, ${reclaimResult.checkpoint.log} WAL frames pending)` : "";
|
|
7299
|
+
logActivity(state, {
|
|
7300
|
+
type: "info",
|
|
7301
|
+
message: `Session cleanup: reclaimed session-db space (${reclaimResult.mode}): ${beforeMib} MiB -> ${afterMib} MiB${checkpointNote}`
|
|
7302
|
+
});
|
|
7303
|
+
} else {
|
|
7304
|
+
logActivity(state, {
|
|
7305
|
+
type: "info",
|
|
7306
|
+
message: `Session cleanup: session-db space reclaim skipped (${reclaimResult.skipped})`
|
|
7307
|
+
});
|
|
7308
|
+
}
|
|
7008
7309
|
} catch (error2) {
|
|
7009
7310
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
7010
7311
|
logActivity(state, {
|
|
@@ -7025,6 +7326,22 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
7025
7326
|
for (const warning2 of config.warnings) {
|
|
7026
7327
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
7027
7328
|
}
|
|
7329
|
+
const dbBytes = statSessionDbBytes(homedir3());
|
|
7330
|
+
void (async () => {
|
|
7331
|
+
const reclaimSkipReason = dbBytes !== null && config.enabled ? await probeReclaimAvailability({ dbPath: sessionDbPath(), requiredBytes: dbBytes }) : null;
|
|
7332
|
+
const sizeWarning = buildSessionStoreSizeWarning({
|
|
7333
|
+
dbBytes,
|
|
7334
|
+
cleanupEnabled: config.enabled,
|
|
7335
|
+
reclaimSkipReason
|
|
7336
|
+
});
|
|
7337
|
+
if (sizeWarning !== null) {
|
|
7338
|
+
logActivity(state, { type: "info", level: "warn", message: sizeWarning });
|
|
7339
|
+
}
|
|
7340
|
+
})().catch((err) => {
|
|
7341
|
+
console.error(
|
|
7342
|
+
`[scheduleSessionCleanup] size-warning preflight failed: ${err instanceof Error ? err.message : String(err)}`
|
|
7343
|
+
);
|
|
7344
|
+
});
|
|
7028
7345
|
if (!config.enabled) return;
|
|
7029
7346
|
logActivity(state, {
|
|
7030
7347
|
type: "info",
|
|
@@ -7606,6 +7923,13 @@ async function run(options) {
|
|
|
7606
7923
|
logActivity(state, { type: "error", error: error2 });
|
|
7607
7924
|
if (state.interactive) displayStatus(state);
|
|
7608
7925
|
},
|
|
7926
|
+
// `warn`, not `info`: `forwardRunnerActivity`'s FORWARDED_LEVELS floor is
|
|
7927
|
+
// {'warn','error'}, so an `info` entry would never leave the machine and
|
|
7928
|
+
// an operator couldn't correlate a reconnect storm with a relay deploy.
|
|
7929
|
+
onWarning: (message) => {
|
|
7930
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
7931
|
+
if (state.interactive) displayStatus(state);
|
|
7932
|
+
},
|
|
7609
7933
|
// Web traffic is proxied transparently; note opencode is live and stamp
|
|
7610
7934
|
// proxied activity so the idle loop treats interactive proxy use as work.
|
|
7611
7935
|
// Fires per forwarded response head (incl. every SSE open) and excludes
|