@evident-ai/cli 3.1.1-dev.fe0815c → 3.2.1-dev.5d79124

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