@evident-ai/cli 3.1.1-dev.1c351d6 → 3.1.1-dev.1e270a5

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
@@ -3141,6 +3141,7 @@ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
3141
3141
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
3142
3142
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
3143
3143
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
3144
+ var MAX_IDENTICAL_REDRIVE_POLL_FAILURES = 5;
3144
3145
  var ChannelAuthError = class extends Error {
3145
3146
  constructor(message) {
3146
3147
  super(message);
@@ -3163,6 +3164,10 @@ function backoffDelay(attempt, policy) {
3163
3164
  function isRetryableStatus(status2) {
3164
3165
  return status2 === 429 || status2 >= 500 && status2 <= 599;
3165
3166
  }
3167
+ var VOLATILE_BODY_FIELD_PATTERN = /("(?:ref|requestId|request_id|traceId|trace_id)"\s*:\s*)"[^"]*"/gi;
3168
+ function normalizeRedrivePollFailureBody(body) {
3169
+ return body.replace(VOLATILE_BODY_FIELD_PATTERN, '$1"<redacted>"').replace(/\s+/g, " ").trim().slice(0, 200);
3170
+ }
3166
3171
  var ChannelDriver = class _ChannelDriver {
3167
3172
  agentId;
3168
3173
  port;
@@ -3294,6 +3299,40 @@ var ChannelDriver = class _ChannelDriver {
3294
3299
  * ADR-0047's own "unreachable ⇒ bounded" rule). Cleared on any other outcome.
3295
3300
  */
3296
3301
  redriveUnresolvedSince = /* @__PURE__ */ new Map();
3302
+ /**
3303
+ * Consecutive-identical-poll-failure streak for the re-drive fence (#1348),
3304
+ * keyed by Evident **message id** (not session) so `clearRedriveUnresolved`
3305
+ * can drop it with the other two trackers and it cannot leak. `sessionId` is
3306
+ * carried inside the entry, not the key: a session change is a different
3307
+ * situation and resets the streak, which gives the `(sessionId, message.id)`
3308
+ * pairing #1348 asks for without a composite map key.
3309
+ */
3310
+ redrivePollFailures = /* @__PURE__ */ new Map();
3311
+ /**
3312
+ * "Already emitted `redrive_outcome_unreported` for THIS (message, outcome)
3313
+ * streak" (Class B, #1340: the runner DECIDED reattach/settle/fail_permanent
3314
+ * but its own PATCH to record it failed — distinct from Class A's
3315
+ * `redrive_poll_failed`, where opencode itself can't be observed). Keyed by
3316
+ * message id, valued by the outcome currently failing to report, so a
3317
+ * change of outcome starts a fresh signal. Cleared by
3318
+ * `clearRedriveUnresolved` the instant either PATCH succeeds.
3319
+ */
3320
+ redriveOutcomeUnreportedSignalled = /* @__PURE__ */ new Map();
3321
+ /**
3322
+ * Consecutive-UNCONFIRMED-dispatch streak for a `pending` row with NO stored
3323
+ * `opencode_message_id` yet — i.e. one that has never even reached the
3324
+ * re-drive fence above. `sendPromptAsync`'s POST may 2xx, but its own
3325
+ * read-back retries can never confirm the assigned id when the session's
3326
+ * message list is PERMANENTLY unreadable (e.g. a corrupted local opencode
3327
+ * SQLite DB, #1345/#1348's exact fault, just hit BEFORE the row is ever
3328
+ * dispatched instead of after). Unlike an already-dispatched row, THIS row has
3329
+ * no other safety net at all: the lifecycle cron only reclaims `status =
3330
+ * 'processing'` rows, and a row stuck here never reaches `processing`. Keyed
3331
+ * by message id, carrying `sessionId` so a session change (a fresh one bound
3332
+ * after abandonment) starts a new streak rather than inheriting the old
3333
+ * session's count — same shape as `redrivePollFailures` above.
3334
+ */
3335
+ unconfirmedDispatchFailures = /* @__PURE__ */ new Map();
3297
3336
  /**
3298
3337
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
3299
3338
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -3618,7 +3657,7 @@ var ChannelDriver = class _ChannelDriver {
3618
3657
  * @returns the count of messages NEWLY dispatched (not already in-flight).
3619
3658
  */
3620
3659
  async processConversation(conv) {
3621
- const { sessionId, refusedSessionId } = await this.ensureSession(conv);
3660
+ const { sessionId, refusedSessionId, created: sessionCreated } = await this.ensureSession(conv);
3622
3661
  const messages = await this.getPendingMessages(conv.id);
3623
3662
  let dispatched = 0;
3624
3663
  let skippedAlreadyDispatched = 0;
@@ -3634,7 +3673,7 @@ var ChannelDriver = class _ChannelDriver {
3634
3673
  continue;
3635
3674
  }
3636
3675
  if (message.opencode_message_id) {
3637
- const outcome = await this.resolveRedrive(conv, sessionId, message, refusedSessionId);
3676
+ const outcome = await this.resolveRedrive(conv, sessionId, message, sessionCreated);
3638
3677
  if (outcome !== "dispatch") {
3639
3678
  break;
3640
3679
  }
@@ -3705,14 +3744,37 @@ var ChannelDriver = class _ChannelDriver {
3705
3744
  break;
3706
3745
  }
3707
3746
  if (opencodeMessageId === null) {
3747
+ const streak = this.recordUnconfirmedDispatch(message.id, sessionId);
3748
+ if (streak < MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
3749
+ this.log({
3750
+ level: "warn",
3751
+ 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`,
3752
+ conversation_id: conv.id,
3753
+ message_id: message.id
3754
+ });
3755
+ continue;
3756
+ }
3757
+ this.unconfirmedDispatchFailures.delete(message.id);
3758
+ this.sessions.delete(conv.id);
3759
+ this.supersede(conv.id, sessionId);
3760
+ const errorMessage = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
3708
3761
  this.log({
3709
- level: "warn",
3710
- message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
3762
+ level: "error",
3763
+ message: errorMessage,
3711
3764
  conversation_id: conv.id,
3712
3765
  message_id: message.id
3713
3766
  });
3714
- continue;
3767
+ await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
3768
+ this.log({
3769
+ level: "warn",
3770
+ 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)}`,
3771
+ conversation_id: conv.id,
3772
+ message_id: message.id
3773
+ });
3774
+ });
3775
+ break;
3715
3776
  }
3777
+ this.unconfirmedDispatchFailures.delete(message.id);
3716
3778
  this.dispatched.add(message.id);
3717
3779
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
3718
3780
  dispatched += 1;
@@ -3733,21 +3795,29 @@ var ChannelDriver = class _ChannelDriver {
3733
3795
  * INJECTED `fetchImpl` — NOT the imported `getSessionMessages` helper, which
3734
3796
  * hits the global `fetch` and would bypass the same override every other
3735
3797
  * opencode poll in this file respects. Mirrors `readoptProcessing`'s own
3736
- * snapshot fetch (`:3081-3111`). `null` = unreadable (non-OK response,
3737
- * non-array body, or a network exception) — treated as "can't observe",
3738
- * never as "confirmed gone".
3798
+ * snapshot fetch (`:3081-3111`).
3799
+ *
3800
+ * Returns `{ ok: true, messages }` on a readable snapshot, or
3801
+ * `{ ok: false, signature }` on failure — `signature` is a string that
3802
+ * repeats across attempts for the SAME underlying fault (used by the
3803
+ * consecutive-identical-failure bound, #1348), or `null` for a thrown
3804
+ * exception, which is NOT countable toward that bound (a network blip / an
3805
+ * opencode restart also throws identically every tick, and must keep
3806
+ * retrying unbounded rather than ever being treated as permanent).
3739
3807
  */
3740
3808
  async pollSessionMessagesForRedrive(conv, message, sessionId) {
3741
3809
  try {
3742
3810
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
3743
3811
  if (!res.ok) {
3812
+ const rawBody = await res.text();
3813
+ const normalized = normalizeRedrivePollFailureBody(rawBody);
3744
3814
  this.log({
3745
3815
  level: "warn",
3746
- message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status} \u2014 treating as unreadable this tick`,
3816
+ message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status}${normalized ? `: ${normalized}` : ""} \u2014 treating as unreadable this tick`,
3747
3817
  conversation_id: conv.id,
3748
3818
  message_id: message.id
3749
3819
  });
3750
- return null;
3820
+ return { ok: false, signature: `HTTP ${res.status}${normalized ? `: ${normalized}` : ""}` };
3751
3821
  }
3752
3822
  const body = await res.json();
3753
3823
  if (!Array.isArray(body)) {
@@ -3757,9 +3827,9 @@ var ChannelDriver = class _ChannelDriver {
3757
3827
  conversation_id: conv.id,
3758
3828
  message_id: message.id
3759
3829
  });
3760
- return null;
3830
+ return { ok: false, signature: "non-array message body" };
3761
3831
  }
3762
- return body;
3832
+ return { ok: true, messages: body };
3763
3833
  } catch (err) {
3764
3834
  this.log({
3765
3835
  level: "warn",
@@ -3767,7 +3837,7 @@ var ChannelDriver = class _ChannelDriver {
3767
3837
  conversation_id: conv.id,
3768
3838
  message_id: message.id
3769
3839
  });
3770
- return null;
3840
+ return { ok: false, signature: null };
3771
3841
  }
3772
3842
  }
3773
3843
  /**
@@ -3779,21 +3849,34 @@ var ChannelDriver = class _ChannelDriver {
3779
3849
  * without this fence the drain loop would re-`prompt_async` the SAME turn a
3780
3850
  * second time against live GitHub state. Mirrors `readoptOne`'s job for the
3781
3851
  * `processing` re-adopt path, but simpler: no b1/b2 preamble cross-check is
3782
- * needed here because `refusedSessionId` already handles the one case
3783
- * (#553 abandoned session) that path exists for.
3852
+ * needed here because `sessionCreated` already handles the cases (a #553
3853
+ * abandoned session, a #190 vanished one) that path exists for.
3784
3854
  *
3785
- * Only `ChannelAuthError` propagates; every other failure resolves to
3786
- * `unresolved` and is retried whole on the next ~2s drain tick.
3855
+ * Only `ChannelAuthError` propagates. A poll that fails identically
3856
+ * `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row reports the message
3857
+ * failed instead of retrying it (#1348) — SEPARATE from, not a replacement
3858
+ * for, `resolveRedriveUnresolved`'s own `pausedMaxWaitMs` bound below. Every
3859
+ * other failure resolves to `unresolved` and is retried whole on the next
3860
+ * ~2s drain tick.
3787
3861
  */
3788
- async resolveRedrive(conv, sessionId, message, refusedSessionId) {
3862
+ async resolveRedrive(conv, sessionId, message, sessionCreated) {
3789
3863
  const ocId = message.opencode_message_id ?? null;
3790
- if (refusedSessionId) {
3864
+ if (sessionCreated) {
3791
3865
  this.clearRedriveUnresolved(message.id);
3792
3866
  void this.postSignal(conv.id, message.id, "redrive_redispatched");
3793
3867
  return "dispatch";
3794
3868
  }
3795
- const messages = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
3796
- if (messages == null || messages.length === 0) {
3869
+ const polled = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
3870
+ if (!polled.ok) {
3871
+ const streak = this.recordRedrivePollFailure(message.id, sessionId, polled.signature);
3872
+ if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES && polled.signature !== null) {
3873
+ return this.failRedrivePollPermanent(conv, sessionId, message, polled.signature, streak);
3874
+ }
3875
+ return this.resolveRedriveUnresolved(conv, message);
3876
+ }
3877
+ this.redrivePollFailures.delete(message.id);
3878
+ const messages = polled.messages;
3879
+ if (messages.length === 0) {
3797
3880
  return this.resolveRedriveUnresolved(conv, message);
3798
3881
  }
3799
3882
  const state = messageRunState(messages, ocId ?? "");
@@ -3860,6 +3943,7 @@ var ChannelDriver = class _ChannelDriver {
3860
3943
  conversation_id: conv.id,
3861
3944
  message_id: message.id
3862
3945
  });
3946
+ this.signalRedriveOutcomeUnreported(conv, message, "reattach");
3863
3947
  return "unresolved";
3864
3948
  }
3865
3949
  this.clearRedriveUnresolved(message.id);
@@ -3920,12 +4004,53 @@ var ChannelDriver = class _ChannelDriver {
3920
4004
  conversation_id: conv.id,
3921
4005
  message_id: message.id
3922
4006
  });
4007
+ this.signalRedriveOutcomeUnreported(conv, message, "settle");
3923
4008
  return "unresolved";
3924
4009
  }
3925
4010
  this.clearRedriveUnresolved(message.id);
3926
4011
  void this.postSignal(conv.id, message.id, "redrive_settled");
3927
4012
  return "settled";
3928
4013
  }
4014
+ /**
4015
+ * The permanent-failure outcome (#1348): the fence's own poll of this session
4016
+ * failed with the SAME opencode-answered signature
4017
+ * `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row — a transient blip
4018
+ * would have varied or eventually cleared (see `pollSessionMessagesForRedrive`
4019
+ * and `recordRedrivePollFailure`), so this is a durable fault (e.g. #1345's
4020
+ * corrupted opencode session) rather than something worth retrying forever.
4021
+ * Mirrors `settleRedrive`'s error discipline: no `usage`/`failure` args to
4022
+ * `markFailed` (no opencode snapshot to extract them from — this poll never
4023
+ * got a readable one).
4024
+ */
4025
+ async failRedrivePollPermanent(conv, sessionId, message, signature, streak) {
4026
+ this.log({
4027
+ level: "error",
4028
+ message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) failed to poll with the identical signature "${signature}" ${streak} times in a row \u2014 reporting the message failed instead of retrying forever`,
4029
+ conversation_id: conv.id,
4030
+ message_id: message.id
4031
+ });
4032
+ try {
4033
+ await this.markFailed(
4034
+ conv.id,
4035
+ message.id,
4036
+ sessionId,
4037
+ `The runner could not read this conversation's state from OpenCode (${signature}). The same failure repeated ${streak} times in a row, so the message was not retried further.`
4038
+ );
4039
+ } catch (err) {
4040
+ if (err instanceof ChannelAuthError) throw err;
4041
+ this.log({
4042
+ level: "warn",
4043
+ message: `Re-drive: failed to report message ${message.id.slice(0, 8)} permanently failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
4044
+ conversation_id: conv.id,
4045
+ message_id: message.id
4046
+ });
4047
+ this.signalRedriveOutcomeUnreported(conv, message, "fail_permanent");
4048
+ return "unresolved";
4049
+ }
4050
+ this.clearRedriveUnresolved(message.id);
4051
+ void this.postSignal(conv.id, message.id, "redrive_poll_failed");
4052
+ return "settled";
4053
+ }
3929
4054
  /**
3930
4055
  * The bounded `unresolved` outcome (Task 3.4): opencode's state could not be
3931
4056
  * observed (snapshot unreadable/empty, or `isSessionOngoing` returned `null`).
@@ -3951,10 +4076,68 @@ var ChannelDriver = class _ChannelDriver {
3951
4076
  }
3952
4077
  return "unresolved";
3953
4078
  }
3954
- /** Clear both `unresolved`-bound trackers for a row (any non-`unresolved` outcome). */
4079
+ /** Clear all `unresolved`/failure-streak trackers for a row (any non-`unresolved` outcome). */
3955
4080
  clearRedriveUnresolved(messageId) {
3956
4081
  this.redriveUnresolvedSince.delete(messageId);
3957
4082
  this.redriveUnresolvedSignalled.delete(messageId);
4083
+ this.redrivePollFailures.delete(messageId);
4084
+ this.redriveOutcomeUnreportedSignalled.delete(messageId);
4085
+ }
4086
+ /**
4087
+ * Class B (#1340): the runner DECIDED an outcome (reattach/settle/fail_permanent)
4088
+ * but its own PATCH to record it failed. Deliberately NOT bounded like
4089
+ * `resolveRedriveUnresolved`, for two load-bearing reasons: (1) the only
4090
+ * remedy is a PATCH to the server — the very call that is failing, so a bound
4091
+ * would act by retrying it; (2) this class is self-clearing by construction
4092
+ * (the next successful PATCH resolves it via `clearRedriveUnresolved`),
4093
+ * unlike #1340's deterministic, permanent shape. Fires at most once per
4094
+ * (message, outcome) streak.
4095
+ */
4096
+ signalRedriveOutcomeUnreported(conv, message, outcome) {
4097
+ if (this.redriveOutcomeUnreportedSignalled.get(message.id) === outcome) return;
4098
+ this.redriveOutcomeUnreportedSignalled.set(message.id, outcome);
4099
+ void this.postSignal(conv.id, message.id, "redrive_outcome_unreported", {
4100
+ attempted_outcome: outcome
4101
+ });
4102
+ }
4103
+ /**
4104
+ * Record one poll outcome toward the re-drive fence's consecutive-identical-
4105
+ * failure streak (#1348) and return the resulting count. `signature === null`
4106
+ * (a thrown exception, H1) always clears the streak and returns `0` — it is
4107
+ * never countable. Otherwise the streak continues only when BOTH the session
4108
+ * and the signature match the previous failure; anything else (a different
4109
+ * session, or the same session failing a DIFFERENT way) starts a fresh streak
4110
+ * at `1`.
4111
+ */
4112
+ recordRedrivePollFailure(messageId, sessionId, signature) {
4113
+ if (signature === null) {
4114
+ this.redrivePollFailures.delete(messageId);
4115
+ return 0;
4116
+ }
4117
+ const existing = this.redrivePollFailures.get(messageId);
4118
+ if (existing && existing.sessionId === sessionId && existing.signature === signature) {
4119
+ existing.count += 1;
4120
+ return existing.count;
4121
+ }
4122
+ this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
4123
+ return 1;
4124
+ }
4125
+ /**
4126
+ * Record one UNCONFIRMED-dispatch outcome (a `pending` row with no stored
4127
+ * `opencode_message_id` whose `sendPromptAsync` returned `null`) toward the
4128
+ * bound in `processConversation`'s dispatch loop, and return the resulting
4129
+ * count. Mirrors `recordRedrivePollFailure`'s session-scoping: a session
4130
+ * change starts a fresh streak at `1` rather than inheriting the old one's
4131
+ * count, since a new session is a genuinely different attempt.
4132
+ */
4133
+ recordUnconfirmedDispatch(messageId, sessionId) {
4134
+ const existing = this.unconfirmedDispatchFailures.get(messageId);
4135
+ if (existing && existing.sessionId === sessionId) {
4136
+ existing.count += 1;
4137
+ return existing.count;
4138
+ }
4139
+ this.unconfirmedDispatchFailures.set(messageId, { sessionId, count: 1 });
4140
+ return 1;
3958
4141
  }
3959
4142
  /**
3960
4143
  * Record that `sessionId` is no longer a valid binding for `conversationId`
@@ -3980,6 +4163,13 @@ var ChannelDriver = class _ChannelDriver {
3980
4163
  * `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
3981
4164
  * binding was an id this runner had abandoned, so a resurrection genuinely
3982
4165
  * happened and a fresh session was bound instead. The caller reports it.
4166
+ *
4167
+ * `created` says the returned session was made JUST NOW, so it provably holds
4168
+ * no prior turn. The re-drive fence needs that as CONTRARY evidence ("nothing
4169
+ * to reconcile against") — distinct from the ambiguous "I polled and saw an
4170
+ * empty transcript", which stays a deferral. Keep it separate from
4171
+ * `refusedSessionId`: only the latter means a #553 resurrection happened, and
4172
+ * only it may drive the `session_superseded` signal.
3983
4173
  */
3984
4174
  async ensureSession(conv) {
3985
4175
  const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
@@ -3990,7 +4180,11 @@ var ChannelDriver = class _ChannelDriver {
3990
4180
  conversation_id: conv.id
3991
4181
  });
3992
4182
  this.sessions.delete(conv.id);
3993
- return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
4183
+ return {
4184
+ sessionId: await this.createAndBindSession(conv.id),
4185
+ refusedSessionId: bound,
4186
+ created: true
4187
+ };
3994
4188
  }
3995
4189
  if (bound) {
3996
4190
  const exists = await sessionExists(this.port, bound);
@@ -4001,12 +4195,12 @@ var ChannelDriver = class _ChannelDriver {
4001
4195
  conversation_id: conv.id
4002
4196
  });
4003
4197
  this.sessions.delete(conv.id);
4004
- return { sessionId: await this.createAndBindSession(conv.id) };
4198
+ return { sessionId: await this.createAndBindSession(conv.id), created: true };
4005
4199
  }
4006
4200
  this.sessions.set(conv.id, bound);
4007
- return { sessionId: bound };
4201
+ return { sessionId: bound, created: false };
4008
4202
  }
4009
- return { sessionId: await this.createAndBindSession(conv.id) };
4203
+ return { sessionId: await this.createAndBindSession(conv.id), created: true };
4010
4204
  }
4011
4205
  /**
4012
4206
  * Create a fresh OpenCode session for a conversation, cache the binding, and
@@ -5092,15 +5286,39 @@ var ChannelDriver = class _ChannelDriver {
5092
5286
  }
5093
5287
  if (ocId === null) {
5094
5288
  this.awaitingReadopt.delete(row.id);
5289
+ const streak = this.recordUnconfirmedDispatch(row.id, sessionId);
5290
+ if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
5291
+ this.unconfirmedDispatchFailures.delete(row.id);
5292
+ this.sessions.delete(readoptConv.id);
5293
+ this.supersede(readoptConv.id, sessionId);
5294
+ const errorMessage = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
5295
+ this.log({
5296
+ level: "error",
5297
+ message: errorMessage,
5298
+ conversation_id: row.conversation_id,
5299
+ message_id: row.id
5300
+ });
5301
+ await this.markFailed(row.conversation_id, row.id, null, errorMessage).catch((markErr) => {
5302
+ this.log({
5303
+ level: "warn",
5304
+ 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)}`,
5305
+ conversation_id: row.conversation_id,
5306
+ message_id: row.id
5307
+ });
5308
+ });
5309
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
5310
+ return;
5311
+ }
5095
5312
  this.log({
5096
5313
  level: "warn",
5097
- message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
5314
+ 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`,
5098
5315
  conversation_id: row.conversation_id,
5099
5316
  message_id: row.id
5100
5317
  });
5101
5318
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
5102
5319
  return;
5103
5320
  }
5321
+ this.unconfirmedDispatchFailures.delete(row.id);
5104
5322
  this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
5105
5323
  this.dispatched.add(row.id);
5106
5324
  this.readopted.add(row.id);