@evident-ai/cli 3.2.1-dev.cf740cc → 3.2.1-dev.d96ef8a

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 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 — pruning stops the store growing, but it does not shrink
117
- what has already grown.
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. Env: `EVIDENT_SESSION_CLEANUP_INTERVAL`.
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
@@ -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
- return errorOf(reply) != null ? "failed" : "done";
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);
@@ -2341,10 +2357,128 @@ function statSessionDbBytes(homeDir) {
2341
2357
  }
2342
2358
  }
2343
2359
  function buildSessionStoreSizeWarning(input) {
2344
- const { dbBytes, cleanupEnabled } = input;
2345
- if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES || cleanupEnabled) return null;
2360
+ const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
2361
+ if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
2346
2362
  const mib = Math.round(dbBytes / 1024 / 1024);
2347
- 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.`;
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
+ }
2348
2482
  }
2349
2483
 
2350
2484
  // src/lib/tunnel/connection.ts
@@ -2512,6 +2646,17 @@ var StreamForwarder = class {
2512
2646
  };
2513
2647
 
2514
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
+ }
2515
2660
  var MAX_RECONNECT_DELAY = 3e4;
2516
2661
  var BASE_RECONNECT_DELAY = 500;
2517
2662
  function getReconnectDelay(attempt) {
@@ -2556,6 +2701,7 @@ function connectTunnel(options) {
2556
2701
  onError,
2557
2702
  onResponse,
2558
2703
  onInfo,
2704
+ onWarning,
2559
2705
  onDrainPing
2560
2706
  } = options;
2561
2707
  const tunnelUrl = getTunnelUrlConfig();
@@ -2575,8 +2721,11 @@ function connectTunnel(options) {
2575
2721
  reject(new Error("Connection timeout"));
2576
2722
  }, 3e4);
2577
2723
  let upgradeRejection = null;
2724
+ let upgradeRejectionReason = null;
2578
2725
  ws.on("unexpected-response", (_req, res) => {
2579
2726
  clearTimeout(connectionTimeout);
2727
+ const reason = classifyUpgradeRejection(res.headers);
2728
+ upgradeRejectionReason = reason;
2580
2729
  const chunks = [];
2581
2730
  res.on("data", (chunk) => chunks.push(chunk));
2582
2731
  res.on("end", () => {
@@ -2590,8 +2739,14 @@ function connectTunnel(options) {
2590
2739
  }
2591
2740
  const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
2592
2741
  upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
2593
- onError?.(`Tunnel refused by relay (${upgradeRejection})`);
2594
- reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
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
+ );
2595
2750
  });
2596
2751
  });
2597
2752
  ws.on("open", () => {
@@ -2637,8 +2792,14 @@ function connectTunnel(options) {
2637
2792
  ws.on("error", (error2) => {
2638
2793
  clearTimeout(connectionTimeout);
2639
2794
  const detail = upgradeRejection ?? describeSocketError(error2, url);
2640
- onError?.(`Connection error: ${detail}`);
2641
- reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
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
+ );
2642
2803
  });
2643
2804
  ws.on("close", (code, reason) => {
2644
2805
  const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
@@ -2714,7 +2875,8 @@ var RunnerConnection = class {
2714
2875
  onError: (error2) => events.onError?.(error2),
2715
2876
  onResponse: () => events.onResponse?.(),
2716
2877
  onDrainPing: () => events.onDrainPing?.(),
2717
- onInfo: (message) => events.onInfo?.(message)
2878
+ onInfo: (message) => events.onInfo?.(message),
2879
+ onWarning: (message) => events.onWarning?.(message)
2718
2880
  });
2719
2881
  return;
2720
2882
  } catch (error2) {
@@ -2725,7 +2887,12 @@ var RunnerConnection = class {
2725
2887
  }
2726
2888
  const delay = getReconnectDelay(this.reconnectAttempt);
2727
2889
  events.onReconnecting?.(this.reconnectAttempt);
2728
- events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
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
+ }
2729
2896
  await this.sleep(delay);
2730
2897
  }
2731
2898
  }
@@ -2782,7 +2949,7 @@ import { homedir as homedir2 } from "os";
2782
2949
  // src/lib/file-push.ts
2783
2950
  import { randomUUID } from "crypto";
2784
2951
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2785
- import { basename, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2952
+ import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2786
2953
  var FILE_MODE = 384;
2787
2954
  var DIRECTORY_MODE = 448;
2788
2955
  async function writePushedFile(request) {
@@ -2813,7 +2980,7 @@ async function writePushedFile(request) {
2813
2980
  }
2814
2981
  try {
2815
2982
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2816
- dirname2(candidate)
2983
+ dirname3(candidate)
2817
2984
  );
2818
2985
  const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
2819
2986
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
@@ -2825,8 +2992,8 @@ async function writePushedFile(request) {
2825
2992
  }
2826
2993
  if (missingSegments.length > 0) {
2827
2994
  await createMissingDirectories(existingAncestor, missingSegments);
2828
- const realParent = await realpath(dirname2(realTarget));
2829
- if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
2995
+ const realParent = await realpath(dirname3(realTarget));
2996
+ if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
2830
2997
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2831
2998
  path: realTarget,
2832
2999
  bytes,
@@ -2869,7 +3036,7 @@ async function resolveNearestExistingAncestor(directory) {
2869
3036
  try {
2870
3037
  return { existingAncestor: await realpath(current), missingSegments };
2871
3038
  } catch (err) {
2872
- const parent = dirname2(current);
3039
+ const parent = dirname3(current);
2873
3040
  if (err.code !== "ENOENT" || parent === current) {
2874
3041
  throw err;
2875
3042
  }
@@ -2930,7 +3097,7 @@ async function createMissingDirectories(existingAncestor, missingSegments) {
2930
3097
  }
2931
3098
  }
2932
3099
  async function writeAtomically(realTarget, content) {
2933
- const temporaryPath = join3(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
3100
+ const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
2934
3101
  let handle;
2935
3102
  try {
2936
3103
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -3203,6 +3370,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
3203
3370
  var HEARTBEAT_MS = 6e4;
3204
3371
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
3205
3372
  var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
3373
+ var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
3206
3374
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
3207
3375
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
3208
3376
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
@@ -4037,6 +4205,9 @@ var ChannelDriver = class _ChannelDriver {
4037
4205
  return this.reattachRedrive(conv, sessionId, message, ocId);
4038
4206
  }
4039
4207
  if (ongoing === false) {
4208
+ if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
4209
+ return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
4210
+ }
4040
4211
  this.clearRedriveUnresolved(message.id);
4041
4212
  void this.postSignal(conv.id, message.id, "redrive_redispatched");
4042
4213
  return "dispatch";
@@ -4648,7 +4819,9 @@ var ChannelDriver = class _ChannelDriver {
4648
4819
  deliveryDeadlineAnchored: false,
4649
4820
  b2PinnedSinceMs: 0,
4650
4821
  b2LastDescendantCheckMs: 0,
4651
- b2AbandonedSignalled: false
4822
+ b2AbandonedSignalled: false,
4823
+ ambiguousPinnedSinceMs: 0,
4824
+ ambiguousResolved: false
4652
4825
  });
4653
4826
  }
4654
4827
  /**
@@ -4727,7 +4900,9 @@ var ChannelDriver = class _ChannelDriver {
4727
4900
  deliveryDeadlineAnchored: false,
4728
4901
  b2PinnedSinceMs: 0,
4729
4902
  b2LastDescendantCheckMs: 0,
4730
- b2AbandonedSignalled: false
4903
+ b2AbandonedSignalled: false,
4904
+ ambiguousPinnedSinceMs: 0,
4905
+ ambiguousResolved: false
4731
4906
  });
4732
4907
  }
4733
4908
  /**
@@ -4995,6 +5170,49 @@ var ChannelDriver = class _ChannelDriver {
4995
5170
  }
4996
5171
  }
4997
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
+ }
4998
5216
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
4999
5217
  this.log({
5000
5218
  level: "warn",
@@ -5249,48 +5467,7 @@ var ChannelDriver = class _ChannelDriver {
5249
5467
  const ocId = row.opencode_message_id;
5250
5468
  const state = messageRunState(messages, ocId ?? "");
5251
5469
  if (state === "done") {
5252
- if (this.doneUndeliverable.has(row.id)) {
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");
5470
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5294
5471
  return;
5295
5472
  }
5296
5473
  const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
@@ -5355,6 +5532,17 @@ var ChannelDriver = class _ChannelDriver {
5355
5532
  const ongoing = sessionOngoing;
5356
5533
  statusReadableOngoing = ongoing;
5357
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
+ }
5358
5546
  this.log({
5359
5547
  level: "info",
5360
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)`,
@@ -5431,6 +5619,63 @@ var ChannelDriver = class _ChannelDriver {
5431
5619
  }
5432
5620
  await this.forceReadoptRun(sessionId, row);
5433
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
+ }
5434
5679
  /**
5435
5680
  * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
5436
5681
  *
@@ -6060,17 +6305,25 @@ var ChannelDriver = class _ChannelDriver {
6060
6305
  * the aborted-in-flight production bug after a restart.
6061
6306
  * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
6062
6307
  * (the sub-agent preamble — #253's shape).
6063
- * - `other` — any other shape (defensive; a running row is normally b1 or b2).
6064
- * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
6065
- * shape) directly rather than re-importing the module-private `completedOf`/
6066
- * `finishOf` — this is a display label only, not a correctness predicate.
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).
6067
6317
  */
6068
6318
  replyCompletionShape(reply) {
6069
6319
  if (!reply) return "other";
6070
6320
  const completed = reply.info?.time?.completed ?? reply.time?.completed;
6071
6321
  if (completed == null) return "b1";
6072
6322
  const finish = reply.info?.finish ?? reply.finish;
6073
- return finish === "tool-calls" ? "b2" : "other";
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";
6074
6327
  }
6075
6328
  /**
6076
6329
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -6988,6 +7241,10 @@ async function driveChannels(state, driver) {
6988
7241
  }
6989
7242
  }
6990
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
+ }
6991
7248
  async function runSweep(state, driver, config) {
6992
7249
  const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
6993
7250
  try {
@@ -7030,6 +7287,25 @@ async function runSweep(state, driver, config) {
7030
7287
  type: "info",
7031
7288
  message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
7032
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
+ }
7033
7309
  } catch (error2) {
7034
7310
  const message = error2 instanceof Error ? error2.message : String(error2);
7035
7311
  logActivity(state, {
@@ -7050,13 +7326,22 @@ function scheduleSessionCleanup(state, driver, options) {
7050
7326
  for (const warning2 of config.warnings) {
7051
7327
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
7052
7328
  }
7053
- const sizeWarning = buildSessionStoreSizeWarning({
7054
- dbBytes: statSessionDbBytes(homedir3()),
7055
- cleanupEnabled: config.enabled
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
+ );
7056
7344
  });
7057
- if (sizeWarning !== null) {
7058
- logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7059
- }
7060
7345
  if (!config.enabled) return;
7061
7346
  logActivity(state, {
7062
7347
  type: "info",
@@ -7638,6 +7923,13 @@ async function run(options) {
7638
7923
  logActivity(state, { type: "error", error: error2 });
7639
7924
  if (state.interactive) displayStatus(state);
7640
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
+ },
7641
7933
  // Web traffic is proxied transparently; note opencode is live and stamp
7642
7934
  // proxied activity so the idle loop treats interactive proxy use as work.
7643
7935
  // Fires per forwarded response head (incl. every SSE open) and excludes