@evident-ai/cli 3.2.1-dev.da70cd4 → 3.2.1-dev.fa69368
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 +5 -3
- package/dist/index.js +316 -65
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,8 +113,8 @@ Options:
|
|
|
113
113
|
and ignored, which can leave cleanup off if it was the only rule set. Env:
|
|
114
114
|
`EVIDENT_SESSION_CLEANUP_MAX_AGE`. With cleanup off, a runner whose local
|
|
115
115
|
session store has grown large warns once at startup, on the runner's page,
|
|
116
|
-
naming this flag —
|
|
117
|
-
|
|
116
|
+
naming this flag — turning cleanup on both stops the store growing further
|
|
117
|
+
and reclaims disk space already used by deleted sessions.
|
|
118
118
|
- `--session-cleanup-max-count <n>` — Keep only the newest N OpenCode sessions
|
|
119
119
|
by last activity, deleting the rest. Also enables cleanup; combines with
|
|
120
120
|
`--session-cleanup-max-age` as OR. A session with a turn actively in progress
|
|
@@ -135,7 +135,9 @@ Options:
|
|
|
135
135
|
`EVIDENT_MAX_ACTIVE_SESSIONS`.
|
|
136
136
|
- `--session-cleanup-interval <duration>` — How often the cleanup sweep runs
|
|
137
137
|
(default: `1h`). An invalid value falls back to the default rather than
|
|
138
|
-
disabling cleanup.
|
|
138
|
+
disabling cleanup. Each sweep also reclaims disk space freed by the sessions
|
|
139
|
+
it deleted, so the store shrinks rather than merely stopping its growth. Env:
|
|
140
|
+
`EVIDENT_SESSION_CLEANUP_INTERVAL`.
|
|
139
141
|
- `--enable-file-sync-to <dir>` — Let the runner write files Evident has queued
|
|
140
142
|
for it into this directory — it collects them as part of the polling it
|
|
141
143
|
already does, so they land a couple of seconds after you hand them over.
|
package/dist/index.js
CHANGED
|
@@ -2106,7 +2106,9 @@ function messageRunState(messages, userMessageId) {
|
|
|
2106
2106
|
}
|
|
2107
2107
|
if (!reply) return "queued";
|
|
2108
2108
|
if (isAssistantInFlight(reply)) return "running";
|
|
2109
|
-
|
|
2109
|
+
if (errorOf(reply) != null) return "failed";
|
|
2110
|
+
if (isAmbiguousTerminalFinish(reply)) return "running";
|
|
2111
|
+
return "done";
|
|
2110
2112
|
}
|
|
2111
2113
|
function isPreamblePinnedRunning(messages, userMessageId) {
|
|
2112
2114
|
if (messageRunState(messages, userMessageId) !== "running") return false;
|
|
@@ -2116,6 +2118,19 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
2116
2118
|
function isB2AbandonmentConfirmed(params) {
|
|
2117
2119
|
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
2118
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
|
+
}
|
|
2119
2134
|
function messageError(messages, userMessageId) {
|
|
2120
2135
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2121
2136
|
const error2 = errorOf(reply);
|
|
@@ -2341,10 +2356,128 @@ function statSessionDbBytes(homeDir) {
|
|
|
2341
2356
|
}
|
|
2342
2357
|
}
|
|
2343
2358
|
function buildSessionStoreSizeWarning(input) {
|
|
2344
|
-
const { dbBytes, cleanupEnabled } = input;
|
|
2345
|
-
if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES
|
|
2359
|
+
const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
|
|
2360
|
+
if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
|
|
2346
2361
|
const mib = Math.round(dbBytes / 1024 / 1024);
|
|
2347
|
-
|
|
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
|
+
}
|
|
2348
2481
|
}
|
|
2349
2482
|
|
|
2350
2483
|
// src/lib/tunnel/connection.ts
|
|
@@ -2782,7 +2915,7 @@ import { homedir as homedir2 } from "os";
|
|
|
2782
2915
|
// src/lib/file-push.ts
|
|
2783
2916
|
import { randomUUID } from "crypto";
|
|
2784
2917
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2785
|
-
import { basename, dirname as
|
|
2918
|
+
import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
|
|
2786
2919
|
var FILE_MODE = 384;
|
|
2787
2920
|
var DIRECTORY_MODE = 448;
|
|
2788
2921
|
async function writePushedFile(request) {
|
|
@@ -2813,7 +2946,7 @@ async function writePushedFile(request) {
|
|
|
2813
2946
|
}
|
|
2814
2947
|
try {
|
|
2815
2948
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2816
|
-
|
|
2949
|
+
dirname3(candidate)
|
|
2817
2950
|
);
|
|
2818
2951
|
const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
|
|
2819
2952
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
@@ -2825,8 +2958,8 @@ async function writePushedFile(request) {
|
|
|
2825
2958
|
}
|
|
2826
2959
|
if (missingSegments.length > 0) {
|
|
2827
2960
|
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2828
|
-
const realParent = await realpath(
|
|
2829
|
-
if (realParent !==
|
|
2961
|
+
const realParent = await realpath(dirname3(realTarget));
|
|
2962
|
+
if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2830
2963
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2831
2964
|
path: realTarget,
|
|
2832
2965
|
bytes,
|
|
@@ -2869,7 +3002,7 @@ async function resolveNearestExistingAncestor(directory) {
|
|
|
2869
3002
|
try {
|
|
2870
3003
|
return { existingAncestor: await realpath(current), missingSegments };
|
|
2871
3004
|
} catch (err) {
|
|
2872
|
-
const parent =
|
|
3005
|
+
const parent = dirname3(current);
|
|
2873
3006
|
if (err.code !== "ENOENT" || parent === current) {
|
|
2874
3007
|
throw err;
|
|
2875
3008
|
}
|
|
@@ -2930,7 +3063,7 @@ async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
|
2930
3063
|
}
|
|
2931
3064
|
}
|
|
2932
3065
|
async function writeAtomically(realTarget, content) {
|
|
2933
|
-
const temporaryPath = join3(
|
|
3066
|
+
const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2934
3067
|
let handle;
|
|
2935
3068
|
try {
|
|
2936
3069
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -3203,6 +3336,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
|
3203
3336
|
var HEARTBEAT_MS = 6e4;
|
|
3204
3337
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
3205
3338
|
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
3339
|
+
var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
|
|
3206
3340
|
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
3207
3341
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
3208
3342
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
@@ -4037,6 +4171,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4037
4171
|
return this.reattachRedrive(conv, sessionId, message, ocId);
|
|
4038
4172
|
}
|
|
4039
4173
|
if (ongoing === false) {
|
|
4174
|
+
if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
|
|
4175
|
+
return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
|
|
4176
|
+
}
|
|
4040
4177
|
this.clearRedriveUnresolved(message.id);
|
|
4041
4178
|
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
4042
4179
|
return "dispatch";
|
|
@@ -4648,7 +4785,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4648
4785
|
deliveryDeadlineAnchored: false,
|
|
4649
4786
|
b2PinnedSinceMs: 0,
|
|
4650
4787
|
b2LastDescendantCheckMs: 0,
|
|
4651
|
-
b2AbandonedSignalled: false
|
|
4788
|
+
b2AbandonedSignalled: false,
|
|
4789
|
+
ambiguousPinnedSinceMs: 0,
|
|
4790
|
+
ambiguousResolved: false
|
|
4652
4791
|
});
|
|
4653
4792
|
}
|
|
4654
4793
|
/**
|
|
@@ -4727,7 +4866,9 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4727
4866
|
deliveryDeadlineAnchored: false,
|
|
4728
4867
|
b2PinnedSinceMs: 0,
|
|
4729
4868
|
b2LastDescendantCheckMs: 0,
|
|
4730
|
-
b2AbandonedSignalled: false
|
|
4869
|
+
b2AbandonedSignalled: false,
|
|
4870
|
+
ambiguousPinnedSinceMs: 0,
|
|
4871
|
+
ambiguousResolved: false
|
|
4731
4872
|
});
|
|
4732
4873
|
}
|
|
4733
4874
|
/**
|
|
@@ -4995,6 +5136,49 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4995
5136
|
}
|
|
4996
5137
|
}
|
|
4997
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
|
+
}
|
|
4998
5182
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
4999
5183
|
this.log({
|
|
5000
5184
|
level: "warn",
|
|
@@ -5249,48 +5433,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5249
5433
|
const ocId = row.opencode_message_id;
|
|
5250
5434
|
const state = messageRunState(messages, ocId ?? "");
|
|
5251
5435
|
if (state === "done") {
|
|
5252
|
-
|
|
5253
|
-
this.log({
|
|
5254
|
-
level: "debug",
|
|
5255
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
5256
|
-
conversation_id: row.conversation_id,
|
|
5257
|
-
message_id: row.id
|
|
5258
|
-
});
|
|
5259
|
-
return;
|
|
5260
|
-
}
|
|
5261
|
-
this.log({
|
|
5262
|
-
level: "info",
|
|
5263
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
|
|
5264
|
-
conversation_id: row.conversation_id,
|
|
5265
|
-
message_id: row.id
|
|
5266
|
-
});
|
|
5267
|
-
try {
|
|
5268
|
-
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
5269
|
-
const usage = messageUsage(messages, ocId ?? "");
|
|
5270
|
-
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
5271
|
-
} catch (err) {
|
|
5272
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
5273
|
-
if (err instanceof ChannelTerminalError) {
|
|
5274
|
-
this.doneUndeliverable.add(row.id);
|
|
5275
|
-
this.log({
|
|
5276
|
-
level: "warn",
|
|
5277
|
-
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}`,
|
|
5278
|
-
conversation_id: row.conversation_id,
|
|
5279
|
-
message_id: row.id
|
|
5280
|
-
});
|
|
5281
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
|
|
5282
|
-
return;
|
|
5283
|
-
}
|
|
5284
|
-
this.log({
|
|
5285
|
-
level: "warn",
|
|
5286
|
-
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
5287
|
-
conversation_id: row.conversation_id,
|
|
5288
|
-
message_id: row.id
|
|
5289
|
-
});
|
|
5290
|
-
return;
|
|
5291
|
-
}
|
|
5292
|
-
this.dontRedispatch.delete(row.id);
|
|
5293
|
-
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
5436
|
+
await this.deliverReadoptedDone(sessionId, row, messages, ocId);
|
|
5294
5437
|
return;
|
|
5295
5438
|
}
|
|
5296
5439
|
const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
|
|
@@ -5355,6 +5498,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5355
5498
|
const ongoing = sessionOngoing;
|
|
5356
5499
|
statusReadableOngoing = ongoing;
|
|
5357
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
|
+
}
|
|
5358
5512
|
this.log({
|
|
5359
5513
|
level: "info",
|
|
5360
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)`,
|
|
@@ -5431,6 +5585,63 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5431
5585
|
}
|
|
5432
5586
|
await this.forceReadoptRun(sessionId, row);
|
|
5433
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
|
+
}
|
|
5434
5645
|
/**
|
|
5435
5646
|
* Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
|
|
5436
5647
|
*
|
|
@@ -6060,17 +6271,25 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
6060
6271
|
* the aborted-in-flight production bug after a restart.
|
|
6061
6272
|
* - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
|
|
6062
6273
|
* (the sub-agent preamble — #253's shape).
|
|
6063
|
-
* - `
|
|
6064
|
-
*
|
|
6065
|
-
*
|
|
6066
|
-
* `
|
|
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).
|
|
6067
6283
|
*/
|
|
6068
6284
|
replyCompletionShape(reply) {
|
|
6069
6285
|
if (!reply) return "other";
|
|
6070
6286
|
const completed = reply.info?.time?.completed ?? reply.time?.completed;
|
|
6071
6287
|
if (completed == null) return "b1";
|
|
6072
6288
|
const finish = reply.info?.finish ?? reply.finish;
|
|
6073
|
-
|
|
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";
|
|
6074
6293
|
}
|
|
6075
6294
|
/**
|
|
6076
6295
|
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
@@ -6988,6 +7207,10 @@ async function driveChannels(state, driver) {
|
|
|
6988
7207
|
}
|
|
6989
7208
|
}
|
|
6990
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
|
+
}
|
|
6991
7214
|
async function runSweep(state, driver, config) {
|
|
6992
7215
|
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
6993
7216
|
try {
|
|
@@ -7030,6 +7253,25 @@ async function runSweep(state, driver, config) {
|
|
|
7030
7253
|
type: "info",
|
|
7031
7254
|
message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
|
|
7032
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
|
+
}
|
|
7033
7275
|
} catch (error2) {
|
|
7034
7276
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
7035
7277
|
logActivity(state, {
|
|
@@ -7050,13 +7292,22 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
7050
7292
|
for (const warning2 of config.warnings) {
|
|
7051
7293
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
7052
7294
|
}
|
|
7053
|
-
const
|
|
7054
|
-
|
|
7055
|
-
|
|
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
|
+
);
|
|
7056
7310
|
});
|
|
7057
|
-
if (sizeWarning !== null) {
|
|
7058
|
-
logActivity(state, { type: "info", level: "warn", message: sizeWarning });
|
|
7059
|
-
}
|
|
7060
7311
|
if (!config.enabled) return;
|
|
7061
7312
|
logActivity(state, {
|
|
7062
7313
|
type: "info",
|