@evident-ai/cli 3.2.1-dev.6d96a16 → 3.2.1-dev.7244588

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
@@ -111,7 +111,10 @@ Options:
111
111
  `7d`, `24h`). Setting this (or `--session-cleanup-max-count`) is what enables
112
112
  cleanup — there is no separate on/off flag. An invalid value is warned about
113
113
  and ignored, which can leave cleanup off if it was the only rule set. Env:
114
- `EVIDENT_SESSION_CLEANUP_MAX_AGE`.
114
+ `EVIDENT_SESSION_CLEANUP_MAX_AGE`. With cleanup off, a runner whose local
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.
115
118
  - `--session-cleanup-max-count <n>` — Keep only the newest N OpenCode sessions
116
119
  by last activity, deleting the rest. Also enables cleanup; combines with
117
120
  `--session-cleanup-max-age` as OR. A session with a turn actively in progress
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 join3, parse, resolve as resolvePath } from "path";
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
@@ -2106,7 +2106,9 @@ function messageRunState(messages, userMessageId) {
2106
2106
  }
2107
2107
  if (!reply) return "queued";
2108
2108
  if (isAssistantInFlight(reply)) return "running";
2109
- return errorOf(reply) != null ? "failed" : "done";
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);
@@ -2322,6 +2337,31 @@ function resolveSessionCleanupConfig(flags, env = process.env) {
2322
2337
  return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
2323
2338
  }
2324
2339
 
2340
+ // src/lib/opencode/session-db-size.ts
2341
+ import { statSync as statSync2 } from "fs";
2342
+ import { join as join2 } from "path";
2343
+ var LARGE_DB_THRESHOLD_BYTES = 268435456;
2344
+ function statSessionDbBytes(homeDir) {
2345
+ const dbPath = join2(homeDir, ".local", "share", "opencode", "opencode.db");
2346
+ try {
2347
+ return statSync2(dbPath).size;
2348
+ } catch (err) {
2349
+ const isMissingFile = err instanceof Error && "code" in err && err.code === "ENOENT";
2350
+ if (!isMissingFile) {
2351
+ console.error(
2352
+ `[statSessionDbBytes] could not stat ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2353
+ );
2354
+ }
2355
+ return null;
2356
+ }
2357
+ }
2358
+ function buildSessionStoreSizeWarning(input) {
2359
+ const { dbBytes, cleanupEnabled } = input;
2360
+ if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES || cleanupEnabled) return null;
2361
+ const mib = Math.round(dbBytes / 1024 / 1024);
2362
+ 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
+ }
2364
+
2325
2365
  // src/lib/tunnel/connection.ts
2326
2366
  import WebSocket2 from "ws";
2327
2367
 
@@ -2757,7 +2797,7 @@ import { homedir as homedir2 } from "os";
2757
2797
  // src/lib/file-push.ts
2758
2798
  import { randomUUID } from "crypto";
2759
2799
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2760
- import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
2800
+ import { basename, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2761
2801
  var FILE_MODE = 384;
2762
2802
  var DIRECTORY_MODE = 448;
2763
2803
  async function writePushedFile(request) {
@@ -2790,7 +2830,7 @@ async function writePushedFile(request) {
2790
2830
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2791
2831
  dirname2(candidate)
2792
2832
  );
2793
- const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
2833
+ const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
2794
2834
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
2795
2835
  if (allowedDirectory === null) {
2796
2836
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
@@ -2826,7 +2866,7 @@ function expandAndValidate(requestedPath, homeDir) {
2826
2866
  if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
2827
2867
  return null;
2828
2868
  }
2829
- const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
2869
+ const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join3(homeDir, requestedPath.slice(2)) : requestedPath;
2830
2870
  if (expanded.split(/[/\\]/).includes("..")) {
2831
2871
  return null;
2832
2872
  }
@@ -2899,13 +2939,13 @@ function contains(realDirectory, realTarget) {
2899
2939
  async function createMissingDirectories(existingAncestor, missingSegments) {
2900
2940
  let current = existingAncestor;
2901
2941
  for (const segment of missingSegments) {
2902
- current = join2(current, segment);
2942
+ current = join3(current, segment);
2903
2943
  await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
2904
2944
  await chmod(current, DIRECTORY_MODE);
2905
2945
  }
2906
2946
  }
2907
2947
  async function writeAtomically(realTarget, content) {
2908
- const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2948
+ const temporaryPath = join3(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2909
2949
  let handle;
2910
2950
  try {
2911
2951
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -3178,6 +3218,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
3178
3218
  var HEARTBEAT_MS = 6e4;
3179
3219
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
3180
3220
  var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
3221
+ var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
3181
3222
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
3182
3223
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
3183
3224
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
@@ -4012,6 +4053,9 @@ var ChannelDriver = class _ChannelDriver {
4012
4053
  return this.reattachRedrive(conv, sessionId, message, ocId);
4013
4054
  }
4014
4055
  if (ongoing === false) {
4056
+ if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
4057
+ return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
4058
+ }
4015
4059
  this.clearRedriveUnresolved(message.id);
4016
4060
  void this.postSignal(conv.id, message.id, "redrive_redispatched");
4017
4061
  return "dispatch";
@@ -4623,7 +4667,9 @@ var ChannelDriver = class _ChannelDriver {
4623
4667
  deliveryDeadlineAnchored: false,
4624
4668
  b2PinnedSinceMs: 0,
4625
4669
  b2LastDescendantCheckMs: 0,
4626
- b2AbandonedSignalled: false
4670
+ b2AbandonedSignalled: false,
4671
+ ambiguousPinnedSinceMs: 0,
4672
+ ambiguousResolved: false
4627
4673
  });
4628
4674
  }
4629
4675
  /**
@@ -4702,7 +4748,9 @@ var ChannelDriver = class _ChannelDriver {
4702
4748
  deliveryDeadlineAnchored: false,
4703
4749
  b2PinnedSinceMs: 0,
4704
4750
  b2LastDescendantCheckMs: 0,
4705
- b2AbandonedSignalled: false
4751
+ b2AbandonedSignalled: false,
4752
+ ambiguousPinnedSinceMs: 0,
4753
+ ambiguousResolved: false
4706
4754
  });
4707
4755
  }
4708
4756
  /**
@@ -4970,6 +5018,49 @@ var ChannelDriver = class _ChannelDriver {
4970
5018
  }
4971
5019
  }
4972
5020
  }
5021
+ const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
5022
+ if (!ambiguousPinnedNow) {
5023
+ if (snapshotReadable) {
5024
+ inFlight.ambiguousPinnedSinceMs = 0;
5025
+ inFlight.ambiguousResolved = false;
5026
+ }
5027
+ } else {
5028
+ if (inFlight.ambiguousResolved) {
5029
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5030
+ return;
5031
+ }
5032
+ if (inFlight.ambiguousPinnedSinceMs === 0) {
5033
+ inFlight.ambiguousPinnedSinceMs = this.now();
5034
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
5035
+ const finish = reply?.info?.finish ?? reply?.finish;
5036
+ this.log({
5037
+ level: "warn",
5038
+ 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)`,
5039
+ conversation_id: conv.id,
5040
+ message_id: id
5041
+ });
5042
+ }
5043
+ const pinnedForMs = this.now() - inFlight.ambiguousPinnedSinceMs;
5044
+ const ongoing = await isSessionOngoing(this.port, sessionId);
5045
+ if (isAmbiguousFinishResolved({
5046
+ pinnedForMs,
5047
+ maxPinnedMs: AMBIGUOUS_FINISH_MAX_PINNED_MS,
5048
+ sessionOngoing: ongoing
5049
+ })) {
5050
+ inFlight.ambiguousResolved = true;
5051
+ this.log({
5052
+ level: "warn",
5053
+ 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`,
5054
+ conversation_id: conv.id,
5055
+ message_id: id
5056
+ });
5057
+ void this.postSignal(conv.id, id, "ambiguous_finish_resolved", {
5058
+ watched_for_ms: pinnedForMs
5059
+ });
5060
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5061
+ return;
5062
+ }
5063
+ }
4973
5064
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
4974
5065
  this.log({
4975
5066
  level: "warn",
@@ -5224,48 +5315,7 @@ var ChannelDriver = class _ChannelDriver {
5224
5315
  const ocId = row.opencode_message_id;
5225
5316
  const state = messageRunState(messages, ocId ?? "");
5226
5317
  if (state === "done") {
5227
- if (this.doneUndeliverable.has(row.id)) {
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");
5318
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5269
5319
  return;
5270
5320
  }
5271
5321
  const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
@@ -5330,6 +5380,17 @@ var ChannelDriver = class _ChannelDriver {
5330
5380
  const ongoing = sessionOngoing;
5331
5381
  statusReadableOngoing = ongoing;
5332
5382
  if (ongoing === false) {
5383
+ if (isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
5384
+ const finish = reply?.info?.finish ?? reply?.finish;
5385
+ this.log({
5386
+ level: "info",
5387
+ 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`,
5388
+ conversation_id: row.conversation_id,
5389
+ message_id: row.id
5390
+ });
5391
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5392
+ return;
5393
+ }
5333
5394
  this.log({
5334
5395
  level: "info",
5335
5396
  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 +5467,63 @@ var ChannelDriver = class _ChannelDriver {
5406
5467
  }
5407
5468
  await this.forceReadoptRun(sessionId, row);
5408
5469
  }
5470
+ /**
5471
+ * Deliver a `processing` row whose correlated reply already completed while
5472
+ * nobody was watching (ADR-0046) — the `readoptOne` `state === 'done'` body,
5473
+ * extracted (#1493 Task 2.4) so the ambiguous-finish guard above can call the
5474
+ * SAME delivery instead of duplicating it.
5475
+ *
5476
+ * EVEN IF the row was previously parked in `dontRedispatch` (a give-up stops
5477
+ * re-dispatch, not delivery — Bugbot #202). Guarded EXACTLY like the watcher's
5478
+ * `settleMessageDone`: auth re-throws; terminal → park in `doneUndeliverable` +
5479
+ * leave for cron; transient → log + leave for the next drain (the still-
5480
+ * `processing` row is re-read and retried). markDone is idempotent server-side
5481
+ * (status-gated), so a repeat can never double-post.
5482
+ */
5483
+ async deliverReadoptedDone(sessionId, row, messages, ocId) {
5484
+ if (this.doneUndeliverable.has(row.id)) {
5485
+ this.log({
5486
+ level: "debug",
5487
+ message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
5488
+ conversation_id: row.conversation_id,
5489
+ message_id: row.id
5490
+ });
5491
+ return;
5492
+ }
5493
+ this.log({
5494
+ level: "info",
5495
+ message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
5496
+ conversation_id: row.conversation_id,
5497
+ message_id: row.id
5498
+ });
5499
+ try {
5500
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
5501
+ const usage = messageUsage(messages, ocId ?? "");
5502
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
5503
+ } catch (err) {
5504
+ if (err instanceof ChannelAuthError) throw err;
5505
+ if (err instanceof ChannelTerminalError) {
5506
+ this.doneUndeliverable.add(row.id);
5507
+ this.log({
5508
+ level: "warn",
5509
+ 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}`,
5510
+ conversation_id: row.conversation_id,
5511
+ message_id: row.id
5512
+ });
5513
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
5514
+ return;
5515
+ }
5516
+ this.log({
5517
+ level: "warn",
5518
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
5519
+ conversation_id: row.conversation_id,
5520
+ message_id: row.id
5521
+ });
5522
+ return;
5523
+ }
5524
+ this.dontRedispatch.delete(row.id);
5525
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
5526
+ }
5409
5527
  /**
5410
5528
  * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
5411
5529
  *
@@ -6035,17 +6153,25 @@ var ChannelDriver = class _ChannelDriver {
6035
6153
  * the aborted-in-flight production bug after a restart.
6036
6154
  * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
6037
6155
  * (the sub-agent preamble — #253's shape).
6038
- * - `other` — any other shape (defensive; a running row is normally b1 or b2).
6039
- * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
6040
- * shape) directly rather than re-importing the module-private `completedOf`/
6041
- * `finishOf` — this is a display label only, not a correctness predicate.
6156
+ * - `ambiguous` — a COMPLETED, non-errored reply whose `finish` is neither
6157
+ * "tool-calls" nor "stop" (issue #1493, class 4 see
6158
+ * `isAmbiguousFinishPinnedRunning`/Task 2.4).
6159
+ * - `other` — any other shape (defensive; a running row is normally b1, b2 or
6160
+ * ambiguous).
6161
+ * Reads `info.time.completed` / `info.finish` / `info.error` (tolerating the
6162
+ * legacy top-level shape) directly rather than re-importing the module-private
6163
+ * `completedOf`/`finishOf`/`errorOf` — this is a display label only, not a
6164
+ * correctness predicate (that is `isAmbiguousFinishPinnedRunning`'s job).
6042
6165
  */
6043
6166
  replyCompletionShape(reply) {
6044
6167
  if (!reply) return "other";
6045
6168
  const completed = reply.info?.time?.completed ?? reply.time?.completed;
6046
6169
  if (completed == null) return "b1";
6047
6170
  const finish = reply.info?.finish ?? reply.finish;
6048
- return finish === "tool-calls" ? "b2" : "other";
6171
+ if (finish === "tool-calls") return "b2";
6172
+ const error2 = reply.info?.error ?? reply.error;
6173
+ if (finish !== "stop" && error2 == null) return "ambiguous";
6174
+ return "other";
6049
6175
  }
6050
6176
  /**
6051
6177
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -6662,7 +6788,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
6662
6788
  if (trimmed === "") {
6663
6789
  throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
6664
6790
  }
6665
- const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
6791
+ const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join4(homeDir, trimmed.slice(2)) : trimmed;
6666
6792
  if (!isAbsolute2(expanded)) {
6667
6793
  throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
6668
6794
  }
@@ -7025,6 +7151,13 @@ function scheduleSessionCleanup(state, driver, options) {
7025
7151
  for (const warning2 of config.warnings) {
7026
7152
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
7027
7153
  }
7154
+ const sizeWarning = buildSessionStoreSizeWarning({
7155
+ dbBytes: statSessionDbBytes(homedir3()),
7156
+ cleanupEnabled: config.enabled
7157
+ });
7158
+ if (sizeWarning !== null) {
7159
+ logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7160
+ }
7028
7161
  if (!config.enabled) return;
7029
7162
  logActivity(state, {
7030
7163
  type: "info",