@evident-ai/cli 3.0.1-dev.39d9b5f → 3.0.1-dev.3e59bb5

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
@@ -1078,6 +1078,12 @@ async function getSessionMessages(port, sessionId) {
1078
1078
  return null;
1079
1079
  }
1080
1080
  }
1081
+ function isSessionActivelyGenerating(messages) {
1082
+ if (!messages || messages.length === 0) return false;
1083
+ const last = messages[messages.length - 1];
1084
+ if (roleOf(last) !== "assistant") return false;
1085
+ return completedOf(last) == null;
1086
+ }
1081
1087
  function sessionLastActivityMs(session) {
1082
1088
  const candidates = [
1083
1089
  session.time?.updated,
@@ -1120,6 +1126,36 @@ async function sessionExists(port, id) {
1120
1126
  return null;
1121
1127
  }
1122
1128
  }
1129
+ async function getSessionStatuses(port) {
1130
+ try {
1131
+ const res = await fetch(`${opencodeBase(port)}/session/status`);
1132
+ if (!res.ok) {
1133
+ console.error(
1134
+ `[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
1135
+ );
1136
+ return null;
1137
+ }
1138
+ const body = await res.json();
1139
+ if (body == null || typeof body !== "object" || Array.isArray(body)) {
1140
+ console.error(
1141
+ `[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
1142
+ );
1143
+ return null;
1144
+ }
1145
+ return body;
1146
+ } catch (err) {
1147
+ console.error(
1148
+ `[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1149
+ );
1150
+ return null;
1151
+ }
1152
+ }
1153
+ async function isSessionOngoing(port, id) {
1154
+ const map = await getSessionStatuses(port);
1155
+ if (map == null) return null;
1156
+ const entry = map[id];
1157
+ return entry != null && entry.type !== "idle";
1158
+ }
1123
1159
  async function createOpenCodeSession(port, directory) {
1124
1160
  const url = new URL(`${opencodeBase(port)}/session`);
1125
1161
  if (directory && directory.trim()) {
@@ -1246,6 +1282,11 @@ function messageRunState(messages, userMessageId) {
1246
1282
  if (isAssistantInFlight(reply)) return "running";
1247
1283
  return errorOf(reply) != null ? "failed" : "done";
1248
1284
  }
1285
+ function isPreamblePinnedRunning(messages, userMessageId) {
1286
+ if (messageRunState(messages, userMessageId) !== "running") return false;
1287
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1288
+ return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1289
+ }
1249
1290
  function messageError(messages, userMessageId) {
1250
1291
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1251
1292
  const error2 = errorOf(reply);
@@ -1779,6 +1820,7 @@ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1779
1820
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1780
1821
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1781
1822
  var HEARTBEAT_MS = 6e4;
1823
+ var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
1782
1824
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
1783
1825
  var ChannelAuthError = class extends Error {
1784
1826
  constructor(message) {
@@ -1876,6 +1918,15 @@ var ChannelDriver = class {
1876
1918
  * the row leaves the processing list, exactly like `dontRedispatch`.
1877
1919
  */
1878
1920
  doneUndeliverable = /* @__PURE__ */ new Set();
1921
+ /**
1922
+ * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
1923
+ * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
1924
+ * every ~2s drain until the status map becomes readable — but the server-visible
1925
+ * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
1926
+ * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
1927
+ * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
1928
+ */
1929
+ readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
1879
1930
  /**
1880
1931
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1881
1932
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -2263,6 +2314,7 @@ var ChannelDriver = class {
2263
2314
  opencodeMessageId,
2264
2315
  message,
2265
2316
  dispatchedAt: now,
2317
+ processingAnchorMs: now,
2266
2318
  deadline: now + this.pausedMaxWaitMs,
2267
2319
  started: false,
2268
2320
  done: false,
@@ -2321,6 +2373,10 @@ var ChannelDriver = class {
2321
2373
  opencodeMessageId,
2322
2374
  message,
2323
2375
  dispatchedAt: this.now(),
2376
+ // Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
2377
+ // value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
2378
+ // reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
2379
+ processingAnchorMs: processedAtMs,
2324
2380
  deadline: processedAtMs + this.pausedMaxWaitMs,
2325
2381
  // The server row is ALREADY `processing`; do not re-fire markProcessing.
2326
2382
  started: true,
@@ -2609,6 +2665,19 @@ var ChannelDriver = class {
2609
2665
  });
2610
2666
  }
2611
2667
  const activelyRunning = state === "running" && !awaitingHuman;
2668
+ if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2669
+ this.log({
2670
+ level: "error",
2671
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
2672
+ conversation_id: conv.id,
2673
+ message_id: inFlight.evidentMessageId
2674
+ });
2675
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2676
+ watched_for_ms: this.now() - inFlight.processingAnchorMs
2677
+ });
2678
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2679
+ return;
2680
+ }
2612
2681
  if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
2613
2682
  inFlight.aliveInFlight = true;
2614
2683
  void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
@@ -2671,12 +2740,17 @@ var ChannelDriver = class {
2671
2740
  */
2672
2741
  async readoptProcessing() {
2673
2742
  const rows = await this.getProcessingMessages();
2674
- if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
2743
+ if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
2675
2744
  const stillProcessing = new Set(rows.map((r) => r.id));
2676
- for (const id of [...this.dontRedispatch, ...this.doneUndeliverable]) {
2745
+ for (const id of [
2746
+ ...this.dontRedispatch,
2747
+ ...this.doneUndeliverable,
2748
+ ...this.readoptPollUnresolvedSignalled
2749
+ ]) {
2677
2750
  if (!stillProcessing.has(id)) {
2678
2751
  const cleared = this.dontRedispatch.delete(id);
2679
2752
  const clearedUndeliverable = this.doneUndeliverable.delete(id);
2753
+ this.readoptPollUnresolvedSignalled.delete(id);
2680
2754
  if (cleared || clearedUndeliverable) {
2681
2755
  this.log({
2682
2756
  level: "info",
@@ -2730,8 +2804,10 @@ var ChannelDriver = class {
2730
2804
  });
2731
2805
  continue;
2732
2806
  }
2807
+ const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
2808
+ const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
2733
2809
  for (const row of sessionRows) {
2734
- await this.readoptOne(sessionId, row, messages);
2810
+ await this.readoptOne(sessionId, row, messages, sessionOngoing);
2735
2811
  }
2736
2812
  }
2737
2813
  }
@@ -2753,7 +2829,7 @@ var ChannelDriver = class {
2753
2829
  *
2754
2830
  * Only `ChannelAuthError` propagates.
2755
2831
  */
2756
- async readoptOne(sessionId, row, messages) {
2832
+ async readoptOne(sessionId, row, messages, sessionOngoing) {
2757
2833
  if (this.isTracked(sessionId, row.id)) {
2758
2834
  this.log({
2759
2835
  level: "info",
@@ -2793,6 +2869,7 @@ var ChannelDriver = class {
2793
2869
  conversation_id: row.conversation_id,
2794
2870
  message_id: row.id
2795
2871
  });
2872
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2796
2873
  return;
2797
2874
  }
2798
2875
  this.log({
@@ -2804,6 +2881,7 @@ var ChannelDriver = class {
2804
2881
  return;
2805
2882
  }
2806
2883
  this.dontRedispatch.delete(row.id);
2884
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
2807
2885
  return;
2808
2886
  }
2809
2887
  if (state === "failed") {
@@ -2826,6 +2904,7 @@ var ChannelDriver = class {
2826
2904
  conversation_id: row.conversation_id,
2827
2905
  message_id: row.id
2828
2906
  });
2907
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2829
2908
  return;
2830
2909
  }
2831
2910
  this.log({
@@ -2837,6 +2916,7 @@ var ChannelDriver = class {
2837
2916
  return;
2838
2917
  }
2839
2918
  this.dontRedispatch.delete(row.id);
2919
+ void this.postSignal(row.conversation_id, row.id, "readopt_failed");
2840
2920
  return;
2841
2921
  }
2842
2922
  if (this.dontRedispatch.has(row.id)) {
@@ -2848,6 +2928,71 @@ var ChannelDriver = class {
2848
2928
  });
2849
2929
  return;
2850
2930
  }
2931
+ let statusReadableOngoing = null;
2932
+ if (state === "running" && ocId) {
2933
+ const reply = findLastAssistantReplyFor(messages, ocId);
2934
+ const shape = this.replyCompletionShape(reply);
2935
+ const ongoing = sessionOngoing;
2936
+ statusReadableOngoing = ongoing;
2937
+ if (ongoing === false) {
2938
+ this.log({
2939
+ level: "info",
2940
+ 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)`,
2941
+ conversation_id: row.conversation_id,
2942
+ message_id: row.id
2943
+ });
2944
+ await this.forceReadoptRun(sessionId, row);
2945
+ return;
2946
+ }
2947
+ if (ongoing === true) {
2948
+ this.log({
2949
+ level: "info",
2950
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
2951
+ conversation_id: row.conversation_id,
2952
+ message_id: row.id
2953
+ });
2954
+ } else {
2955
+ if (shape === "b1") {
2956
+ this.log({
2957
+ level: "info",
2958
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
2959
+ conversation_id: row.conversation_id,
2960
+ message_id: row.id
2961
+ });
2962
+ if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
2963
+ this.readoptPollUnresolvedSignalled.add(row.id);
2964
+ void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
2965
+ }
2966
+ return;
2967
+ }
2968
+ this.log({
2969
+ level: "info",
2970
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
2971
+ conversation_id: row.conversation_id,
2972
+ message_id: row.id
2973
+ });
2974
+ }
2975
+ }
2976
+ if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
2977
+ const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2978
+ if (descendantAlive === true) {
2979
+ this.log({
2980
+ level: "info",
2981
+ message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
2982
+ conversation_id: row.conversation_id,
2983
+ message_id: row.id
2984
+ });
2985
+ } else {
2986
+ this.log({
2987
+ level: "info",
2988
+ message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned on recovery (root ${sessionId.slice(0, 8)}), no live descendant runner \u2014 re-dispatching from scratch${descendantAlive === null ? " (descendant liveness indeterminate; a restart guarantees no live runner, so this does NOT block the re-dispatch)" : ""}`,
2989
+ conversation_id: row.conversation_id,
2990
+ message_id: row.id
2991
+ });
2992
+ await this.forceReadoptRun(sessionId, row);
2993
+ return;
2994
+ }
2995
+ }
2851
2996
  if ((state === "running" || state === "queued") && ocId) {
2852
2997
  const conv = this.convForRow(sessionId, row);
2853
2998
  const message = this.queuedMessageForRow(row);
@@ -2861,6 +3006,7 @@ var ChannelDriver = class {
2861
3006
  conversation_id: row.conversation_id,
2862
3007
  message_id: row.id
2863
3008
  });
3009
+ void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
2864
3010
  return;
2865
3011
  }
2866
3012
  await this.forceReadoptRun(sessionId, row);
@@ -2913,6 +3059,7 @@ var ChannelDriver = class {
2913
3059
  conversation_id: row.conversation_id,
2914
3060
  message_id: row.id
2915
3061
  });
3062
+ void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
2916
3063
  return;
2917
3064
  }
2918
3065
  const options = {
@@ -2941,6 +3088,7 @@ var ChannelDriver = class {
2941
3088
  conversation_id: row.conversation_id,
2942
3089
  message_id: row.id
2943
3090
  });
3091
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2944
3092
  return;
2945
3093
  }
2946
3094
  if (ocId === null) {
@@ -2951,6 +3099,7 @@ var ChannelDriver = class {
2951
3099
  conversation_id: row.conversation_id,
2952
3100
  message_id: row.id
2953
3101
  });
3102
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2954
3103
  return;
2955
3104
  }
2956
3105
  const conv = this.convForRow(sessionId, row);
@@ -2960,6 +3109,7 @@ var ChannelDriver = class {
2960
3109
  this.readopted.add(row.id);
2961
3110
  this.awaitingReadopt.delete(row.id);
2962
3111
  this.ensureWatcherRunning(sessionId);
3112
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
2963
3113
  }
2964
3114
  /**
2965
3115
  * True if `evidentMessageId` is already being driven — either in the
@@ -3169,6 +3319,83 @@ var ChannelDriver = class {
3169
3319
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3170
3320
  return parent;
3171
3321
  }
3322
+ /**
3323
+ * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3324
+ * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
3325
+ *
3326
+ * The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
3327
+ * runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
3328
+ * completed `finish: "tool-calls"` root reply encountered during re-adoption is
3329
+ * idle by OpenCode's own definition and is re-dispatched. This method exists only
3330
+ * so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
3331
+ * provably in flight at the exact moment of recovery.
3332
+ *
3333
+ * "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
3334
+ * ACTIVELY generating — its LAST message is an assistant still mid-generation
3335
+ * (`completed == null`, via `isSessionActivelyGenerating`). An
3336
+ * INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
3337
+ * completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
3338
+ * is generating once the runner is gone), so it does NOT veto. (This is
3339
+ * deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
3340
+ * shapes and would falsely veto — re-hanging the very turn this path recovers.)
3341
+ *
3342
+ * Return contract (encoded so WI-3 need not re-derive it):
3343
+ * - `true` → a descendant is provably, actively generating (veto re-dispatch).
3344
+ * - `false` → descendants exist but none is actively generating (the restart
3345
+ * case), OR no descendant is found at all.
3346
+ * - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
3347
+ *
3348
+ * ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
3349
+ * as `false` and does NOT veto — a restart guarantees no live runner, so an
3350
+ * indeterminate cross-check almost always means "couldn't reach a child that no
3351
+ * longer exists". The inversion lives in the caller; this method just reports
3352
+ * true/false/null faithfully.
3353
+ *
3354
+ * VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
3355
+ * (already proven by the existing child-session interaction tests, via
3356
+ * `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
3357
+ * terminal state. We do NOT depend on any session-level `busy`/`idle` field —
3358
+ * there is none on `GET /session/:id`; OpenCode's busy state is in-memory
3359
+ * `SessionStatus` only.
3360
+ */
3361
+ async isAnyDescendantSessionAlive(rootSessionId) {
3362
+ const sessions = await listSessions(this.port);
3363
+ if (!sessions) {
3364
+ this.log({
3365
+ level: "error",
3366
+ message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3367
+ });
3368
+ return null;
3369
+ }
3370
+ for (const candidate of sessions) {
3371
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
3372
+ if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
3373
+ const childMsgs = await getSessionMessages(this.port, candidate.id);
3374
+ if (isSessionActivelyGenerating(childMsgs)) {
3375
+ return true;
3376
+ }
3377
+ }
3378
+ return false;
3379
+ }
3380
+ /**
3381
+ * Cheap decision-telemetry label for a running row's LAST correlated reply
3382
+ * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
3383
+ * - `b1` — the reply itself is still in flight (`time.completed == null`) —
3384
+ * the aborted-in-flight production bug after a restart.
3385
+ * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
3386
+ * (the sub-agent preamble — #253's shape).
3387
+ * - `other` — any other shape (defensive; a running row is normally b1 or b2).
3388
+ * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
3389
+ * shape) directly rather than re-importing the module-private `completedOf`/
3390
+ * `finishOf` — this is a display label only, not a correctness predicate.
3391
+ */
3392
+ replyCompletionShape(reply) {
3393
+ if (!reply) return "other";
3394
+ const completed = reply.info?.time?.completed ?? reply.time?.completed;
3395
+ if (completed == null) return "b1";
3396
+ const finish = reply.info?.finish ?? reply.finish;
3397
+ return finish === "tool-calls" ? "b2" : "other";
3398
+ }
3172
3399
  /**
3173
3400
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
3174
3401
  *