@evident-ai/cli 3.5.2-dev.07822ab → 3.5.2-dev.1ca1af3

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
@@ -703,6 +703,31 @@ async function reportResourceUsage(agentId, authHeader, usage) {
703
703
  opencode_db_bytes: usage.opencodeDbBytes
704
704
  });
705
705
  }
706
+ async function getRunnerCredentialVault(agentId, authHeader) {
707
+ const empty = { prefix: null, vaultId: null, source: null };
708
+ try {
709
+ const apiUrl = getApiUrlConfig();
710
+ const response = await fetch(`${apiUrl}/runners/${agentId}/credential-vault`, {
711
+ headers: { Authorization: authHeader },
712
+ signal: AbortSignal.timeout(1e4)
713
+ });
714
+ if (!response.ok) {
715
+ const serverMessage = await readErrorMessage(response);
716
+ return {
717
+ ...empty,
718
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
719
+ };
720
+ }
721
+ const data = await response.json();
722
+ return {
723
+ prefix: data.credential_vault_prefix,
724
+ vaultId: data.vault_id,
725
+ source: data.source
726
+ };
727
+ } catch (error2) {
728
+ return { ...empty, error: describeTimeoutError(error2, 1e4) };
729
+ }
730
+ }
706
731
  async function getAgentInfo(agentId, authHeader) {
707
732
  const apiUrl = getApiUrlConfig();
708
733
  try {
@@ -877,6 +902,76 @@ async function status(options = {}) {
877
902
  process.exit(result.exitCode);
878
903
  }
879
904
 
905
+ // src/commands/credential-vault-env.ts
906
+ function shellQuote(value) {
907
+ return `'${value.replaceAll("'", "'\\''")}'`;
908
+ }
909
+ function printAssignment(prefix) {
910
+ console.log(`CREDENTIAL_VAULT_PREFIX=${shellQuote(prefix ?? "")}`);
911
+ }
912
+ function safeReason(reason, credentials2, authHeader) {
913
+ let safe = reason;
914
+ for (const secret of [
915
+ authHeader,
916
+ credentials2?.token,
917
+ process.env.EVIDENT_RUNNER_KEY,
918
+ process.env.EVIDENT_AGENT_KEY,
919
+ process.env.EVIDENT_TOKEN
920
+ ]) {
921
+ if (secret) safe = safe.replaceAll(secret, "[redacted]");
922
+ }
923
+ return safe.replace(/[\r\n]+/g, " ");
924
+ }
925
+ function printFallback(reason, credentials2, authHeader) {
926
+ printAssignment(null);
927
+ console.error(
928
+ `WARNING: ${safeReason(reason, credentials2, authHeader)}; using this runner's own credential prefix.`
929
+ );
930
+ }
931
+ async function credentialVaultEnv() {
932
+ let credentials2 = null;
933
+ let authHeader;
934
+ try {
935
+ credentials2 = await getAuthCredentials();
936
+ if (!credentials2) {
937
+ printFallback("no runner credentials are configured");
938
+ return;
939
+ }
940
+ authHeader = getAuthHeader(credentials2);
941
+ const runner = await resolveAgentIdFromKey(authHeader);
942
+ if (!runner.agent_id) {
943
+ printFallback(
944
+ runner.error ?? "the runner identity could not be resolved",
945
+ credentials2,
946
+ authHeader
947
+ );
948
+ return;
949
+ }
950
+ const lookup = await getRunnerCredentialVault(runner.agent_id, authHeader);
951
+ if (lookup.error) {
952
+ printFallback(`credential vault lookup failed: ${lookup.error}`, credentials2, authHeader);
953
+ return;
954
+ }
955
+ if (lookup.prefix === null) {
956
+ printFallback("this runner is not linked to a credential vault", credentials2, authHeader);
957
+ return;
958
+ }
959
+ if (/[\r\n]/.test(lookup.prefix)) {
960
+ printFallback("credential vault lookup returned an invalid prefix", credentials2, authHeader);
961
+ return;
962
+ }
963
+ printAssignment(lookup.prefix);
964
+ console.error(
965
+ `VAULT-MODE: credential stores use vault=${lookup.vaultId} source=${lookup.source} prefix=${lookup.prefix}.`
966
+ );
967
+ } catch (error2) {
968
+ const detail = credentials2 === null ? "credential resolution failed" : `credential vault lookup could not be completed: ${error2 instanceof Error ? error2.message : String(error2)}`;
969
+ printFallback(detail, credentials2, authHeader);
970
+ } finally {
971
+ process.exitCode = 0;
972
+ }
973
+ }
974
+
880
975
  // src/lib/claude-usage.ts
881
976
  import { execFileSync } from "node:child_process";
882
977
  import { readFileSync } from "node:fs";
@@ -1069,6 +1164,76 @@ import chalk7 from "chalk";
1069
1164
  // ../../packages/types/src/agents/index.ts
1070
1165
  var MICROVM_MAX_LIFETIME_MS = 8 * 60 * 6e4;
1071
1166
 
1167
+ // ../../packages/types/src/opencode/index.ts
1168
+ function roleOf(m) {
1169
+ if (!m || typeof m !== "object") return void 0;
1170
+ if (typeof m.role === "string") return m.role;
1171
+ const infoRole = m.info?.role;
1172
+ return typeof infoRole === "string" ? infoRole : void 0;
1173
+ }
1174
+ function idOf(m) {
1175
+ if (!m || typeof m !== "object") return void 0;
1176
+ if (typeof m.id === "string") return m.id;
1177
+ const infoId = m.info?.id;
1178
+ return typeof infoId === "string" ? infoId : void 0;
1179
+ }
1180
+ function parentIdOf(m) {
1181
+ if (!m || typeof m !== "object") return void 0;
1182
+ if (typeof m.parentID === "string") return m.parentID;
1183
+ const infoParent = m.info?.parentID;
1184
+ return typeof infoParent === "string" ? infoParent : void 0;
1185
+ }
1186
+ function textPartsOf(m) {
1187
+ if (!m || typeof m !== "object") return [];
1188
+ const parts = Array.isArray(m.parts) ? m.parts : [];
1189
+ return parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text ?? "").filter(Boolean);
1190
+ }
1191
+ function correlatedAssistantRun(messages, userMessageId) {
1192
+ if (!messages || messages.length === 0) return [];
1193
+ const byParent = messages.filter(
1194
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1195
+ );
1196
+ if (byParent.length > 0) return byParent;
1197
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1198
+ if (userIndex === -1) return [];
1199
+ const run2 = [];
1200
+ for (let i = userIndex + 1; i < messages.length; i++) {
1201
+ const role = roleOf(messages[i]);
1202
+ if (role === "user") break;
1203
+ if (role === "assistant") run2.push(messages[i]);
1204
+ }
1205
+ return run2;
1206
+ }
1207
+ function lastText(assistantRun) {
1208
+ for (let i = assistantRun.length - 1; i >= 0; i--) {
1209
+ const textParts = textPartsOf(assistantRun[i]);
1210
+ if (textParts.length > 0) return textParts.join("\n").trim() || null;
1211
+ }
1212
+ return null;
1213
+ }
1214
+ function effectiveCorrelatedRun(messages, userMessageId) {
1215
+ const run2 = correlatedAssistantRun(messages, userMessageId);
1216
+ if (lastText(run2) !== null) return run2;
1217
+ const userIndex = messages?.findIndex((m) => idOf(m) === userMessageId) ?? -1;
1218
+ if (userIndex === -1) return run2;
1219
+ let continuation;
1220
+ for (let i = userIndex + 1; messages && i < messages.length; i++) {
1221
+ if (roleOf(messages[i]) === "user") {
1222
+ continuation = messages[i];
1223
+ break;
1224
+ }
1225
+ }
1226
+ const continuationId = idOf(continuation);
1227
+ if (roleOf(continuation) === "user" && textPartsOf(continuation).join("\n").trim().toLowerCase() === "continue" && continuationId) {
1228
+ const continuationRun = correlatedAssistantRun(messages, continuationId);
1229
+ if (continuationRun.length > 0) return continuationRun;
1230
+ }
1231
+ return run2;
1232
+ }
1233
+ function extractReplyTextForMessage(messages, opencodeUserMessageId) {
1234
+ return lastText(effectiveCorrelatedRun(messages, opencodeUserMessageId));
1235
+ }
1236
+
1072
1237
  // ../../packages/types/src/telemetry/index.ts
1073
1238
  var TelemetryEventTypes = {
1074
1239
  // Agent activity events (shown in web UI activity log)
@@ -1157,7 +1322,7 @@ import ora4 from "ora";
1157
1322
  import { select as select4 } from "@inquirer/prompts";
1158
1323
 
1159
1324
  // src/lib/telemetry.ts
1160
- var CLI_VERSION = (true ? "3.5.2-dev.07822ab" : void 0) ?? process.env.npm_package_version ?? "unknown";
1325
+ var CLI_VERSION = (true ? "3.5.2-dev.1ca1af3" : void 0) ?? process.env.npm_package_version ?? "unknown";
1161
1326
  function getCliVersion() {
1162
1327
  return CLI_VERSION;
1163
1328
  }
@@ -1889,12 +2054,23 @@ function buildSessionDbRecoveryActivity(record) {
1889
2054
  metadata: withoutContractFields(record),
1890
2055
  message: `This runner started without restoring session history because it could not verify where its session history is backed up. Nothing in the backup was changed; check the runner backup configuration and permissions.${replication}`
1891
2056
  };
1892
- case "session_db_boot_refused":
1893
- return {
1894
- level,
1895
- metadata: withoutContractFields(record),
1896
- message: `This runner did not come online because its damaged session database could not be safely separated from its active backup or proven removed. Backed-up session history remains readable at ${record.quarantine_destination ?? "its original location or the quarantine destination named in the boot logs"}; any local session-database files that remain were left in place and nothing opened or wrote them. See the runner boot logs for SESSION-DB-LOCAL-DISCARD-FAILED details.`
1897
- };
2057
+ case "session_db_boot_refused": {
2058
+ const message = (() => {
2059
+ switch (record.reason) {
2060
+ case "local_discard_failed":
2061
+ return `This runner did not come online because its damaged session database could not be safely separated from its active backup or proven removed. Backed-up session history remains readable at ${record.quarantine_destination ?? "its original location or the quarantine destination named in the boot logs"}; any local session-database files that remain were left in place and nothing opened or wrote them. See the runner boot logs for SESSION-DB-LOCAL-DISCARD-FAILED details.`;
2062
+ case "replica_separation_unproven":
2063
+ return `This runner did not come online because its damaged session database could not be safely separated from its active backup. The SESSION-DB-REPLICA-SEPARATION-UNVERIFIED boot log contains the partial-quarantine moved and remaining counts. Backed-up session history remains readable at ${record.quarantine_destination ?? "its original location or the quarantine destination named in the boot logs"}; any local session-database files that remain were left in place and nothing opened or wrote them.`;
2064
+ case "verification_timeout_before_separation":
2065
+ return "This runner did not come online because session database verification timed out after detecting corruption, before replica separation was attempted. No replica progress is claimed. See SESSION-DB-VERIFY-TIMEOUT in the runner boot logs before retrying.";
2066
+ case "verification_timeout_during_separation":
2067
+ return "This runner did not come online because session database verification timed out while replica separation was in progress. Separation completion and object removal were not proven. See SESSION-DB-VERIFY-TIMEOUT in the runner boot logs before retrying.";
2068
+ default:
2069
+ return "This runner did not come online because session database recovery could not be completed. Inspect the runner boot logs for the refusal reason before retrying.";
2070
+ }
2071
+ })();
2072
+ return { level, metadata: withoutContractFields(record), message };
2073
+ }
1898
2074
  case "schema_provenance_mismatch":
1899
2075
  return {
1900
2076
  level,
@@ -2144,7 +2320,7 @@ async function getOpenCodeDirectory(port, client) {
2144
2320
  return null;
2145
2321
  }
2146
2322
  }
2147
- function roleOf(m) {
2323
+ function roleOf2(m) {
2148
2324
  if (!m || typeof m !== "object") return void 0;
2149
2325
  if (typeof m.role === "string") return m.role;
2150
2326
  const infoRole = m.info?.role;
@@ -2158,13 +2334,13 @@ function createdOf(m) {
2158
2334
  if (!m || typeof m !== "object") return void 0;
2159
2335
  return m.info?.time?.created ?? m.time?.created;
2160
2336
  }
2161
- function idOf(m) {
2337
+ function idOf2(m) {
2162
2338
  if (!m || typeof m !== "object") return void 0;
2163
2339
  if (typeof m.id === "string") return m.id;
2164
2340
  const infoId = m.info?.id;
2165
2341
  return typeof infoId === "string" ? infoId : void 0;
2166
2342
  }
2167
- function parentIdOf(m) {
2343
+ function parentIdOf2(m) {
2168
2344
  if (!m || typeof m !== "object") return void 0;
2169
2345
  if (typeof m.parentID === "string") return m.parentID;
2170
2346
  const infoParent = m.info?.parentID;
@@ -2180,8 +2356,12 @@ function errorOf(m) {
2180
2356
  if (!m || typeof m !== "object") return void 0;
2181
2357
  return m.info?.error ?? m.error;
2182
2358
  }
2359
+ function isTerminalStopAnswer(m) {
2360
+ return completedOf(m) != null && errorOf(m) == null && finishOf(m) === "stop";
2361
+ }
2183
2362
  function isAssistantInFlight(m) {
2184
2363
  if (completedOf(m) == null) return true;
2364
+ if (errorOf(m) != null) return false;
2185
2365
  return finishOf(m) === "tool-calls";
2186
2366
  }
2187
2367
  async function getSessionMessages(port, sessionId, client) {
@@ -2229,7 +2409,7 @@ async function pollSessionMessagesForRedrive(port, sessionId, client) {
2229
2409
  function isSessionActivelyGenerating(messages) {
2230
2410
  if (!messages || messages.length === 0) return false;
2231
2411
  const last = messages[messages.length - 1];
2232
- if (roleOf(last) !== "assistant") return false;
2412
+ if (roleOf2(last) !== "assistant") return false;
2233
2413
  return completedOf(last) == null;
2234
2414
  }
2235
2415
  var FULL_SESSION_ENUMERATION_MAX_SESSIONS = 3200;
@@ -2538,11 +2718,31 @@ function messageText(m) {
2538
2718
  if (!m || !Array.isArray(m.parts)) return "";
2539
2719
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
2540
2720
  }
2541
- async function sendPromptAsync(port, sessionId, content, options, attachments, client) {
2721
+ function findDispatchedUserMessageId(messages, content, excludeIds) {
2722
+ if (!messages || messages.length === 0) return null;
2723
+ let best = null;
2724
+ for (const message of messages) {
2725
+ if (roleOf2(message) !== "user") continue;
2726
+ const id = idOf2(message);
2727
+ if (typeof id !== "string" || excludeIds?.has(id)) continue;
2728
+ if (messageText(message) !== content) continue;
2729
+ const created = createdOf(message) ?? 0;
2730
+ if (best === null || created > best.created) best = { id, created };
2731
+ }
2732
+ return best?.id ?? null;
2733
+ }
2734
+ async function sendPromptAsync(port, sessionId, content, options, attachments, client, callbacks) {
2542
2735
  const before = await getSessionMessages(port, sessionId, client);
2543
2736
  const knownUserIds = new Set(
2544
- (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
2737
+ (before ?? []).filter((m) => roleOf2(m) === "user").map((m) => idOf2(m)).filter((id) => typeof id === "string")
2545
2738
  );
2739
+ if (callbacks) {
2740
+ try {
2741
+ callbacks.onKnownUserIds(before === null ? null : knownUserIds);
2742
+ } catch (error2) {
2743
+ callbacks.onKnownUserIdsError(error2);
2744
+ }
2745
+ }
2546
2746
  const parts = [{ type: "text", text: content }];
2547
2747
  let pendingOutcomes = null;
2548
2748
  if (attachments && attachments.inputs.length > 0) {
@@ -2581,22 +2781,10 @@ async function sendPromptAsync(port, sessionId, content, options, attachments, c
2581
2781
  const READ_BACK_DELAY_MS = 150;
2582
2782
  for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
2583
2783
  const after = await getSessionMessages(port, sessionId, client);
2584
- if (after) {
2585
- let best = null;
2586
- for (const m of after) {
2587
- if (roleOf(m) !== "user") continue;
2588
- const id = idOf(m);
2589
- if (typeof id !== "string" || knownUserIds.has(id)) continue;
2590
- if (messageText(m) !== content) continue;
2591
- const created = createdOf(m) ?? 0;
2592
- if (best === null || created > best.created) {
2593
- best = { id, created };
2594
- }
2595
- }
2596
- if (best) {
2597
- if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
2598
- return best.id;
2599
- }
2784
+ const dispatchedUserMessageId = findDispatchedUserMessageId(after, content, knownUserIds);
2785
+ if (dispatchedUserMessageId !== null) {
2786
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
2787
+ return dispatchedUserMessageId;
2600
2788
  }
2601
2789
  if (attempt < READ_BACK_ATTEMPTS - 1) {
2602
2790
  await new Promise((resolve4) => setTimeout(resolve4, READ_BACK_DELAY_MS));
@@ -2607,57 +2795,68 @@ async function sendPromptAsync(port, sessionId, content, options, attachments, c
2607
2795
  function findAssistantReplyAfter(messages, userMessageId) {
2608
2796
  if (!messages || messages.length === 0) return null;
2609
2797
  const byParent = messages.find(
2610
- (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
2798
+ (m) => roleOf2(m) === "assistant" && parentIdOf2(m) === userMessageId
2611
2799
  );
2612
2800
  if (byParent) return byParent;
2613
- const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
2801
+ const userIndex = messages.findIndex((m) => idOf2(m) === userMessageId);
2614
2802
  if (userIndex === -1) return null;
2615
2803
  for (let i = userIndex + 1; i < messages.length; i++) {
2616
- if (roleOf(messages[i]) === "assistant") return messages[i];
2804
+ if (roleOf2(messages[i]) === "assistant") return messages[i];
2617
2805
  }
2618
2806
  return null;
2619
2807
  }
2620
2808
  function findLastAssistantReplyFor(messages, userMessageId) {
2621
2809
  if (!messages || messages.length === 0) return null;
2622
2810
  let lastCorrelated = null;
2623
- let lastNonErrored = null;
2624
2811
  for (let i = messages.length - 1; i >= 0; i--) {
2625
2812
  const m = messages[i];
2626
- if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
2627
- if (lastCorrelated === null) lastCorrelated = m;
2628
- if (errorOf(m) == null) {
2629
- lastNonErrored = m;
2630
- break;
2813
+ if (roleOf2(m) !== "assistant" || parentIdOf2(m) !== userMessageId) continue;
2814
+ lastCorrelated = m;
2815
+ break;
2816
+ }
2817
+ if (lastCorrelated) {
2818
+ if (errorOf(lastCorrelated) == null) return lastCorrelated;
2819
+ for (let i = messages.length - 1; i >= 0; i--) {
2820
+ const m = messages[i];
2821
+ if (roleOf2(m) === "assistant" && parentIdOf2(m) === userMessageId && isTerminalStopAnswer(m)) {
2822
+ return m;
2823
+ }
2631
2824
  }
2825
+ return lastCorrelated;
2632
2826
  }
2633
- if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
2634
- const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
2827
+ const userIndex = messages.findIndex((m) => idOf2(m) === userMessageId);
2635
2828
  if (userIndex === -1) return null;
2636
2829
  let last = null;
2637
- let lastOk = null;
2830
+ let lastAssistantIndex = -1;
2638
2831
  for (let i = userIndex + 1; i < messages.length; i++) {
2639
- const role = roleOf(messages[i]);
2832
+ const role = roleOf2(messages[i]);
2640
2833
  if (role === "user") break;
2641
2834
  if (role === "assistant") {
2642
2835
  last = messages[i];
2643
- if (errorOf(messages[i]) == null) lastOk = messages[i];
2836
+ lastAssistantIndex = i;
2644
2837
  }
2645
2838
  }
2646
- return lastOk ?? last;
2839
+ if (last === null || errorOf(last) == null) return last;
2840
+ for (let i = lastAssistantIndex; i > userIndex; i--) {
2841
+ const m = messages[i];
2842
+ if (roleOf2(m) === "user") break;
2843
+ if (roleOf2(m) === "assistant" && isTerminalStopAnswer(m)) return m;
2844
+ }
2845
+ return last;
2647
2846
  }
2648
2847
  function collectSubagentSessions(messages, userMessageId) {
2649
2848
  if (!messages || messages.length === 0) return [];
2650
2849
  const byParent = messages.filter(
2651
- (message) => roleOf(message) === "assistant" && parentIdOf(message) === userMessageId
2850
+ (message) => roleOf2(message) === "assistant" && parentIdOf2(message) === userMessageId
2652
2851
  );
2653
2852
  const assistants = byParent.length > 0 ? byParent : [];
2654
2853
  if (assistants.length === 0) {
2655
- const userIndex = messages.findIndex((message) => idOf(message) === userMessageId);
2854
+ const userIndex = messages.findIndex((message) => idOf2(message) === userMessageId);
2656
2855
  if (userIndex === -1) return [];
2657
2856
  for (let i = userIndex + 1; i < messages.length; i++) {
2658
2857
  const message = messages[i];
2659
- if (roleOf(message) === "user") break;
2660
- if (roleOf(message) === "assistant") assistants.push(message);
2858
+ if (roleOf2(message) === "user") break;
2859
+ if (roleOf2(message) === "assistant") assistants.push(message);
2661
2860
  }
2662
2861
  }
2663
2862
  const refs = [];
@@ -2697,7 +2896,7 @@ function collectTaskCalls(messages, userMessageId) {
2697
2896
  if (!messages || messages.length === 0) return [];
2698
2897
  const calls = [];
2699
2898
  for (const message of messages) {
2700
- if (roleOf(message) !== "assistant" || parentIdOf(message) !== userMessageId) continue;
2899
+ if (roleOf2(message) !== "assistant" || parentIdOf2(message) !== userMessageId) continue;
2701
2900
  for (const part of message.parts ?? []) {
2702
2901
  if (part.tool !== "task" || !part.callID || !part.state || part.state.status === "pending") {
2703
2902
  continue;
@@ -2727,7 +2926,7 @@ function attributeTaskCallUsage(messages, windows) {
2727
2926
  for (const window of eligibleWindows) assignments.set(window.callID, []);
2728
2927
  const unattributed = [];
2729
2928
  for (const message of messages ?? []) {
2730
- if (roleOf(message) !== "assistant") continue;
2929
+ if (roleOf2(message) !== "assistant") continue;
2731
2930
  const created = finiteNumber(createdOf(message));
2732
2931
  const matching = created === null ? [] : eligibleWindows.filter(
2733
2932
  (window) => window.timeStart <= created && (window.timeEnd === null || window.timeEnd === void 0 || created <= window.timeEnd)
@@ -2749,8 +2948,9 @@ function attributeTaskCallUsage(messages, windows) {
2749
2948
  }
2750
2949
  function sumAssistantUsage(messages) {
2751
2950
  if (!messages || messages.length === 0) return null;
2752
- const nonErrored = messages.filter((message) => errorOf(message) == null);
2753
- const selected = nonErrored.length > 0 ? nonErrored : messages;
2951
+ const hasTrailingError = errorOf(messages[messages.length - 1]) != null;
2952
+ const hasTerminalStopAnswer = messages.some(isTerminalStopAnswer);
2953
+ const selected = hasTrailingError && hasTerminalStopAnswer ? messages.filter((message) => errorOf(message) == null) : messages;
2754
2954
  let sawAnyUsage = false;
2755
2955
  let inputSum = 0;
2756
2956
  let outputSum = 0;
@@ -2804,13 +3004,11 @@ function sumAssistantUsage(messages) {
2804
3004
  function messageUsage(messages, userMessageId) {
2805
3005
  if (!messages || messages.length === 0) return null;
2806
3006
  const byParentAll = messages.filter(
2807
- (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
3007
+ (m) => roleOf2(m) === "assistant" && parentIdOf2(m) === userMessageId
2808
3008
  );
2809
- const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
2810
- const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
2811
3009
  let correlated;
2812
- if (byParent.length > 0) {
2813
- correlated = byParent;
3010
+ if (byParentAll.length > 0) {
3011
+ correlated = byParentAll;
2814
3012
  } else {
2815
3013
  const reply = findAssistantReplyAfter(messages, userMessageId);
2816
3014
  correlated = reply ? [reply] : [];
@@ -2820,7 +3018,7 @@ function messageUsage(messages, userMessageId) {
2820
3018
  }
2821
3019
  function messageRunState(messages, userMessageId) {
2822
3020
  if (!messages || messages.length === 0) return "unknown";
2823
- const hasUser = messages.some((m) => idOf(m) === userMessageId);
3021
+ const hasUser = messages.some((m) => idOf2(m) === userMessageId);
2824
3022
  const reply = findLastAssistantReplyFor(messages, userMessageId);
2825
3023
  if (!hasUser) {
2826
3024
  if (!reply) return "unknown";
@@ -2856,14 +3054,38 @@ function messageError(messages, userMessageId) {
2856
3054
  const reply = findLastAssistantReplyFor(messages, userMessageId);
2857
3055
  const error2 = errorOf(reply);
2858
3056
  if (error2 == null) return null;
2859
- if (typeof error2 === "string") return error2;
2860
- if (typeof error2 === "object") {
3057
+ let rendered;
3058
+ if (typeof error2 === "string") {
3059
+ rendered = error2;
3060
+ } else if (typeof error2 === "object") {
2861
3061
  const e = error2;
2862
3062
  const dataMessage = e.data?.message;
2863
- if (typeof dataMessage === "string") return dataMessage;
2864
- if (typeof e.message === "string") return e.message;
3063
+ if (typeof dataMessage === "string") {
3064
+ rendered = dataMessage;
3065
+ } else if (typeof e.message === "string") {
3066
+ rendered = e.message;
3067
+ } else {
3068
+ rendered = "The agent run failed.";
3069
+ }
3070
+ } else {
3071
+ rendered = "The agent run failed.";
2865
3072
  }
2866
- return "The agent run failed.";
3073
+ const toolPart = Array.isArray(reply?.parts) ? reply.parts.find(
3074
+ (part) => part && typeof part === "object" && part.type === "tool" && part.state?.status === "error"
3075
+ ) : void 0;
3076
+ return typeof toolPart?.tool === "string" && toolPart.tool.length > 0 ? `${rendered} (tool "${toolPart.tool}")` : rendered;
3077
+ }
3078
+ function describeAbandonedReplyShape(messages, userMessageId) {
3079
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
3080
+ if (!reply) return null;
3081
+ const parts = Array.isArray(reply.parts) ? reply.parts : [];
3082
+ const lastTool = [...parts].reverse().find((part) => part?.type === "tool");
3083
+ return {
3084
+ partTypes: parts.slice(-12).map((part) => typeof part?.type === "string" ? part.type : "(unknown)"),
3085
+ droppedPartCount: Math.max(0, parts.length - 12),
3086
+ lastToolName: typeof lastTool?.tool === "string" && lastTool.tool.length > 0 ? lastTool.tool : null,
3087
+ lastToolStatus: typeof lastTool?.state?.status === "string" && lastTool.state.status.length > 0 ? lastTool.state.status : null
3088
+ };
2867
3089
  }
2868
3090
  function isAbortedTerminalReply(messages, userMessageId) {
2869
3091
  const reply = findLastAssistantReplyFor(messages, userMessageId);
@@ -2912,7 +3134,7 @@ function findLatestSubagentAuthOutcome(messages, sinceMs) {
2912
3134
  if (!messages || messages.length === 0) return null;
2913
3135
  for (let i = messages.length - 1; i >= 0; i--) {
2914
3136
  const message = messages[i];
2915
- if (roleOf(message) !== "assistant") continue;
3137
+ if (roleOf2(message) !== "assistant") continue;
2916
3138
  const created = createdOf(message);
2917
3139
  if (sinceMs !== null && typeof created === "number" && created < sinceMs) continue;
2918
3140
  const failure = classifyReplyAuthError(message);
@@ -2944,25 +3166,25 @@ function applyZeroProviderFallback(classified, hasConfiguredProvider, replyProvi
2944
3166
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
2945
3167
  if (!messages || messages.length === 0) return false;
2946
3168
  return messages.some(
2947
- (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
3169
+ (m) => roleOf2(m) === "assistant" && parentIdOf2(m) !== exceptUserMessageId && isAssistantInFlight(m)
2948
3170
  );
2949
3171
  }
2950
3172
  function hasLaterSiblingTurnStarted(messages, userMessageId, siblingUserMessageIds) {
2951
3173
  if (!messages || messages.length === 0) return false;
2952
- const userIndex = messages.findIndex((message) => idOf(message) === userMessageId);
3174
+ const userIndex = messages.findIndex((message) => idOf2(message) === userMessageId);
2953
3175
  if (userIndex === -1) return false;
2954
3176
  let hasLaterUser = false;
2955
3177
  let hasStartedLaterUser = false;
2956
3178
  for (let i = userIndex + 1; i < messages.length; i++) {
2957
3179
  const message = messages[i];
2958
- if (roleOf(message) !== "user") continue;
3180
+ if (roleOf2(message) !== "user") continue;
2959
3181
  hasLaterUser = true;
2960
- const laterUserMessageId = idOf(message);
3182
+ const laterUserMessageId = idOf2(message);
2961
3183
  if (laterUserMessageId === void 0 || !siblingUserMessageIds.has(laterUserMessageId)) {
2962
3184
  return false;
2963
3185
  }
2964
3186
  if (messages.some(
2965
- (candidate) => roleOf(candidate) === "assistant" && parentIdOf(candidate) === laterUserMessageId
3187
+ (candidate) => roleOf2(candidate) === "assistant" && parentIdOf2(candidate) === laterUserMessageId
2966
3188
  )) {
2967
3189
  hasStartedLaterUser = true;
2968
3190
  }
@@ -4147,9 +4369,12 @@ function runSynchroniser(args, opts) {
4147
4369
 
4148
4370
  // src/lib/opencode/session-db-boot.ts
4149
4371
  var SESSION_DB_RESTORE_TIMEOUT_MS = 3e5;
4150
- var SESSION_DB_VERIFY_TIMEOUT_MS = 12e4;
4372
+ var SESSION_DB_VERIFY_TIMEOUT_MS = 36e4;
4151
4373
  var SESSION_DB_SYNCHRONISER_TIMEOUT_MS = 12e4;
4152
- var SESSION_DB_VERIFY_WALKBACK_BUDGET_SECONDS = 120;
4374
+ var SESSION_DB_VERIFY_WALKBACK_BUDGET_SECONDS = 40;
4375
+ var SESSION_DB_VERIFY_RESTORE_POINTS_TIMEOUT_SECONDS = 30;
4376
+ var SESSION_DB_VERIFY_RESTORE_CANDIDATE_TIMEOUT_SECONDS = 120;
4377
+ var SESSION_DB_VERIFY_DISPOSAL_BUDGET_SECONDS = 90;
4153
4378
  function commandError(result) {
4154
4379
  return result.stderr.trim() || `process exited with code ${result.code ?? "null"}`;
4155
4380
  }
@@ -4460,20 +4685,37 @@ async function verifySessionDb(options, configPath, env) {
4460
4685
  timeoutMs: SESSION_DB_VERIFY_TIMEOUT_MS,
4461
4686
  env: {
4462
4687
  ...env,
4463
- // The synchroniser reads this value in SECONDS. Keep this at 120, not
4464
- // 120_000, so the walkback gives up before the outer process bound.
4688
+ // The synchroniser reads all four values in SECONDS.
4465
4689
  EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS: String(
4466
4690
  SESSION_DB_VERIFY_WALKBACK_BUDGET_SECONDS
4691
+ ),
4692
+ EVIDENT_SESSION_DB_RESTORE_POINTS_TIMEOUT_SECONDS: String(
4693
+ SESSION_DB_VERIFY_RESTORE_POINTS_TIMEOUT_SECONDS
4694
+ ),
4695
+ EVIDENT_SESSION_DB_RESTORE_CANDIDATE_TIMEOUT_SECONDS: String(
4696
+ SESSION_DB_VERIFY_RESTORE_CANDIDATE_TIMEOUT_SECONDS
4697
+ ),
4698
+ EVIDENT_SESSION_DB_DISPOSAL_BUDGET_SECONDS: String(
4699
+ SESSION_DB_VERIFY_DISPOSAL_BUDGET_SECONDS
4467
4700
  )
4468
4701
  }
4469
4702
  });
4470
4703
  logSynchroniserDiagnostics(result, options);
4471
4704
  if (result.timedOut || result.code === 124 || result.code === 137) {
4705
+ if (result.stderr.includes("SESSION-DB-INTEGRITY-FAILED")) {
4706
+ const reason = result.stderr.includes("SESSION-DB-REPLICA-SIZE") ? "verification_timeout_during_separation" : "verification_timeout_before_separation";
4707
+ options.log(
4708
+ `SESSION-DB-VERIFY-TIMEOUT: verification did not finish within its ${SESSION_DB_VERIFY_TIMEOUT_MS}ms deadline; refusing to start with the restored opencode.db (${reason})`,
4709
+ "error"
4710
+ );
4711
+ reportRecord("verify", "session_db_boot_refused", reason, null, options);
4712
+ return { verifyFatal: true, verifyFatalReason: reason };
4713
+ }
4472
4714
  options.log(
4473
4715
  `SESSION-DB-VERIFY-TIMEOUT: verification did not finish within its ${SESSION_DB_VERIFY_TIMEOUT_MS}ms deadline; continuing with the restored opencode.db as-is, unverified`,
4474
4716
  "warn"
4475
4717
  );
4476
- return false;
4718
+ return { verifyFatal: false };
4477
4719
  }
4478
4720
  if (result.code === 34) {
4479
4721
  reportRecord(
@@ -4483,14 +4725,14 @@ async function verifySessionDb(options, configPath, env) {
4483
4725
  null,
4484
4726
  options
4485
4727
  );
4486
- return true;
4728
+ return { verifyFatal: true };
4487
4729
  }
4488
4730
  if (result.code === 33) {
4489
4731
  options.log(
4490
4732
  "SESSION-DB-INTEGRITY-EXHAUSTED: booting continues and litestream still replicates, starting an empty backup chain after the corrupt replica was separated.",
4491
4733
  "warn"
4492
4734
  );
4493
- return false;
4735
+ return { verifyFatal: false };
4494
4736
  }
4495
4737
  if (result.code !== 0) {
4496
4738
  options.log(
@@ -4498,13 +4740,13 @@ async function verifySessionDb(options, configPath, env) {
4498
4740
  "warn"
4499
4741
  );
4500
4742
  }
4501
- return false;
4743
+ return { verifyFatal: false };
4502
4744
  }
4503
4745
  options.log(
4504
4746
  "skipping session-DB verification: this boot's session DB was not proven safe to replicate",
4505
4747
  "debug"
4506
4748
  );
4507
- return false;
4749
+ return { verifyFatal: false };
4508
4750
  }
4509
4751
  function fileExists(path) {
4510
4752
  try {
@@ -4591,7 +4833,7 @@ async function restoreAndVerifySessionDb(options) {
4591
4833
  if (options.noReplicateMarker && fileExists(options.noReplicateMarker)) {
4592
4834
  return { verifyFatal: false };
4593
4835
  }
4594
- return { verifyFatal: await verifySessionDb(options, configPath, env) };
4836
+ return verifySessionDb(options, configPath, env);
4595
4837
  }
4596
4838
 
4597
4839
  // src/lib/opencode/session-db-provenance.ts
@@ -6843,6 +7085,9 @@ var SUBAGENT_TELEMETRY_FETCH_TIMEOUT_MS = 2e3;
6843
7085
  var SESSION_ERROR_STREAM_HEALTHY_MS = 5e3;
6844
7086
  var SESSION_ERROR_BUFFER_TTL_MS = SESSION_ERROR_STREAM_HEALTHY_MS;
6845
7087
  var MAX_BUFFERED_SESSION_ERRORS = 256;
7088
+ var SESSION_ERROR_RETENTION_TTL_MS = 6e4;
7089
+ var SESSION_STORE_UNWRITABLE_ERROR = "The runner session database could not be written, so this turn did not finish. Nothing was lost from the queue. Clear disk space and retry.";
7090
+ var SESSION_ERROR_RETAINED_DETAIL = "The runner's session database could not be written, so this turn stopped part-way. Runner-observed session error: ";
6846
7091
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
6847
7092
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
6848
7093
  var DRIVER_SESSION_ENUMERATION_LIMIT = 20;
@@ -7000,6 +7245,7 @@ var ChannelDriver = class _ChannelDriver {
7000
7245
  * with a short TTL so an unmatched session cannot retain an event indefinitely.
7001
7246
  */
7002
7247
  bufferedSessionErrors = /* @__PURE__ */ new Map();
7248
+ retainedSessionErrors = /* @__PURE__ */ new Map();
7003
7249
  /**
7004
7250
  * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
7005
7251
  * dispatched and are still in-flight. A message in this set is never
@@ -7032,10 +7278,18 @@ var ChannelDriver = class _ChannelDriver {
7032
7278
  * row is `processing` or `pending`; clear it only when absent from both lists.
7033
7279
  */
7034
7280
  untrackableAck = /* @__PURE__ */ new Map();
7281
+ /** Session binding used by the first untrackable-ack report, for retries. */
7282
+ untrackableAckSessionIds = /* @__PURE__ */ new Map();
7035
7283
  /** Messages whose untrackable-ack failure signal was emitted while fenced. */
7036
7284
  untrackableAckSignalled = /* @__PURE__ */ new Set();
7037
7285
  /** Messages whose first untrackable-ack fence warning was already logged. */
7038
7286
  untrackableAckWarned = /* @__PURE__ */ new Set();
7287
+ /**
7288
+ * Accepted V1 prompts whose assigned ids were not read back; these rows are
7289
+ * re-read instead of POSTed again. A null snapshot cannot safely exclude an older
7290
+ * identical-text turn, so it never produces a correlation.
7291
+ */
7292
+ awaitingCorrelation = /* @__PURE__ */ new Map();
7039
7293
  /** Pending rows seen in the current drain, used to retain terminal dispatch fences. */
7040
7294
  pendingMessageIds = /* @__PURE__ */ new Set();
7041
7295
  /**
@@ -7121,21 +7375,6 @@ var ChannelDriver = class _ChannelDriver {
7121
7375
  * re-signal on every one of the 15h of re-dispatch attempts #1110 made.
7122
7376
  */
7123
7377
  dispatchNotStartedSignalled = /* @__PURE__ */ new Map();
7124
- /**
7125
- * Consecutive-UNCONFIRMED-dispatch streak for a `pending` row with NO stored
7126
- * `opencode_message_id` yet — i.e. one that has never even reached the
7127
- * re-drive fence above. `sendPromptAsync`'s POST may 2xx, but its own
7128
- * read-back retries can never confirm the assigned id when the session's
7129
- * message list is PERMANENTLY unreadable (e.g. a corrupted local opencode
7130
- * SQLite DB, #1345/#1348's exact fault, just hit BEFORE the row is ever
7131
- * dispatched instead of after). Unlike an already-dispatched row, THIS row has
7132
- * no other safety net at all: the lifecycle cron only reclaims `status =
7133
- * 'processing'` rows, and a row stuck here never reaches `processing`. Keyed
7134
- * by message id, carrying `sessionId` so a session change (a fresh one bound
7135
- * after abandonment) starts a new streak rather than inheriting the old
7136
- * session's count — same shape as `redrivePollFailures` above.
7137
- */
7138
- unconfirmedDispatchFailures = /* @__PURE__ */ new Map();
7139
7378
  /**
7140
7379
  * Consecutive give-ups for a message whose OpenCode user row remains queued.
7141
7380
  * The session id scopes the count: a fresh session is a new attempt after a
@@ -7153,8 +7392,8 @@ var ChannelDriver = class _ChannelDriver {
7153
7392
  * short-circuits while it is present, so a null-id row is re-dispatched AT MOST
7154
7393
  * ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
7155
7394
  * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
7156
- * re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
7157
- * so the NEXT tick may retry exactly once more).
7395
+ * re-entry) OR on a failed/unresolved dispatch (the accepted prompt is handed to
7396
+ * the terminal-report fence and is never sent again).
7158
7397
  */
7159
7398
  awaitingReadopt = /* @__PURE__ */ new Set();
7160
7399
  /**
@@ -7472,6 +7711,49 @@ var ChannelDriver = class _ChannelDriver {
7472
7711
  }
7473
7712
  return ids;
7474
7713
  }
7714
+ /** Find an accepted prompt's user row without adopting an older or concurrent turn. */
7715
+ findAwaitingCorrelationId(entry, sessionId, messages) {
7716
+ if (entry.sessionId !== sessionId || entry.knownUserIds === null) return null;
7717
+ const excludeIds = new Set(entry.knownUserIds);
7718
+ for (const inFlight of this.watchers.get(entry.sessionId)?.inFlight.values() ?? []) {
7719
+ excludeIds.add(inFlight.opencodeMessageId);
7720
+ }
7721
+ return findDispatchedUserMessageId(messages, entry.content, excludeIds);
7722
+ }
7723
+ /** Hand an exhausted accepted-but-untrackable prompt to the terminal-report fence. */
7724
+ async handoffUntrackableCorrelation(conversationId, messageId, sessionId, attempts, source) {
7725
+ this.awaitingCorrelation.delete(messageId);
7726
+ this.awaitingReadopt.delete(messageId);
7727
+ this.sessions.delete(conversationId);
7728
+ this.supersede(conversationId, sessionId);
7729
+ const dispatchDescription = source === "readopt" ? "re-dispatched message" : "message";
7730
+ const errorMessage3 = `OpenCode accepted this ${dispatchDescription} once, but its assigned id was never confirmed after ${attempts} attempts to match it up \u2014 the message was deliberately not sent again, so no duplicate turn was created; session ${sessionId.slice(0, 8)} was abandoned; a fresh one is used for further messages.`;
7731
+ this.untrackableAck.set(messageId, errorMessage3);
7732
+ this.untrackableAckSessionIds.set(messageId, null);
7733
+ this.log({
7734
+ level: "error",
7735
+ message: errorMessage3,
7736
+ conversation_id: conversationId,
7737
+ message_id: messageId
7738
+ });
7739
+ const reportError = await this.reportUntrackableAck(
7740
+ conversationId,
7741
+ messageId,
7742
+ null,
7743
+ errorMessage3
7744
+ );
7745
+ if (reportError !== null) {
7746
+ this.log({
7747
+ level: "warn",
7748
+ message: `markFailed PATCH for message ${messageId.slice(0, 8)} (conversation ${conversationId.slice(0, 8)}) failed: ${reportError.message}`,
7749
+ conversation_id: conversationId,
7750
+ message_id: messageId
7751
+ });
7752
+ }
7753
+ if (source === "readopt") {
7754
+ void this.postSignal(conversationId, messageId, "readopt_orphan_unsent");
7755
+ }
7756
+ }
7475
7757
  /**
7476
7758
  * File-pull work, for `run.ts`'s idle accounting (#559).
7477
7759
  *
@@ -7651,6 +7933,56 @@ var ChannelDriver = class _ChannelDriver {
7651
7933
  }
7652
7934
  break;
7653
7935
  }
7936
+ const correlation = this.awaitingCorrelation.get(message.id);
7937
+ if (correlation) {
7938
+ correlation.attempts += 1;
7939
+ let correlatedId = null;
7940
+ if (correlation.knownUserIds !== null && correlation.sessionId === sessionId) {
7941
+ const polled = await this.pollSessionMessagesForRedrive(
7942
+ conv,
7943
+ message,
7944
+ correlation.sessionId
7945
+ );
7946
+ if (polled.ok)
7947
+ correlatedId = this.findAwaitingCorrelationId(correlation, sessionId, polled.messages);
7948
+ } else if (correlation.knownUserIds === null) {
7949
+ this.log({
7950
+ level: "debug",
7951
+ message: `Message ${message.id.slice(0, 8)} cannot be correlated because its pre-dispatch session list was unreadable`,
7952
+ conversation_id: conv.id,
7953
+ message_id: message.id
7954
+ });
7955
+ }
7956
+ if (correlatedId !== null) {
7957
+ this.awaitingCorrelation.delete(message.id);
7958
+ this.dispatchNotStartedSignalled.delete(message.id);
7959
+ this.releasedOpencodeIds.delete(message.id);
7960
+ this.dispatched.add(message.id);
7961
+ this.registerInFlight(conv, correlation.sessionId, message, correlatedId);
7962
+ dispatched += 1;
7963
+ this.log({
7964
+ level: "info",
7965
+ message: `Matched accepted message ${message.id.slice(0, 8)} to OpenCode turn ${correlatedId.slice(0, 8)} without sending it again`,
7966
+ conversation_id: conv.id,
7967
+ message_id: message.id
7968
+ });
7969
+ void this.postSignal(conv.id, message.id, "ack_correlated", {
7970
+ opencode_message_id: correlatedId
7971
+ });
7972
+ continue;
7973
+ }
7974
+ if (correlation.attempts >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
7975
+ await this.handoffUntrackableCorrelation(
7976
+ conv.id,
7977
+ message.id,
7978
+ correlation.sessionId,
7979
+ correlation.attempts,
7980
+ "fresh"
7981
+ );
7982
+ break;
7983
+ }
7984
+ break;
7985
+ }
7654
7986
  const effectiveOpencodeMessageId = message.opencode_message_id ?? this.releasedOpencodeIds.get(message.id)?.opencodeMessageId ?? null;
7655
7987
  if (effectiveOpencodeMessageId) {
7656
7988
  const outcome = await this.resolveRedrive(
@@ -7672,6 +8004,7 @@ var ChannelDriver = class _ChannelDriver {
7672
8004
  model: message.opencode_model ?? void 0
7673
8005
  };
7674
8006
  let opencodeMessageId;
8007
+ let preDispatchUserIds = null;
7675
8008
  try {
7676
8009
  this.log({
7677
8010
  level: "info",
@@ -7701,7 +8034,20 @@ var ChannelDriver = class _ChannelDriver {
7701
8034
  message.content,
7702
8035
  options,
7703
8036
  sendAttachments,
7704
- this.openCodeClient
8037
+ this.openCodeClient,
8038
+ {
8039
+ onKnownUserIds: (ids) => {
8040
+ preDispatchUserIds = ids;
8041
+ },
8042
+ onKnownUserIdsError: (error2) => {
8043
+ this.log({
8044
+ level: "warn",
8045
+ message: `Pre-dispatch snapshot callback failed for message ${message.id.slice(0, 8)}: ${error2 instanceof Error ? error2.message : String(error2)}`,
8046
+ conversation_id: conv.id,
8047
+ message_id: message.id
8048
+ });
8049
+ }
8050
+ }
7705
8051
  )
7706
8052
  );
7707
8053
  } catch (err) {
@@ -7709,6 +8055,7 @@ var ChannelDriver = class _ChannelDriver {
7709
8055
  if (this.isV2 && err instanceof OpenCodeV2PromptAckError) {
7710
8056
  const errorMessage4 = err instanceof Error ? err.message : String(err);
7711
8057
  this.untrackableAck.set(message.id, errorMessage4);
8058
+ this.untrackableAckSessionIds.set(message.id, null);
7712
8059
  this.log({
7713
8060
  level: "error",
7714
8061
  message: `V2 prompt dispatch for message ${message.id.slice(0, 8)} failed after a positive ack with no usable id: ${errorMessage4}`,
@@ -7784,39 +8131,21 @@ var ChannelDriver = class _ChannelDriver {
7784
8131
  if (this.isV2) {
7785
8132
  throw new Error("V2 prompt dispatch completed without an acknowledged message id");
7786
8133
  }
7787
- const streak = this.recordUnconfirmedDispatch(message.id, sessionId);
7788
- if (streak < MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
7789
- this.log({
7790
- level: "warn",
7791
- 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`,
7792
- conversation_id: conv.id,
7793
- message_id: message.id
7794
- });
7795
- this.signalDispatchNotStarted(conv, message, "readback_unconfirmed");
7796
- continue;
7797
- }
7798
- this.unconfirmedDispatchFailures.delete(message.id);
7799
- this.sessions.delete(conv.id);
7800
- this.supersede(conv.id, sessionId);
7801
- const errorMessage3 = `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.`;
8134
+ this.awaitingCorrelation.set(message.id, {
8135
+ sessionId,
8136
+ content: message.content,
8137
+ attempts: 0,
8138
+ knownUserIds: preDispatchUserIds
8139
+ });
7802
8140
  this.log({
7803
- level: "error",
7804
- message: errorMessage3,
8141
+ level: "warn",
8142
+ message: `OpenCode accepted message ${message.id.slice(0, 8)}, but its assigned id could not be read back \u2014 the message will not be sent again while the runner matches it to the turn already running`,
7805
8143
  conversation_id: conv.id,
7806
8144
  message_id: message.id
7807
8145
  });
7808
- await this.markFailed(conv.id, message.id, null, errorMessage3).catch((markErr) => {
7809
- this.log({
7810
- level: "warn",
7811
- 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)}`,
7812
- conversation_id: conv.id,
7813
- message_id: message.id
7814
- });
7815
- this.signalDispatchNotStarted(conv, message, "abandon_unreported");
7816
- });
8146
+ this.signalDispatchNotStarted(conv, message, "readback_unconfirmed");
7817
8147
  break;
7818
8148
  }
7819
- this.unconfirmedDispatchFailures.delete(message.id);
7820
8149
  this.dispatchNotStartedSignalled.delete(message.id);
7821
8150
  this.releasedOpencodeIds.delete(message.id);
7822
8151
  this.dispatched.add(message.id);
@@ -8421,23 +8750,6 @@ var ChannelDriver = class _ChannelDriver {
8421
8750
  this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
8422
8751
  return 1;
8423
8752
  }
8424
- /**
8425
- * Record one UNCONFIRMED-dispatch outcome (a `pending` row with no stored
8426
- * `opencode_message_id` whose `sendPromptAsync` returned `null`) toward the
8427
- * bound in `processConversation`'s dispatch loop, and return the resulting
8428
- * count. Mirrors `recordRedrivePollFailure`'s session-scoping: a session
8429
- * change starts a fresh streak at `1` rather than inheriting the old one's
8430
- * count, since a new session is a genuinely different attempt.
8431
- */
8432
- recordUnconfirmedDispatch(messageId, sessionId) {
8433
- const existing = this.unconfirmedDispatchFailures.get(messageId);
8434
- if (existing && existing.sessionId === sessionId) {
8435
- existing.count += 1;
8436
- return existing.count;
8437
- }
8438
- this.unconfirmedDispatchFailures.set(messageId, { sessionId, count: 1 });
8439
- return 1;
8440
- }
8441
8753
  /** Record one queued give-up toward the never-started bound. */
8442
8754
  recordNeverStartedAttempt(messageId, sessionId) {
8443
8755
  const existing = this.neverStartedAttempts.get(messageId);
@@ -8749,6 +9061,7 @@ var ChannelDriver = class _ChannelDriver {
8749
9061
  b2LastDescendantCheckMs: 0,
8750
9062
  b2RootOngoingHeldLogged: false,
8751
9063
  b2AbandonedSignalled: false,
9064
+ b2AbandonedWithoutAnswer: false,
8752
9065
  ambiguousPinnedSinceMs: 0,
8753
9066
  ambiguousResolved: false,
8754
9067
  sessionStoreFailurePending: false,
@@ -8773,6 +9086,28 @@ var ChannelDriver = class _ChannelDriver {
8773
9086
  this.bufferedSessionErrors.delete(oldest);
8774
9087
  }
8775
9088
  }
9089
+ retainSessionError(event) {
9090
+ this.retainedSessionErrors.delete(event.sessionId);
9091
+ this.retainedSessionErrors.set(event.sessionId, {
9092
+ reason: event.reason,
9093
+ observedAt: this.now()
9094
+ });
9095
+ while (this.retainedSessionErrors.size > MAX_BUFFERED_SESSION_ERRORS) {
9096
+ const oldest = this.retainedSessionErrors.keys().next().value;
9097
+ if (typeof oldest !== "string") break;
9098
+ this.retainedSessionErrors.delete(oldest);
9099
+ }
9100
+ }
9101
+ readRetainedSessionError(sessionId) {
9102
+ const retained = this.retainedSessionErrors.get(sessionId);
9103
+ if (!retained) return null;
9104
+ if (this.now() - retained.observedAt >= SESSION_ERROR_RETENTION_TTL_MS) {
9105
+ this.retainedSessionErrors.delete(sessionId);
9106
+ return null;
9107
+ }
9108
+ this.retainedSessionErrors.delete(sessionId);
9109
+ return retained.reason;
9110
+ }
8776
9111
  /**
8777
9112
  * Build a fresh `SessionWatcher`. Seeds `lastTickAt`/`lastObservedTickAt`
8778
9113
  * EQUAL (#1618) so a watcher whose loop has not started ticking yet is never
@@ -8868,6 +9203,7 @@ var ChannelDriver = class _ChannelDriver {
8868
9203
  b2LastDescendantCheckMs: 0,
8869
9204
  b2RootOngoingHeldLogged: false,
8870
9205
  b2AbandonedSignalled: false,
9206
+ b2AbandonedWithoutAnswer: false,
8871
9207
  ambiguousPinnedSinceMs: 0,
8872
9208
  ambiguousResolved: false,
8873
9209
  sessionStoreFailurePending: false,
@@ -9098,9 +9434,10 @@ var ChannelDriver = class _ChannelDriver {
9098
9434
  return;
9099
9435
  }
9100
9436
  if ([...watcher.inFlight.values()].some((message) => message.started && !message.done)) {
9437
+ this.retainSessionError(event);
9101
9438
  this.log({
9102
- level: "debug",
9103
- message: `A turn is already running in session ${event.sessionId.slice(0, 8)} \u2014 deferring to transcript polling`,
9439
+ level: "warn",
9440
+ message: `A turn is already running in session ${event.sessionId.slice(0, 8)} \u2014 retaining the session error and deferring to transcript polling: ${event.reason}`,
9104
9441
  conversation_id: watcher.conv.id
9105
9442
  });
9106
9443
  return;
@@ -9130,9 +9467,10 @@ var ChannelDriver = class _ChannelDriver {
9130
9467
  const messages = await this.getSessionMessages(event.sessionId);
9131
9468
  const state = messageRunState(messages, inFlight.opencodeMessageId);
9132
9469
  if (state !== "queued") {
9470
+ this.retainSessionError(event);
9133
9471
  this.log({
9134
- level: "debug",
9135
- message: `Session error for message ${inFlight.evidentMessageId.slice(0, 8)} observed state ${state}; leaving it to transcript polling`,
9472
+ level: "warn",
9473
+ message: `Session error for message ${inFlight.evidentMessageId.slice(0, 8)} in session ${event.sessionId.slice(0, 8)} observed state ${state}; retaining the reason for transcript polling: ${event.reason}`,
9136
9474
  conversation_id: watcher.conv.id,
9137
9475
  message_id: inFlight.evidentMessageId
9138
9476
  });
@@ -9361,7 +9699,14 @@ var ChannelDriver = class _ChannelDriver {
9361
9699
  if (state === "failed") {
9362
9700
  this.anchorDeliveryDeadline(inFlight);
9363
9701
  if (!inFlight.done) {
9364
- const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
9702
+ const messageErrorText = messageError(messages, inFlight.opencodeMessageId);
9703
+ const retainedReason = this.readRetainedSessionError(sessionId);
9704
+ const retainedError = retainedReason === null ? null : `${SESSION_ERROR_RETAINED_DETAIL}${retainedReason}`;
9705
+ const storeError = this.sessionStoreHealth?.isUnwritable() ? SESSION_STORE_UNWRITABLE_ERROR : null;
9706
+ const runnerDetails = [retainedError, storeError].filter(
9707
+ (detail) => detail !== null
9708
+ );
9709
+ const error2 = messageErrorText === null ? runnerDetails.length === 0 ? void 0 : runnerDetails.join(" ") : [messageErrorText, ...runnerDetails].join(" ");
9365
9710
  this.log({
9366
9711
  level: "error",
9367
9712
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -9447,10 +9792,22 @@ var ChannelDriver = class _ChannelDriver {
9447
9792
  inFlight.b2LastDescendantCheckMs = 0;
9448
9793
  inFlight.b2RootOngoingHeldLogged = false;
9449
9794
  inFlight.b2AbandonedSignalled = false;
9795
+ inFlight.b2AbandonedWithoutAnswer = false;
9450
9796
  }
9451
9797
  } else {
9452
9798
  if (inFlight.b2AbandonedSignalled) {
9453
- await this.settleMessageDone(sessionId, watcher, inFlight, messages);
9799
+ if (inFlight.b2AbandonedWithoutAnswer) {
9800
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
9801
+ await this.settleMessageAbandonedWithoutAnswer(
9802
+ sessionId,
9803
+ watcher,
9804
+ inFlight,
9805
+ messages,
9806
+ reply?.info?.finish ?? reply?.finish
9807
+ );
9808
+ } else {
9809
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
9810
+ }
9454
9811
  return;
9455
9812
  }
9456
9813
  if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
@@ -9478,16 +9835,36 @@ var ChannelDriver = class _ChannelDriver {
9478
9835
  return;
9479
9836
  }
9480
9837
  inFlight.b2AbandonedSignalled = true;
9838
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
9839
+ const finish = reply?.info?.finish ?? reply?.finish;
9840
+ if (extractReplyTextForMessage(messages, inFlight.opencodeMessageId) === null) {
9841
+ inFlight.b2AbandonedWithoutAnswer = true;
9842
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
9843
+ watched_for_ms: pinnedForMs,
9844
+ run_state: "failed",
9845
+ finish,
9846
+ ...rootOngoing != null ? { root_ongoing: rootOngoing } : {},
9847
+ ...descendantOngoing != null ? { descendant_ongoing: descendantOngoing } : {},
9848
+ opencode_message_id: inFlight.opencodeMessageId
9849
+ });
9850
+ await this.settleMessageAbandonedWithoutAnswer(
9851
+ sessionId,
9852
+ watcher,
9853
+ inFlight,
9854
+ messages,
9855
+ finish
9856
+ );
9857
+ return;
9858
+ }
9481
9859
  this.log({
9482
9860
  level: "warn",
9483
9861
  message: `Message ${id.slice(0, 8)} b2-pinned for ${Math.round(pinnedForMs / 1e3)}s with no ongoing descendant sub-agent session (status-map confirmed) \u2014 treating the delegated/tool turn as abandoned, resolving done`,
9484
9862
  conversation_id: conv.id,
9485
9863
  message_id: id
9486
9864
  });
9487
- const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
9488
9865
  void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
9489
9866
  watched_for_ms: pinnedForMs,
9490
- finish: reply?.info?.finish ?? reply?.finish,
9867
+ finish,
9491
9868
  ...rootOngoing != null ? { root_ongoing: rootOngoing } : {},
9492
9869
  ...descendantOngoing != null ? { descendant_ongoing: descendantOngoing } : {},
9493
9870
  opencode_message_id: inFlight.opencodeMessageId
@@ -9769,6 +10146,76 @@ var ChannelDriver = class _ChannelDriver {
9769
10146
  }
9770
10147
  this.removeInFlight(watcher, inFlight.evidentMessageId);
9771
10148
  }
10149
+ /** Report a confirmed b2 abandonment that produced no answer text. */
10150
+ async settleMessageAbandonedWithoutAnswer(sessionId, watcher, inFlight, messages, finish) {
10151
+ const conv = watcher.conv;
10152
+ const legacyError = `OpenCode ended this turn after a tool call (finish="${finish ?? "(unknown)"}") without writing an answer, and confirmed it is no longer working on this conversation. Evident is reporting the run as failed so the failure and its reason are visible in failed runs.`;
10153
+ const replyShape = describeAbandonedReplyShape(messages, inFlight.opencodeMessageId);
10154
+ const error2 = replyShape === null ? legacyError : `${legacyError} ${replyShape.lastToolName === null ? "No tool call was recorded" : `The last tool call was "${replyShape.lastToolName}" with status "${replyShape.lastToolStatus ?? "(unknown)"}"`}; the reply's final part types were [${replyShape.partTypes.join(", ")}]${replyShape.droppedPartCount > 0 ? ` (${replyShape.droppedPartCount} earlier part types omitted)` : ""}.`;
10155
+ this.anchorDeliveryDeadline(inFlight);
10156
+ this.log({
10157
+ level: "warn",
10158
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} was abandoned without an answer \u2014 ${error2}`,
10159
+ conversation_id: conv.id,
10160
+ message_id: inFlight.evidentMessageId
10161
+ });
10162
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
10163
+ const usageAgentName = this.usageAgentName(messages ?? [], inFlight.opencodeMessageId);
10164
+ const subagentInvocations = await this.resolveSubagentInvocations(
10165
+ messages,
10166
+ inFlight.opencodeMessageId,
10167
+ inFlight.evidentMessageId
10168
+ );
10169
+ try {
10170
+ await this.markFailed(
10171
+ conv.id,
10172
+ inFlight.evidentMessageId,
10173
+ sessionId,
10174
+ error2,
10175
+ usage,
10176
+ void 0,
10177
+ usageAgentName,
10178
+ subagentInvocations
10179
+ );
10180
+ } catch (err) {
10181
+ if (err instanceof ChannelAuthError) throw err;
10182
+ if (err instanceof ChannelTerminalError) {
10183
+ this.log({
10184
+ level: "warn",
10185
+ message: `Failed to report abandoned no-answer message ${inFlight.evidentMessageId.slice(0, 8)} as failed (terminal HTTP ${err.status}) \u2014 leaving it for the cron safety net: ${err.message}`,
10186
+ conversation_id: conv.id,
10187
+ message_id: inFlight.evidentMessageId
10188
+ });
10189
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
10190
+ return;
10191
+ }
10192
+ if (this.now() >= inFlight.deadline) {
10193
+ this.log({
10194
+ level: "warn",
10195
+ message: `Failed to report abandoned no-answer message ${inFlight.evidentMessageId.slice(0, 8)} as failed within the delivery window \u2014 leaving it for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
10196
+ conversation_id: conv.id,
10197
+ message_id: inFlight.evidentMessageId
10198
+ });
10199
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
10200
+ return;
10201
+ }
10202
+ this.log({
10203
+ level: "warn",
10204
+ message: `Failed to report abandoned no-answer message ${inFlight.evidentMessageId.slice(0, 8)} as failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
10205
+ conversation_id: conv.id,
10206
+ message_id: inFlight.evidentMessageId
10207
+ });
10208
+ return;
10209
+ }
10210
+ inFlight.done = true;
10211
+ await this.reportSubagentAuthFailures(
10212
+ watcher.conv.id,
10213
+ inFlight.opencodeMessageId,
10214
+ inFlight.evidentMessageId,
10215
+ messages
10216
+ );
10217
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
10218
+ }
9772
10219
  /**
9773
10220
  * Report a queued message as failed after the never-started bound. The accepted
9774
10221
  * OpenCode copy is not cancelled, so the local fence remains on unsuccessful
@@ -9831,7 +10278,7 @@ var ChannelDriver = class _ChannelDriver {
9831
10278
  */
9832
10279
  async settleMessageSessionStoreUnwritable(sessionId, watcher, inFlight) {
9833
10280
  const conv = watcher.conv;
9834
- const error2 = "The runner session database could not be written, so this turn did not finish. Nothing was lost from the queue. Clear disk space and retry.";
10281
+ const error2 = SESSION_STORE_UNWRITABLE_ERROR;
9835
10282
  this.log({
9836
10283
  level: "error",
9837
10284
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} was not resolved as done: ${error2}`,
@@ -9922,11 +10369,22 @@ var ChannelDriver = class _ChannelDriver {
9922
10369
  }
9923
10370
  }
9924
10371
  }
9925
- if (this.untrackableAck.size > 0 || this.neverStartedAttempts.size > 0) {
10372
+ if (this.untrackableAck.size > 0 || this.neverStartedAttempts.size > 0 || this.awaitingCorrelation.size > 0) {
9926
10373
  const stillProcessing = new Set(rows.map((r) => r.id));
10374
+ for (const id of this.awaitingCorrelation.keys()) {
10375
+ if (!stillProcessing.has(id) && !this.pendingMessageIds.has(id)) {
10376
+ this.awaitingCorrelation.delete(id);
10377
+ this.log({
10378
+ level: "debug",
10379
+ message: `Re-adopt: message ${id.slice(0, 8)} left processing and pending \u2014 cleared awaiting-correlation fence`,
10380
+ message_id: id
10381
+ });
10382
+ }
10383
+ }
9927
10384
  for (const id of [...this.untrackableAck.keys()]) {
9928
10385
  if (!stillProcessing.has(id) && !this.pendingMessageIds.has(id)) {
9929
10386
  this.untrackableAck.delete(id);
10387
+ this.untrackableAckSessionIds.delete(id);
9930
10388
  this.untrackableAckSignalled.delete(id);
9931
10389
  this.untrackableAckWarned.delete(id);
9932
10390
  this.log({
@@ -9966,7 +10424,7 @@ var ChannelDriver = class _ChannelDriver {
9966
10424
  bySession.set(row.opencode_session_id, list);
9967
10425
  }
9968
10426
  for (const [sessionId, sessionRows] of bySession) {
9969
- let messages;
10427
+ let messages = null;
9970
10428
  try {
9971
10429
  if (this.isV2) {
9972
10430
  const snapshot = await this.getSessionMessages(sessionId);
@@ -9975,9 +10433,9 @@ var ChannelDriver = class _ChannelDriver {
9975
10433
  level: "warn",
9976
10434
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned an unreadable message snapshot \u2014 skipping this session this tick`
9977
10435
  });
9978
- continue;
10436
+ } else {
10437
+ messages = snapshot;
9979
10438
  }
9980
- messages = snapshot;
9981
10439
  } else {
9982
10440
  const polled = await pollSessionMessagesForRedrive(
9983
10441
  this.port,
@@ -9990,15 +10448,22 @@ var ChannelDriver = class _ChannelDriver {
9990
10448
  level: "warn",
9991
10449
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned ${polled.malformed ? "a non-array message body" : `HTTP ${polled.status ?? "a network error"}${normalized ? `: ${normalized}` : ""}`} \u2014 skipping this session this tick`
9992
10450
  });
9993
- continue;
10451
+ } else {
10452
+ messages = polled.messages;
9994
10453
  }
9995
- messages = polled.messages;
9996
10454
  }
9997
10455
  } catch (err) {
9998
10456
  this.log({
9999
10457
  level: "warn",
10000
10458
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
10001
10459
  });
10460
+ }
10461
+ if (messages === null) {
10462
+ for (const row of sessionRows) {
10463
+ if (this.awaitingCorrelation.has(row.id)) {
10464
+ await this.advanceReadoptCorrelation(sessionId, row, null);
10465
+ }
10466
+ }
10002
10467
  continue;
10003
10468
  }
10004
10469
  const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
@@ -10008,6 +10473,59 @@ var ChannelDriver = class _ChannelDriver {
10008
10473
  }
10009
10474
  }
10010
10475
  }
10476
+ /** Advance a re-adopted accepted prompt through the shared correlation fence. */
10477
+ async advanceReadoptCorrelation(sessionId, row, messages) {
10478
+ const correlation = this.awaitingCorrelation.get(row.id);
10479
+ if (!correlation) return false;
10480
+ correlation.attempts += 1;
10481
+ const correlatedId = messages === null ? null : this.findAwaitingCorrelationId(correlation, sessionId, messages);
10482
+ if (correlatedId !== null) {
10483
+ this.awaitingCorrelation.delete(row.id);
10484
+ const conv = this.convForRow(sessionId, row);
10485
+ const message = this.queuedMessageForRow(row);
10486
+ const processedAtMs = this.processedAtMs(row);
10487
+ this.registerReadopted(conv, sessionId, message, correlatedId, processedAtMs, processedAtMs);
10488
+ this.dispatched.add(row.id);
10489
+ this.readopted.add(row.id);
10490
+ this.ensureWatcherRunning(sessionId);
10491
+ this.log({
10492
+ level: "info",
10493
+ message: `Re-adopt: matched accepted message ${row.id.slice(0, 8)} to OpenCode turn ${correlatedId.slice(0, 8)} without sending it again`,
10494
+ conversation_id: row.conversation_id,
10495
+ message_id: row.id
10496
+ });
10497
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
10498
+ void this.postSignal(row.conversation_id, row.id, "ack_correlated", {
10499
+ opencode_message_id: correlatedId
10500
+ });
10501
+ return true;
10502
+ }
10503
+ if (correlation.knownUserIds === null) {
10504
+ this.log({
10505
+ level: "debug",
10506
+ message: `Re-adopt: message ${row.id.slice(0, 8)} cannot be correlated because its pre-dispatch session list was unreadable`,
10507
+ conversation_id: row.conversation_id,
10508
+ message_id: row.id
10509
+ });
10510
+ } else if (correlation.sessionId !== sessionId) {
10511
+ this.log({
10512
+ level: "warn",
10513
+ message: `Re-adopt: message ${row.id.slice(0, 8)} moved from original session ${correlation.sessionId.slice(0, 8)} to ${sessionId.slice(0, 8)} while awaiting correlation \u2014 refusing to adopt a turn from the rebound session`,
10514
+ conversation_id: row.conversation_id,
10515
+ message_id: row.id
10516
+ });
10517
+ }
10518
+ if (correlation.attempts >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
10519
+ await this.handoffUntrackableCorrelation(
10520
+ row.conversation_id,
10521
+ row.id,
10522
+ correlation.sessionId,
10523
+ correlation.attempts,
10524
+ "readopt"
10525
+ );
10526
+ }
10527
+ return true;
10528
+ }
10011
10529
  /**
10012
10530
  * Re-adopt ONE `processing` row against the tick's session message snapshot
10013
10531
  * (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
@@ -10015,8 +10533,10 @@ var ChannelDriver = class _ChannelDriver {
10015
10533
  * Branches on `messageRunState(messages, row.opencode_message_id)` — the
10016
10534
  * opencode-assigned user-message id persisted on the first `processing` PATCH
10017
10535
  * (#218). A row with a NULL stored id (dispatched but the read-back never landed
10018
- * before the restart) has no id to correlate → treated as an orphan and
10019
- * re-dispatched (at most once, see `forceReadoptRun`):
10536
+ * before the restart) is first offered to an awaiting-correlation fence recorded
10537
+ * by this process; without that fence, it has no id to correlate after restart
10538
+ * and is treated as an orphan and re-dispatched (at most once, see
10539
+ * `forceReadoptRun`):
10020
10540
  * - `done` → `markDone` now (guarded like the watcher's done branch);
10021
10541
  * - `failed` → `markFailed` with the surfaced error (issue #182), so an
10022
10542
  * errored turn is reported failed on restart, NOT re-dispatched —
@@ -10025,7 +10545,8 @@ var ChannelDriver = class _ChannelDriver {
10025
10545
  * re-dispatched instead (issue #1310, see the branch below);
10026
10546
  * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
10027
10547
  * tracking the stored id so the reply correlates by it;
10028
- * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
10548
+ * - `unknown`/null id → correlate through an awaiting fence when present;
10549
+ * otherwise re-dispatch (opencode assigns a fresh id) + attach a watcher.
10029
10550
  *
10030
10551
  * Only `ChannelAuthError` propagates.
10031
10552
  */
@@ -10039,6 +10560,7 @@ var ChannelDriver = class _ChannelDriver {
10039
10560
  });
10040
10561
  return;
10041
10562
  }
10563
+ if (await this.advanceReadoptCorrelation(sessionId, row, messages)) return;
10042
10564
  const ocId = row.opencode_message_id;
10043
10565
  const state = messageRunState(messages, ocId ?? "");
10044
10566
  if (state === "done") {
@@ -10105,12 +10627,48 @@ var ChannelDriver = class _ChannelDriver {
10105
10627
  await this.reportSubagentAuthFailures(row.conversation_id, ocId ?? "", row.id, messages);
10106
10628
  this.dontRedispatch.delete(row.id);
10107
10629
  this.untrackableAck.delete(row.id);
10630
+ this.untrackableAckSessionIds.delete(row.id);
10108
10631
  this.untrackableAckSignalled.delete(row.id);
10109
10632
  this.untrackableAckWarned.delete(row.id);
10110
10633
  void this.postSignal(row.conversation_id, row.id, "readopt_failed");
10111
10634
  return;
10112
10635
  }
10113
- if (this.dontRedispatch.has(row.id) || this.untrackableAck.has(row.id)) {
10636
+ if (this.untrackableAck.has(row.id)) {
10637
+ if (this.untrackableAckSignalled.has(row.id)) {
10638
+ const firstFenceWarning = !this.untrackableAckWarned.has(row.id);
10639
+ this.untrackableAckWarned.add(row.id);
10640
+ this.log({
10641
+ level: firstFenceWarning ? "warn" : "debug",
10642
+ message: `Re-adopt: message ${row.id.slice(0, 8)} is fenced after an untrackable OpenCode turn \u2014 skipping re-dispatch and retrying the terminal report`,
10643
+ conversation_id: row.conversation_id,
10644
+ message_id: row.id
10645
+ });
10646
+ const errorMessage3 = this.untrackableAck.get(row.id);
10647
+ const reportError = await this.reportUntrackableAck(
10648
+ row.conversation_id,
10649
+ row.id,
10650
+ this.untrackableAckSessionIds.get(row.id) ?? null,
10651
+ errorMessage3
10652
+ );
10653
+ if (reportError !== null) {
10654
+ this.log({
10655
+ level: "warn",
10656
+ message: `Re-adopt: failed to report untrackable message ${row.id.slice(0, 8)} (will retry next drain): ${reportError.message}`,
10657
+ conversation_id: row.conversation_id,
10658
+ message_id: row.id
10659
+ });
10660
+ }
10661
+ } else {
10662
+ this.log({
10663
+ level: "debug",
10664
+ message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
10665
+ conversation_id: row.conversation_id,
10666
+ message_id: row.id
10667
+ });
10668
+ }
10669
+ return;
10670
+ }
10671
+ if (this.dontRedispatch.has(row.id)) {
10114
10672
  this.log({
10115
10673
  level: "debug",
10116
10674
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
@@ -10288,6 +10846,7 @@ var ChannelDriver = class _ChannelDriver {
10288
10846
  }
10289
10847
  this.dontRedispatch.delete(row.id);
10290
10848
  this.untrackableAck.delete(row.id);
10849
+ this.untrackableAckSessionIds.delete(row.id);
10291
10850
  this.untrackableAckSignalled.delete(row.id);
10292
10851
  this.untrackableAckWarned.delete(row.id);
10293
10852
  void this.postSignal(row.conversation_id, row.id, "readopt_done");
@@ -10307,8 +10866,9 @@ var ChannelDriver = class _ChannelDriver {
10307
10866
  * latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
10308
10867
  * short-circuit while the row is latched; clear it on a successful dispatch (the
10309
10868
  * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
10310
- * re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
10311
- * may retry exactly once more).
10869
+ * re-entry) or when the dispatch itself failed before acceptance. An accepted V1
10870
+ * dispatch whose id is not readable enters the bounded correlation fence instead
10871
+ * of retrying; exhaustion hands it to `untrackableAck`.
10312
10872
  *
10313
10873
  * `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
10314
10874
  * `processed_at` (Invariant 1).
@@ -10371,6 +10931,7 @@ var ChannelDriver = class _ChannelDriver {
10371
10931
  );
10372
10932
  }
10373
10933
  let ocId;
10934
+ let preDispatchUserIds = null;
10374
10935
  try {
10375
10936
  ocId = this.isV2 ? await sendV2Prompt(this.openCodeClient, sessionId, row.content) : await this.dispatchLocked(
10376
10937
  sessionId,
@@ -10380,7 +10941,20 @@ var ChannelDriver = class _ChannelDriver {
10380
10941
  row.content,
10381
10942
  options,
10382
10943
  sendAttachments,
10383
- this.openCodeClient
10944
+ this.openCodeClient,
10945
+ {
10946
+ onKnownUserIds: (ids) => {
10947
+ preDispatchUserIds = ids;
10948
+ },
10949
+ onKnownUserIdsError: (error2) => {
10950
+ this.log({
10951
+ level: "warn",
10952
+ message: `Pre-dispatch snapshot callback failed for message ${row.id.slice(0, 8)}: ${error2 instanceof Error ? error2.message : String(error2)}`,
10953
+ conversation_id: row.conversation_id,
10954
+ message_id: row.id
10955
+ });
10956
+ }
10957
+ }
10384
10958
  )
10385
10959
  );
10386
10960
  } catch (err) {
@@ -10413,6 +10987,7 @@ var ChannelDriver = class _ChannelDriver {
10413
10987
  }
10414
10988
  } else {
10415
10989
  this.untrackableAck.set(row.id, errorMessage3);
10990
+ this.untrackableAckSessionIds.set(row.id, sessionId);
10416
10991
  }
10417
10992
  this.log({
10418
10993
  level: "error",
@@ -10461,39 +11036,20 @@ var ChannelDriver = class _ChannelDriver {
10461
11036
  }
10462
11037
  if (ocId === null) {
10463
11038
  this.awaitingReadopt.delete(row.id);
10464
- const streak = this.recordUnconfirmedDispatch(row.id, sessionId);
10465
- if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
10466
- this.unconfirmedDispatchFailures.delete(row.id);
10467
- this.sessions.delete(readoptConv.id);
10468
- this.supersede(readoptConv.id, sessionId);
10469
- const errorMessage3 = `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.`;
10470
- this.log({
10471
- level: "error",
10472
- message: errorMessage3,
10473
- conversation_id: row.conversation_id,
10474
- message_id: row.id
10475
- });
10476
- await this.markFailed(row.conversation_id, row.id, null, errorMessage3).catch((markErr) => {
10477
- this.log({
10478
- level: "warn",
10479
- 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)}`,
10480
- conversation_id: row.conversation_id,
10481
- message_id: row.id
10482
- });
10483
- });
10484
- void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
10485
- return;
10486
- }
11039
+ this.awaitingCorrelation.set(row.id, {
11040
+ sessionId,
11041
+ content: row.content,
11042
+ attempts: 0,
11043
+ knownUserIds: preDispatchUserIds
11044
+ });
10487
11045
  this.log({
10488
11046
  level: "warn",
10489
- 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`,
11047
+ message: `Re-adopt: OpenCode accepted message ${row.id.slice(0, 8)}, but its assigned id could not be read back \u2014 the message will not be sent again while the runner matches it to the turn already running`,
10490
11048
  conversation_id: row.conversation_id,
10491
11049
  message_id: row.id
10492
11050
  });
10493
- void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
10494
11051
  return;
10495
11052
  }
10496
- this.unconfirmedDispatchFailures.delete(row.id);
10497
11053
  const processedAtMs = this.processedAtMs(row);
10498
11054
  this.registerReadopted(
10499
11055
  readoptConv,
@@ -13972,6 +14528,34 @@ async function run(options) {
13972
14528
  } else {
13973
14529
  log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
13974
14530
  }
14531
+ if (options.restoreRunnerCredentials || options.credentialSyncMarker) {
14532
+ const logCredentialVaultResolution = (message, level) => {
14533
+ log2(state, message, level);
14534
+ if (state.interactive && !state.json) {
14535
+ logActivity(state, { type: "info", level, message });
14536
+ }
14537
+ };
14538
+ const vaultResolution = await getRunnerCredentialVault(state.agentId, state.authHeader);
14539
+ if (vaultResolution.error) {
14540
+ delete process.env.CREDENTIAL_VAULT_PREFIX;
14541
+ logCredentialVaultResolution(
14542
+ `CREDENTIAL-VAULT-LOOKUP-FAILED: ${vaultResolution.error}; credentials will be read from and written to this runner's own prefix this boot`,
14543
+ "warn"
14544
+ );
14545
+ } else if (vaultResolution.prefix !== null && vaultResolution.vaultId !== null && vaultResolution.source !== null) {
14546
+ process.env.CREDENTIAL_VAULT_PREFIX = vaultResolution.prefix;
14547
+ logCredentialVaultResolution(
14548
+ `CREDENTIAL-VAULT: source=${vaultResolution.source} vault=${vaultResolution.vaultId} prefix=${vaultResolution.prefix}`,
14549
+ "info"
14550
+ );
14551
+ } else {
14552
+ delete process.env.CREDENTIAL_VAULT_PREFIX;
14553
+ logCredentialVaultResolution(
14554
+ "CREDENTIAL-VAULT-NONE: this runner is not linked to a credential vault; using its own prefix",
14555
+ "debug"
14556
+ );
14557
+ }
14558
+ }
13975
14559
  if (options.restoreRunnerCredentials) {
13976
14560
  log2(state, "Restoring runner credentials before starting OpenCode");
13977
14561
  const credentialContext = {
@@ -14005,6 +14589,7 @@ async function run(options) {
14005
14589
  openCodeLogTail.stop();
14006
14590
  };
14007
14591
  let sessionDbVerifyFatal = false;
14592
+ let sessionDbVerifyFatalReason;
14008
14593
  if (!options.restoreSessionDb) {
14009
14594
  log2(state, "Skipping session-DB restore: --restore-session-db was not passed", "debug");
14010
14595
  } else {
@@ -14037,12 +14622,13 @@ async function run(options) {
14037
14622
  reportRecovery: (record) => reportSessionDbRecoveryRecord(state, record)
14038
14623
  });
14039
14624
  sessionDbVerifyFatal = result.verifyFatal;
14625
+ sessionDbVerifyFatalReason = result.verifyFatalReason;
14040
14626
  }
14041
14627
  }
14042
14628
  reportSessionDbRecovery(state);
14043
14629
  if (sessionDbVerifyFatal) {
14044
14630
  throw new Error(
14045
- "SESSION-DB-INTEGRITY-EXHAUSTED: the corrupt session DB could not be proven separated from the active backup prefix or removed from disk"
14631
+ sessionDbVerifyFatalReason === void 0 ? "SESSION-DB-INTEGRITY-EXHAUSTED: the corrupt session DB could not be proven separated from the active backup prefix or removed from disk" : `SESSION-DB-VERIFY-TIMEOUT: session DB verification timed out (${sessionDbVerifyFatalReason}); refusing to start`
14046
14632
  );
14047
14633
  }
14048
14634
  applyRunnerOpenCodeConfig({
@@ -14369,14 +14955,14 @@ async function run(options) {
14369
14955
  },
14370
14956
  onDisconnected: (code, reason, transition) => {
14371
14957
  state.connected = false;
14372
- const safeReason = redactDiagnosticText(reason).slice(0, MAX_DISCONNECT_REASON_LENGTH);
14958
+ const safeReason2 = redactDiagnosticText(reason).slice(0, MAX_DISCONNECT_REASON_LENGTH);
14373
14959
  logActivity(state, {
14374
14960
  type: "info",
14375
- message: `Tunnel disconnected (generation: ${transition.generation}, code: ${code}, reason: ${safeReason})`
14961
+ message: `Tunnel disconnected (generation: ${transition.generation}, code: ${code}, reason: ${safeReason2})`
14376
14962
  });
14377
14963
  emitAgentDisconnected(state.agentId, {
14378
14964
  code,
14379
- reason: safeReason,
14965
+ reason: safeReason2,
14380
14966
  connection_generation: transition.generation
14381
14967
  });
14382
14968
  if (state.interactive) displayStatus(state);
@@ -14539,6 +15125,7 @@ program.command("login").description("Authenticate with Evident").option("--toke
14539
15125
  program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
14540
15126
  program.command("whoami").description("Show the currently logged in user").action(whoami);
14541
15127
  program.command("status").description("Check whether the configured credentials can reach Evident").option("--json", "Output in JSON format").action((options) => status({ json: options.json }));
15128
+ program.command("credential-vault-env").description("Print the shell assignment a runner image evals before starting the synchroniser").action(credentialVaultEnv);
14542
15129
  program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
14543
15130
  program.command("run").description("Connect to Evident and process messages").option("--runner [id]", "Runner ID to connect to (optional when EVIDENT_RUNNER_KEY is set)").option(
14544
15131
  "-a, --agent [id]",