@evident-ai/cli 3.1.1-dev.5a3c6a5 → 3.1.1-dev.63072ee

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
@@ -1532,6 +1532,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
1532
1532
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1533
1533
  return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1534
1534
  }
1535
+ function isB2AbandonmentConfirmed(params) {
1536
+ return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
1537
+ }
1535
1538
  function messageError(messages, userMessageId) {
1536
1539
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1537
1540
  const error2 = errorOf(reply);
@@ -1545,6 +1548,42 @@ function messageError(messages, userMessageId) {
1545
1548
  }
1546
1549
  return "The agent run failed.";
1547
1550
  }
1551
+ function messageFailure(messages, userMessageId) {
1552
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1553
+ const error2 = errorOf(reply);
1554
+ if (error2 == null || typeof error2 !== "object") return null;
1555
+ const e = error2;
1556
+ const replyProviderId = reply?.info?.providerID ?? null;
1557
+ const replyModelId = reply?.info?.modelID ?? null;
1558
+ if (e.name === "ProviderAuthError") {
1559
+ const data = e.data;
1560
+ const providerId = typeof data?.providerID === "string" && data.providerID || replyProviderId;
1561
+ return { kind: "model_auth", providerId, modelId: replyModelId, reason: "missing" };
1562
+ }
1563
+ if (e.name === "APIError") {
1564
+ const data = e.data;
1565
+ const statusCode = data?.statusCode;
1566
+ if (statusCode === 401 || statusCode === 403) {
1567
+ return {
1568
+ kind: "model_auth",
1569
+ providerId: replyProviderId,
1570
+ modelId: replyModelId,
1571
+ reason: "rejected"
1572
+ };
1573
+ }
1574
+ }
1575
+ return null;
1576
+ }
1577
+ function applyZeroProviderFallback(classified, hasConfiguredProvider, replyProviderId, replyModelId = null) {
1578
+ if (classified != null) return classified;
1579
+ if (hasConfiguredProvider !== false) return null;
1580
+ return {
1581
+ kind: "model_auth",
1582
+ providerId: replyProviderId,
1583
+ modelId: replyModelId,
1584
+ reason: "missing"
1585
+ };
1586
+ }
1548
1587
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1549
1588
  if (!messages || messages.length === 0) return false;
1550
1589
  return messages.some(
@@ -2073,6 +2112,18 @@ var RunnerConnection = class {
2073
2112
  }
2074
2113
  };
2075
2114
 
2115
+ // src/lib/tunnel/ready-marker.ts
2116
+ import { writeFileSync } from "fs";
2117
+ function writeTunnelReadyMarker(path, agentId) {
2118
+ try {
2119
+ writeFileSync(path, `${agentId}
2120
+ `);
2121
+ return { ok: true };
2122
+ } catch (error2) {
2123
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
2124
+ }
2125
+ }
2126
+
2076
2127
  // src/lib/channels/driver.ts
2077
2128
  import { homedir } from "os";
2078
2129
 
@@ -2499,6 +2550,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
2499
2550
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2500
2551
  var HEARTBEAT_MS = 6e4;
2501
2552
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2553
+ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2554
+ var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2502
2555
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2503
2556
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
2504
2557
  var ChannelAuthError = class extends Error {
@@ -3317,12 +3370,17 @@ var ChannelDriver = class _ChannelDriver {
3317
3370
  stuckReported: false,
3318
3371
  lastAliveAt: 0,
3319
3372
  aliveInFlight: false,
3373
+ titleSynced: false,
3374
+ titleSyncInFlight: false,
3320
3375
  awaitingHumanLatched: false,
3321
3376
  pausedOnQuestion: false,
3322
3377
  pausedOnPermission: false,
3323
3378
  pausedClearConfirmed: false,
3324
3379
  pausedInFlight: false,
3325
- deliveryDeadlineAnchored: false
3380
+ deliveryDeadlineAnchored: false,
3381
+ b2PinnedSinceMs: 0,
3382
+ b2LastDescendantCheckMs: 0,
3383
+ b2AbandonedSignalled: false
3326
3384
  });
3327
3385
  }
3328
3386
  /**
@@ -3390,12 +3448,17 @@ var ChannelDriver = class _ChannelDriver {
3390
3448
  // with no extra `re_adopted` signal needed (folds old WI-6).
3391
3449
  lastAliveAt: 0,
3392
3450
  aliveInFlight: false,
3451
+ titleSynced: false,
3452
+ titleSyncInFlight: false,
3393
3453
  awaitingHumanLatched: false,
3394
3454
  pausedOnQuestion: false,
3395
3455
  pausedOnPermission: false,
3396
3456
  pausedClearConfirmed: false,
3397
3457
  pausedInFlight: false,
3398
- deliveryDeadlineAnchored: false
3458
+ deliveryDeadlineAnchored: false,
3459
+ b2PinnedSinceMs: 0,
3460
+ b2LastDescendantCheckMs: 0,
3461
+ b2AbandonedSignalled: false
3399
3462
  });
3400
3463
  }
3401
3464
  /**
@@ -3557,58 +3620,7 @@ var ChannelDriver = class _ChannelDriver {
3557
3620
  }
3558
3621
  }
3559
3622
  if (state === "done") {
3560
- this.anchorDeliveryDeadline(inFlight);
3561
- if (!inFlight.done) {
3562
- this.log({
3563
- level: "info",
3564
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3565
- conversation_id: conv.id,
3566
- message_id: inFlight.evidentMessageId
3567
- });
3568
- const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3569
- const usage = messageUsage(messages, inFlight.opencodeMessageId);
3570
- try {
3571
- await this.markDone(
3572
- conv.id,
3573
- inFlight.evidentMessageId,
3574
- sessionId,
3575
- inFlight.opencodeMessageId,
3576
- title,
3577
- usage
3578
- );
3579
- } catch (err) {
3580
- if (err instanceof ChannelAuthError) throw err;
3581
- if (err instanceof ChannelTerminalError) {
3582
- this.log({
3583
- level: "warn",
3584
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3585
- conversation_id: conv.id,
3586
- message_id: inFlight.evidentMessageId
3587
- });
3588
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3589
- return;
3590
- }
3591
- if (this.now() >= inFlight.deadline) {
3592
- this.log({
3593
- level: "warn",
3594
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
3595
- conversation_id: conv.id,
3596
- message_id: inFlight.evidentMessageId
3597
- });
3598
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3599
- return;
3600
- }
3601
- this.log({
3602
- level: "warn",
3603
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3604
- conversation_id: conv.id,
3605
- message_id: inFlight.evidentMessageId
3606
- });
3607
- return;
3608
- }
3609
- inFlight.done = true;
3610
- }
3611
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3623
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3612
3624
  return;
3613
3625
  }
3614
3626
  if (state === "failed") {
@@ -3622,8 +3634,16 @@ var ChannelDriver = class _ChannelDriver {
3622
3634
  message_id: inFlight.evidentMessageId
3623
3635
  });
3624
3636
  const usage = messageUsage(messages, inFlight.opencodeMessageId);
3637
+ const failure = await this.classifyModelAuthFailure(messages, inFlight.opencodeMessageId);
3625
3638
  try {
3626
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
3639
+ await this.markFailed(
3640
+ conv.id,
3641
+ inFlight.evidentMessageId,
3642
+ sessionId,
3643
+ error2,
3644
+ usage,
3645
+ failure
3646
+ );
3627
3647
  } catch (err) {
3628
3648
  if (err instanceof ChannelAuthError) throw err;
3629
3649
  if (err instanceof ChannelTerminalError) {
@@ -3668,6 +3688,44 @@ var ChannelDriver = class _ChannelDriver {
3668
3688
  });
3669
3689
  }
3670
3690
  const activelyRunning = state === "running" && !awaitingHuman;
3691
+ const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
3692
+ const snapshotReadable = messages != null && messages.length > 0;
3693
+ if (!pinnedNow) {
3694
+ if (snapshotReadable) {
3695
+ inFlight.b2PinnedSinceMs = 0;
3696
+ inFlight.b2LastDescendantCheckMs = 0;
3697
+ inFlight.b2AbandonedSignalled = false;
3698
+ }
3699
+ } else {
3700
+ if (inFlight.b2AbandonedSignalled) {
3701
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3702
+ return;
3703
+ }
3704
+ if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
3705
+ const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
3706
+ if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
3707
+ inFlight.b2LastDescendantCheckMs = this.now();
3708
+ const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
3709
+ if (isB2AbandonmentConfirmed({
3710
+ pinnedForMs,
3711
+ minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
3712
+ descendantOngoing
3713
+ })) {
3714
+ inFlight.b2AbandonedSignalled = true;
3715
+ this.log({
3716
+ level: "warn",
3717
+ 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`,
3718
+ conversation_id: conv.id,
3719
+ message_id: id
3720
+ });
3721
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
3722
+ watched_for_ms: pinnedForMs
3723
+ });
3724
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3725
+ return;
3726
+ }
3727
+ }
3728
+ }
3671
3729
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
3672
3730
  this.log({
3673
3731
  level: "warn",
@@ -3687,6 +3745,18 @@ var ChannelDriver = class _ChannelDriver {
3687
3745
  inFlight.aliveInFlight = false;
3688
3746
  if (ok) inFlight.lastAliveAt = this.now();
3689
3747
  });
3748
+ if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
3749
+ inFlight.titleSyncInFlight = true;
3750
+ void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
3751
+ if (!title) {
3752
+ inFlight.titleSyncInFlight = false;
3753
+ return;
3754
+ }
3755
+ const ok = await this.patchConversationTitle(conv.id, title);
3756
+ inFlight.titleSyncInFlight = false;
3757
+ if (ok) inFlight.titleSynced = true;
3758
+ });
3759
+ }
3690
3760
  }
3691
3761
  if (awaitingHuman) {
3692
3762
  if (!inFlight.awaitingHumanLatched) {
@@ -3724,6 +3794,70 @@ var ChannelDriver = class _ChannelDriver {
3724
3794
  this.removeInFlight(watcher, inFlight.evidentMessageId);
3725
3795
  }
3726
3796
  }
3797
+ /**
3798
+ * Settle a message whose run-state has resolved `'done'` — extracted verbatim
3799
+ * (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
3800
+ * inline `state === 'done'` branch body, so a SECOND caller (the #721
3801
+ * b2-abandonment resolution) can reach the exact same completion behavior
3802
+ * (delivery-deadline anchoring, title resolution, usage extraction, and
3803
+ * `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
3804
+ * and risking the two copies silently drifting apart.
3805
+ */
3806
+ async settleMessageDone(sessionId, watcher, inFlight, messages) {
3807
+ const conv = watcher.conv;
3808
+ this.anchorDeliveryDeadline(inFlight);
3809
+ if (!inFlight.done) {
3810
+ this.log({
3811
+ level: "info",
3812
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3813
+ conversation_id: conv.id,
3814
+ message_id: inFlight.evidentMessageId
3815
+ });
3816
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3817
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
3818
+ try {
3819
+ await this.markDone(
3820
+ conv.id,
3821
+ inFlight.evidentMessageId,
3822
+ sessionId,
3823
+ inFlight.opencodeMessageId,
3824
+ title,
3825
+ usage
3826
+ );
3827
+ } catch (err) {
3828
+ if (err instanceof ChannelAuthError) throw err;
3829
+ if (err instanceof ChannelTerminalError) {
3830
+ this.log({
3831
+ level: "warn",
3832
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3833
+ conversation_id: conv.id,
3834
+ message_id: inFlight.evidentMessageId
3835
+ });
3836
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3837
+ return;
3838
+ }
3839
+ if (this.now() >= inFlight.deadline) {
3840
+ this.log({
3841
+ level: "warn",
3842
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
3843
+ conversation_id: conv.id,
3844
+ message_id: inFlight.evidentMessageId
3845
+ });
3846
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3847
+ return;
3848
+ }
3849
+ this.log({
3850
+ level: "warn",
3851
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3852
+ conversation_id: conv.id,
3853
+ message_id: inFlight.evidentMessageId
3854
+ });
3855
+ return;
3856
+ }
3857
+ inFlight.done = true;
3858
+ }
3859
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3860
+ }
3727
3861
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
3728
3862
  /**
3729
3863
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
@@ -3890,6 +4024,7 @@ var ChannelDriver = class _ChannelDriver {
3890
4024
  if (state === "failed") {
3891
4025
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3892
4026
  const usage = messageUsage(messages, ocId ?? "");
4027
+ const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
3893
4028
  this.log({
3894
4029
  level: "error",
3895
4030
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -3897,7 +4032,7 @@ var ChannelDriver = class _ChannelDriver {
3897
4032
  message_id: row.id
3898
4033
  });
3899
4034
  try {
3900
- await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
4035
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage, failure);
3901
4036
  } catch (err) {
3902
4037
  if (err instanceof ChannelAuthError) throw err;
3903
4038
  if (err instanceof ChannelTerminalError) {
@@ -4303,6 +4438,47 @@ var ChannelDriver = class _ChannelDriver {
4303
4438
  }
4304
4439
  return false;
4305
4440
  }
4441
+ /**
4442
+ * Tri-state variant of the upward parentID membership walk (#721), used ONLY
4443
+ * by `isAnyDescendantSessionOngoing`. Walks the SAME cached
4444
+ * `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
4445
+ * `sessionBelongsTo`, which deliberately collapses "confirmed not a
4446
+ * descendant" and "the walk's fetch failed" into the same `false` (safe for
4447
+ * its OTHER callers: interaction attribution and the recovery-path
4448
+ * `isAnyDescendantSessionAlive`, both of which just retry next tick with no
4449
+ * safety consequence either way) — this variant keeps those two outcomes
4450
+ * SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
4451
+ * (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
4452
+ * not ongoing".
4453
+ *
4454
+ * Return contract:
4455
+ * - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
4456
+ * - `false` → the walk reached a definitive, parent-less root session
4457
+ * WITHOUT ever matching `rootSessionId` — `sessionId` is
4458
+ * CONFIRMED NOT a descendant of it.
4459
+ * - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
4460
+ * through the walk (`resolveSessionParent` returned `undefined`),
4461
+ * or the depth cap (32) was hit without a definitive answer (a
4462
+ * pathological/cyclic chain proves nothing either way). NEVER
4463
+ * treat this the same as `false` — see `sessionBelongsTo`'s own
4464
+ * doc comment above for why that collapse is safe THERE but not
4465
+ * here.
4466
+ *
4467
+ * `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
4468
+ * to the live-path descendant check, not a modification of shared code used
4469
+ * by interaction attribution or the recovery path.
4470
+ */
4471
+ async resolveSessionMembership(sessionId, rootSessionId) {
4472
+ let current = sessionId;
4473
+ for (let depth = 0; current && depth < 32; depth++) {
4474
+ if (current === rootSessionId) return true;
4475
+ const parent = await this.resolveSessionParent(current);
4476
+ if (parent === void 0) return null;
4477
+ if (parent === null) return false;
4478
+ current = parent;
4479
+ }
4480
+ return null;
4481
+ }
4306
4482
  /**
4307
4483
  * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
4308
4484
  * `null` for a root session (no parent) and `undefined` when opencode is
@@ -4387,6 +4563,54 @@ var ChannelDriver = class _ChannelDriver {
4387
4563
  }
4388
4564
  return null;
4389
4565
  }
4566
+ /**
4567
+ * Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
4568
+ * session title onto the conversation via the PLAIN conversation-update
4569
+ * endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
4570
+ * message-status endpoint `markProcessing`/`markDone` use. Deliberately a
4571
+ * separate, lighter call: it carries no `status`, so it cannot re-trigger the
4572
+ * `processing`/`done` transition side effects (Slack notices, activity-log
4573
+ * rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
4574
+ * ever touches `conversations.title`. That route (`routes/conversations.ts`)
4575
+ * skips a title write matching the stored value, so a redundant call with the
4576
+ * same title is a real no-op — it does not bump `updated_at`, which the
4577
+ * conversation list sorts and paginates on. (Note this is a DIFFERENT guard
4578
+ * from `threads.ts`'s "non-empty AND changed" one, which only covers the
4579
+ * message-status PATCH; the non-empty half is enforced here instead, by
4580
+ * `resolveSessionTitle` never returning an empty/placeholder title.)
4581
+ *
4582
+ * Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
4583
+ * is logged and the title is simply retried on the next heartbeat tick (the
4584
+ * caller only latches `titleSynced` on `true`).
4585
+ */
4586
+ async patchConversationTitle(conversationId, title) {
4587
+ try {
4588
+ const res = await this.fetchImpl(
4589
+ `${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
4590
+ {
4591
+ method: "PATCH",
4592
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
4593
+ body: JSON.stringify({ title })
4594
+ }
4595
+ );
4596
+ if (!res.ok) {
4597
+ this.log({
4598
+ level: "debug",
4599
+ message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
4600
+ conversation_id: conversationId
4601
+ });
4602
+ return false;
4603
+ }
4604
+ return true;
4605
+ } catch (err) {
4606
+ this.log({
4607
+ level: "debug",
4608
+ message: `Best-effort mid-turn title sync PATCH failed for conversation ${conversationId.slice(0, 8)} (will retry next heartbeat): ${err instanceof Error ? err.message : String(err)}`,
4609
+ conversation_id: conversationId
4610
+ });
4611
+ return false;
4612
+ }
4613
+ }
4390
4614
  /**
4391
4615
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
4392
4616
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -4445,6 +4669,84 @@ var ChannelDriver = class _ChannelDriver {
4445
4669
  }
4446
4670
  return false;
4447
4671
  }
4672
+ /**
4673
+ * LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
4674
+ * sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
4675
+ * in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
4676
+ *
4677
+ * Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
4678
+ * cross-check above): that method judges liveness from the child's OWN
4679
+ * TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
4680
+ * on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
4681
+ * path the local opencode server IS running, so its in-memory status map is
4682
+ * live and authoritative — and per ADR-0047 §4a ("the child has its own entry
4683
+ * [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
4684
+ * ENTIRE turn (including any tool call it is itself executing), not a
4685
+ * per-message transcript snapshot. This sidesteps the "child's own tool is
4686
+ * executing, between its step's completion and the next generation step"
4687
+ * transcript gap that a transcript-based check would need a second,
4688
+ * sustained-window bound to guard against — it is simply not derived from
4689
+ * message timestamps at all.
4690
+ *
4691
+ * Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
4692
+ * status, as the recovery path does per §4a)? Because on the LIVE path the
4693
+ * root session can be shared: a SECOND, unrelated user message can land on the
4694
+ * SAME session (issue #721's own root cause) and keep the root `busy` for a
4695
+ * reason that has nothing to do with THIS message's delegation. A `task`
4696
+ * descendant session is spawned for exactly one delegated turn and never
4697
+ * reused, so its OWN status-map entry is unambiguous evidence about that one
4698
+ * delegation — which the root's status is not.
4699
+ *
4700
+ * Why membership is checked via `resolveSessionMembership`, NOT
4701
+ * `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
4702
+ * `GET /session/:id` fetch failure into "not a descendant", which would
4703
+ * silently drop a genuinely-live candidate from consideration on the one
4704
+ * unlucky tick its membership-walk fetch hiccups (#721).
4705
+ * `resolveSessionMembership` keeps that failure mode as a distinct `null`
4706
+ * (indeterminate) so it is folded into THIS method's own `indeterminate` flag
4707
+ * instead.
4708
+ *
4709
+ * Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
4710
+ * - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
4711
+ * - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
4712
+ * confirmed either way (`resolveSessionMembership` never
4713
+ * returned `null`), and every CONFIRMED descendant's status read
4714
+ * succeeded and is not ongoing (includes "no descendant session
4715
+ * exists at all" — e.g. a plain, non-`task` tool call).
4716
+ * - `null` → INDETERMINATE: `listSessions` failed, OR at least one
4717
+ * candidate's MEMBERSHIP could not be confirmed
4718
+ * (`resolveSessionMembership` returned `null` — a fetch failure
4719
+ * or pathological chain partway through the parent walk), OR at
4720
+ * least one CONFIRMED descendant's `isSessionOngoing` read
4721
+ * failed — and no OTHER candidate was already confirmed `true`.
4722
+ * The caller MUST NOT treat `null` the same as `false` here
4723
+ * (unlike the recovery cross-check's contract) — see
4724
+ * `isB2AbandonmentConfirmed`.
4725
+ */
4726
+ async isAnyDescendantSessionOngoing(rootSessionId) {
4727
+ const sessions = await listSessions(this.port);
4728
+ if (!sessions) {
4729
+ this.log({
4730
+ level: "warn",
4731
+ message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
4732
+ });
4733
+ return null;
4734
+ }
4735
+ let indeterminate = false;
4736
+ for (const candidate of sessions) {
4737
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
4738
+ const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
4739
+ if (membership === null) {
4740
+ indeterminate = true;
4741
+ continue;
4742
+ }
4743
+ if (membership === false) continue;
4744
+ const ongoing = await isSessionOngoing(this.port, candidate.id);
4745
+ if (ongoing === true) return true;
4746
+ if (ongoing === null) indeterminate = true;
4747
+ }
4748
+ return indeterminate ? null : false;
4749
+ }
4448
4750
  /**
4449
4751
  * Cheap decision-telemetry label for a running row's LAST correlated reply
4450
4752
  * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
@@ -4708,7 +5010,7 @@ var ChannelDriver = class _ChannelDriver {
4708
5010
  * exists but is wedged, so the next attempt must get a fresh one
4709
5011
  * instead of reusing it — see WI-1's server-side null-clearing PATCH).
4710
5012
  */
4711
- async markFailed(conversationId, messageId, sessionId, error2, usage) {
5013
+ async markFailed(conversationId, messageId, sessionId, error2, usage, failure) {
4712
5014
  const body = { status: "failed" };
4713
5015
  if (sessionId === null) {
4714
5016
  body.opencode_session_id = null;
@@ -4717,6 +5019,12 @@ var ChannelDriver = class _ChannelDriver {
4717
5019
  }
4718
5020
  if (error2 !== void 0) body.error = error2;
4719
5021
  if (usage) Object.assign(body, usage);
5022
+ if (failure) {
5023
+ body.failure_kind = failure.kind;
5024
+ body.failure_provider_id = failure.providerId;
5025
+ body.failure_model_id = failure.modelId;
5026
+ body.failure_reason = failure.reason;
5027
+ }
4720
5028
  await this.callWithRetry(
4721
5029
  "marking message as failed",
4722
5030
  () => this.fetchImpl(
@@ -4729,6 +5037,29 @@ var ChannelDriver = class _ChannelDriver {
4729
5037
  )
4730
5038
  );
4731
5039
  }
5040
+ /**
5041
+ * Classify an errored turn as a model-auth failure (#736 P1-4), for `markFailed`.
5042
+ *
5043
+ * `messageFailure` alone (structured OpenCode error → `model_auth`) covers
5044
+ * most cases; when it returns `null` on this ALREADY-FAILED turn, fall back
5045
+ * to the P1-2b zero-provider check — one extra loopback call to
5046
+ * `hasAnyConfiguredProvider`, only reached when the structured classifier
5047
+ * couldn't place it. Fails open (never throws): a fallback probe failure
5048
+ * (`null`/indeterminate) leaves the classification `null`, which produces
5049
+ * today's byte-identical PATCH body via `markFailed`'s `if (failure)` guard.
5050
+ */
5051
+ async classifyModelAuthFailure(messages, userMessageId) {
5052
+ const classified = messageFailure(messages, userMessageId);
5053
+ if (classified != null) return classified;
5054
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
5055
+ const hasProvider = await hasAnyConfiguredProvider(this.port);
5056
+ return applyZeroProviderFallback(
5057
+ classified,
5058
+ hasProvider,
5059
+ reply?.info?.providerID ?? null,
5060
+ reply?.info?.modelID ?? null
5061
+ );
5062
+ }
4732
5063
  /**
4733
5064
  * Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
4734
5065
  * (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
@@ -4905,7 +5236,7 @@ async function ensureOpenCodeRunning(ctx) {
4905
5236
  console.log(chalk5.yellow("Tip: Run with the correct port:"));
4906
5237
  console.log(
4907
5238
  chalk5.dim(
4908
- ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
5239
+ ` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
4909
5240
  )
4910
5241
  );
4911
5242
  }
@@ -5035,19 +5366,21 @@ async function resolveAgentIdFromKey(authHeader) {
5035
5366
  return { agent_id: data.agent_id };
5036
5367
  }
5037
5368
  return {
5038
- error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
5369
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
5039
5370
  };
5040
5371
  } catch (error2) {
5041
5372
  const message = error2 instanceof Error ? error2.message : "Unknown error";
5042
5373
  return { error: `Failed to resolve runner from key: ${message}` };
5043
5374
  }
5044
5375
  }
5376
+ var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
5045
5377
  async function notifyAgentDisconnected(agentId, authHeader) {
5046
5378
  const apiUrl = getApiUrlConfig();
5047
5379
  try {
5048
5380
  const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
5049
5381
  method: "POST",
5050
- headers: { Authorization: authHeader }
5382
+ headers: { Authorization: authHeader },
5383
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5051
5384
  });
5052
5385
  if (!response.ok) {
5053
5386
  const serverMessage = await readErrorMessage(response);
@@ -5058,7 +5391,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
5058
5391
  }
5059
5392
  return { ok: true };
5060
5393
  } catch (error2) {
5061
- return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
5394
+ return { ok: false, error: describeBestEffortError(error2) };
5395
+ }
5396
+ }
5397
+ function describeBestEffortError(error2) {
5398
+ const name = error2?.name;
5399
+ if (name === "TimeoutError" || name === "AbortError") {
5400
+ return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5401
+ }
5402
+ return error2 instanceof Error ? error2.message : String(error2);
5403
+ }
5404
+ async function reportMicrovmId(agentId, authHeader, microvmId) {
5405
+ try {
5406
+ const apiUrl = getApiUrlConfig();
5407
+ const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5408
+ method: "POST",
5409
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
5410
+ body: JSON.stringify({ microvm_id: microvmId }),
5411
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5412
+ });
5413
+ if (!response.ok) {
5414
+ const serverMessage = await readErrorMessage(response);
5415
+ return {
5416
+ ok: false,
5417
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5418
+ };
5419
+ }
5420
+ return { ok: true };
5421
+ } catch (error2) {
5422
+ return { ok: false, error: describeBestEffortError(error2) };
5062
5423
  }
5063
5424
  }
5064
5425
  async function getAgentInfo(agentId, authHeader) {
@@ -5108,6 +5469,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
5108
5469
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
5109
5470
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
5110
5471
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
5472
+ var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
5111
5473
  function resolveLogLevel(options) {
5112
5474
  const accepted = Object.keys(LOG_LEVELS);
5113
5475
  const validate = (value, source) => {
@@ -5434,7 +5796,18 @@ async function notifyOffline(state) {
5434
5796
  if (state.interactive) displayStatus(state);
5435
5797
  }
5436
5798
  }
5799
+ async function timeShutdownPhase(state, durations, name, run2) {
5800
+ const startedAt = Date.now();
5801
+ try {
5802
+ return await run2();
5803
+ } finally {
5804
+ const elapsedMs = Date.now() - startedAt;
5805
+ durations[name] = elapsedMs;
5806
+ log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
5807
+ }
5808
+ }
5437
5809
  async function cleanup(state, opts = {}) {
5810
+ const durations = {};
5438
5811
  state.running = false;
5439
5812
  for (const timer of state.sessionCleanupTimers) {
5440
5813
  clearInterval(timer);
@@ -5448,7 +5821,13 @@ async function cleanup(state, opts = {}) {
5448
5821
  logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
5449
5822
  displayStatus(state);
5450
5823
  }
5451
- const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
5824
+ const driver = state.channelDriver;
5825
+ const settled = await timeShutdownPhase(
5826
+ state,
5827
+ durations,
5828
+ "drain",
5829
+ () => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
5830
+ );
5452
5831
  if (!settled) {
5453
5832
  logActivity(state, {
5454
5833
  type: "info",
@@ -5457,13 +5836,15 @@ async function cleanup(state, opts = {}) {
5457
5836
  if (state.interactive) displayStatus(state);
5458
5837
  }
5459
5838
  }
5460
- await notifyOffline(state);
5839
+ await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
5461
5840
  if (state.connection) {
5462
- state.connection.close();
5841
+ const connection = state.connection;
5842
+ await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
5463
5843
  state.connection = null;
5464
5844
  }
5465
5845
  if (state.opencodeProcess) {
5466
- stopOpenCode(state.opencodeProcess);
5846
+ const opencodeProcess = state.opencodeProcess;
5847
+ await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
5467
5848
  if (state.interactive) {
5468
5849
  logActivity(state, { type: "info", message: "Stopped OpenCode process" });
5469
5850
  displayStatus(state);
@@ -5472,6 +5853,7 @@ async function cleanup(state, opts = {}) {
5472
5853
  }
5473
5854
  state.opencodeProcess = null;
5474
5855
  }
5856
+ return durations;
5475
5857
  }
5476
5858
  async function run(options) {
5477
5859
  const interactive = isInteractive(options.json);
@@ -5542,14 +5924,38 @@ async function run(options) {
5542
5924
  const handleSignal = async () => {
5543
5925
  if (state.shuttingDown) return;
5544
5926
  state.shuttingDown = true;
5927
+ const shutdownStartedAt = Date.now();
5545
5928
  if (state.interactive) {
5546
5929
  logActivity(state, { type: "info", message: "Shutting down..." });
5547
5930
  displayStatus(state);
5548
5931
  } else {
5549
5932
  log2(state, "Shutting down...");
5550
5933
  }
5551
- await cleanup(state, { graceful: true });
5552
- await shutdownTelemetry();
5934
+ const durations = await cleanup(state, { graceful: true });
5935
+ const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
5936
+ await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
5937
+ let timer;
5938
+ const flushed = shutdownTelemetry().then(
5939
+ () => true,
5940
+ (error2) => {
5941
+ log2(
5942
+ state,
5943
+ `Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
5944
+ "warn"
5945
+ );
5946
+ return true;
5947
+ }
5948
+ );
5949
+ const timedOut = new Promise((resolve3) => {
5950
+ timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
5951
+ });
5952
+ if (!await Promise.race([flushed, timedOut])) {
5953
+ log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
5954
+ }
5955
+ clearTimeout(timer);
5956
+ });
5957
+ const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
5958
+ log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
5553
5959
  process.exit(0);
5554
5960
  };
5555
5961
  process.on("SIGINT", handleSignal);
@@ -5663,6 +6069,21 @@ async function run(options) {
5663
6069
  }
5664
6070
  spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
5665
6071
  state.agentName = validation.agent.name;
6072
+ const microvmId = process.env.MICROVM_ID?.trim();
6073
+ if (microvmId) {
6074
+ const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
6075
+ if (reported.ok) {
6076
+ log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
6077
+ } else {
6078
+ const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
6079
+ log2(state, message, "warn");
6080
+ if (state.interactive && !state.json) {
6081
+ logActivity(state, { type: "info", level: "warn", message });
6082
+ }
6083
+ }
6084
+ } else {
6085
+ log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
6086
+ }
5666
6087
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
5667
6088
  try {
5668
6089
  const oc = await ensureOpenCodeRunning({
@@ -5741,6 +6162,18 @@ async function run(options) {
5741
6162
  type: "info",
5742
6163
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
5743
6164
  });
6165
+ if (options.tunnelReadyFile) {
6166
+ const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
6167
+ if (marker.ok) {
6168
+ log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
6169
+ } else {
6170
+ log2(
6171
+ state,
6172
+ `Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
6173
+ "error"
6174
+ );
6175
+ }
6176
+ }
5744
6177
  emitAgentConnected(state.agentId, {
5745
6178
  port: state.port,
5746
6179
  cli_version: getCliVersion(),
@@ -5905,6 +6338,9 @@ program.command("run").description("Connect to Evident and process messages").op
5905
6338
  "Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
5906
6339
  (value, previous) => previous.concat([value]),
5907
6340
  []
6341
+ ).option(
6342
+ "--tunnel-ready-file <path>",
6343
+ "Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
5908
6344
  ).action(
5909
6345
  (options) => {
5910
6346
  run({
@@ -5924,7 +6360,8 @@ program.command("run").description("Connect to Evident and process messages").op
5924
6360
  sessionCleanupInterval: options.sessionCleanupInterval,
5925
6361
  // Raw values — expansion/validation is single-sourced in run.ts's
5926
6362
  // resolveFileSyncDirectories.
5927
- enableFileSyncTo: options.enableFileSyncTo
6363
+ enableFileSyncTo: options.enableFileSyncTo,
6364
+ tunnelReadyFile: options.tunnelReadyFile
5928
6365
  });
5929
6366
  }
5930
6367
  );