@evident-ai/cli 3.1.1-dev.94145fb → 3.1.1-dev.9639f77

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);
@@ -2499,6 +2502,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
2499
2502
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2500
2503
  var HEARTBEAT_MS = 6e4;
2501
2504
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2505
+ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2506
+ var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2502
2507
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2503
2508
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
2504
2509
  var ChannelAuthError = class extends Error {
@@ -3317,12 +3322,17 @@ var ChannelDriver = class _ChannelDriver {
3317
3322
  stuckReported: false,
3318
3323
  lastAliveAt: 0,
3319
3324
  aliveInFlight: false,
3325
+ titleSynced: false,
3326
+ titleSyncInFlight: false,
3320
3327
  awaitingHumanLatched: false,
3321
3328
  pausedOnQuestion: false,
3322
3329
  pausedOnPermission: false,
3323
3330
  pausedClearConfirmed: false,
3324
3331
  pausedInFlight: false,
3325
- deliveryDeadlineAnchored: false
3332
+ deliveryDeadlineAnchored: false,
3333
+ b2PinnedSinceMs: 0,
3334
+ b2LastDescendantCheckMs: 0,
3335
+ b2AbandonedSignalled: false
3326
3336
  });
3327
3337
  }
3328
3338
  /**
@@ -3390,12 +3400,17 @@ var ChannelDriver = class _ChannelDriver {
3390
3400
  // with no extra `re_adopted` signal needed (folds old WI-6).
3391
3401
  lastAliveAt: 0,
3392
3402
  aliveInFlight: false,
3403
+ titleSynced: false,
3404
+ titleSyncInFlight: false,
3393
3405
  awaitingHumanLatched: false,
3394
3406
  pausedOnQuestion: false,
3395
3407
  pausedOnPermission: false,
3396
3408
  pausedClearConfirmed: false,
3397
3409
  pausedInFlight: false,
3398
- deliveryDeadlineAnchored: false
3410
+ deliveryDeadlineAnchored: false,
3411
+ b2PinnedSinceMs: 0,
3412
+ b2LastDescendantCheckMs: 0,
3413
+ b2AbandonedSignalled: false
3399
3414
  });
3400
3415
  }
3401
3416
  /**
@@ -3557,58 +3572,7 @@ var ChannelDriver = class _ChannelDriver {
3557
3572
  }
3558
3573
  }
3559
3574
  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);
3575
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3612
3576
  return;
3613
3577
  }
3614
3578
  if (state === "failed") {
@@ -3668,6 +3632,44 @@ var ChannelDriver = class _ChannelDriver {
3668
3632
  });
3669
3633
  }
3670
3634
  const activelyRunning = state === "running" && !awaitingHuman;
3635
+ const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
3636
+ const snapshotReadable = messages != null && messages.length > 0;
3637
+ if (!pinnedNow) {
3638
+ if (snapshotReadable) {
3639
+ inFlight.b2PinnedSinceMs = 0;
3640
+ inFlight.b2LastDescendantCheckMs = 0;
3641
+ inFlight.b2AbandonedSignalled = false;
3642
+ }
3643
+ } else {
3644
+ if (inFlight.b2AbandonedSignalled) {
3645
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3646
+ return;
3647
+ }
3648
+ if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
3649
+ const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
3650
+ if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
3651
+ inFlight.b2LastDescendantCheckMs = this.now();
3652
+ const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
3653
+ if (isB2AbandonmentConfirmed({
3654
+ pinnedForMs,
3655
+ minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
3656
+ descendantOngoing
3657
+ })) {
3658
+ inFlight.b2AbandonedSignalled = true;
3659
+ this.log({
3660
+ level: "warn",
3661
+ 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`,
3662
+ conversation_id: conv.id,
3663
+ message_id: id
3664
+ });
3665
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
3666
+ watched_for_ms: pinnedForMs
3667
+ });
3668
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3669
+ return;
3670
+ }
3671
+ }
3672
+ }
3671
3673
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
3672
3674
  this.log({
3673
3675
  level: "warn",
@@ -3687,6 +3689,18 @@ var ChannelDriver = class _ChannelDriver {
3687
3689
  inFlight.aliveInFlight = false;
3688
3690
  if (ok) inFlight.lastAliveAt = this.now();
3689
3691
  });
3692
+ if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
3693
+ inFlight.titleSyncInFlight = true;
3694
+ void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
3695
+ if (!title) {
3696
+ inFlight.titleSyncInFlight = false;
3697
+ return;
3698
+ }
3699
+ const ok = await this.patchConversationTitle(conv.id, title);
3700
+ inFlight.titleSyncInFlight = false;
3701
+ if (ok) inFlight.titleSynced = true;
3702
+ });
3703
+ }
3690
3704
  }
3691
3705
  if (awaitingHuman) {
3692
3706
  if (!inFlight.awaitingHumanLatched) {
@@ -3724,6 +3738,70 @@ var ChannelDriver = class _ChannelDriver {
3724
3738
  this.removeInFlight(watcher, inFlight.evidentMessageId);
3725
3739
  }
3726
3740
  }
3741
+ /**
3742
+ * Settle a message whose run-state has resolved `'done'` — extracted verbatim
3743
+ * (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
3744
+ * inline `state === 'done'` branch body, so a SECOND caller (the #721
3745
+ * b2-abandonment resolution) can reach the exact same completion behavior
3746
+ * (delivery-deadline anchoring, title resolution, usage extraction, and
3747
+ * `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
3748
+ * and risking the two copies silently drifting apart.
3749
+ */
3750
+ async settleMessageDone(sessionId, watcher, inFlight, messages) {
3751
+ const conv = watcher.conv;
3752
+ this.anchorDeliveryDeadline(inFlight);
3753
+ if (!inFlight.done) {
3754
+ this.log({
3755
+ level: "info",
3756
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3757
+ conversation_id: conv.id,
3758
+ message_id: inFlight.evidentMessageId
3759
+ });
3760
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3761
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
3762
+ try {
3763
+ await this.markDone(
3764
+ conv.id,
3765
+ inFlight.evidentMessageId,
3766
+ sessionId,
3767
+ inFlight.opencodeMessageId,
3768
+ title,
3769
+ usage
3770
+ );
3771
+ } catch (err) {
3772
+ if (err instanceof ChannelAuthError) throw err;
3773
+ if (err instanceof ChannelTerminalError) {
3774
+ this.log({
3775
+ level: "warn",
3776
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3777
+ conversation_id: conv.id,
3778
+ message_id: inFlight.evidentMessageId
3779
+ });
3780
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3781
+ return;
3782
+ }
3783
+ if (this.now() >= inFlight.deadline) {
3784
+ this.log({
3785
+ level: "warn",
3786
+ 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)}`,
3787
+ conversation_id: conv.id,
3788
+ message_id: inFlight.evidentMessageId
3789
+ });
3790
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3791
+ return;
3792
+ }
3793
+ this.log({
3794
+ level: "warn",
3795
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3796
+ conversation_id: conv.id,
3797
+ message_id: inFlight.evidentMessageId
3798
+ });
3799
+ return;
3800
+ }
3801
+ inFlight.done = true;
3802
+ }
3803
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3804
+ }
3727
3805
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
3728
3806
  /**
3729
3807
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
@@ -4303,6 +4381,47 @@ var ChannelDriver = class _ChannelDriver {
4303
4381
  }
4304
4382
  return false;
4305
4383
  }
4384
+ /**
4385
+ * Tri-state variant of the upward parentID membership walk (#721), used ONLY
4386
+ * by `isAnyDescendantSessionOngoing`. Walks the SAME cached
4387
+ * `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
4388
+ * `sessionBelongsTo`, which deliberately collapses "confirmed not a
4389
+ * descendant" and "the walk's fetch failed" into the same `false` (safe for
4390
+ * its OTHER callers: interaction attribution and the recovery-path
4391
+ * `isAnyDescendantSessionAlive`, both of which just retry next tick with no
4392
+ * safety consequence either way) — this variant keeps those two outcomes
4393
+ * SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
4394
+ * (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
4395
+ * not ongoing".
4396
+ *
4397
+ * Return contract:
4398
+ * - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
4399
+ * - `false` → the walk reached a definitive, parent-less root session
4400
+ * WITHOUT ever matching `rootSessionId` — `sessionId` is
4401
+ * CONFIRMED NOT a descendant of it.
4402
+ * - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
4403
+ * through the walk (`resolveSessionParent` returned `undefined`),
4404
+ * or the depth cap (32) was hit without a definitive answer (a
4405
+ * pathological/cyclic chain proves nothing either way). NEVER
4406
+ * treat this the same as `false` — see `sessionBelongsTo`'s own
4407
+ * doc comment above for why that collapse is safe THERE but not
4408
+ * here.
4409
+ *
4410
+ * `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
4411
+ * to the live-path descendant check, not a modification of shared code used
4412
+ * by interaction attribution or the recovery path.
4413
+ */
4414
+ async resolveSessionMembership(sessionId, rootSessionId) {
4415
+ let current = sessionId;
4416
+ for (let depth = 0; current && depth < 32; depth++) {
4417
+ if (current === rootSessionId) return true;
4418
+ const parent = await this.resolveSessionParent(current);
4419
+ if (parent === void 0) return null;
4420
+ if (parent === null) return false;
4421
+ current = parent;
4422
+ }
4423
+ return null;
4424
+ }
4306
4425
  /**
4307
4426
  * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
4308
4427
  * `null` for a root session (no parent) and `undefined` when opencode is
@@ -4387,6 +4506,54 @@ var ChannelDriver = class _ChannelDriver {
4387
4506
  }
4388
4507
  return null;
4389
4508
  }
4509
+ /**
4510
+ * Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
4511
+ * session title onto the conversation via the PLAIN conversation-update
4512
+ * endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
4513
+ * message-status endpoint `markProcessing`/`markDone` use. Deliberately a
4514
+ * separate, lighter call: it carries no `status`, so it cannot re-trigger the
4515
+ * `processing`/`done` transition side effects (Slack notices, activity-log
4516
+ * rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
4517
+ * ever touches `conversations.title`. That route (`routes/conversations.ts`)
4518
+ * skips a title write matching the stored value, so a redundant call with the
4519
+ * same title is a real no-op — it does not bump `updated_at`, which the
4520
+ * conversation list sorts and paginates on. (Note this is a DIFFERENT guard
4521
+ * from `threads.ts`'s "non-empty AND changed" one, which only covers the
4522
+ * message-status PATCH; the non-empty half is enforced here instead, by
4523
+ * `resolveSessionTitle` never returning an empty/placeholder title.)
4524
+ *
4525
+ * Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
4526
+ * is logged and the title is simply retried on the next heartbeat tick (the
4527
+ * caller only latches `titleSynced` on `true`).
4528
+ */
4529
+ async patchConversationTitle(conversationId, title) {
4530
+ try {
4531
+ const res = await this.fetchImpl(
4532
+ `${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
4533
+ {
4534
+ method: "PATCH",
4535
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
4536
+ body: JSON.stringify({ title })
4537
+ }
4538
+ );
4539
+ if (!res.ok) {
4540
+ this.log({
4541
+ level: "debug",
4542
+ message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
4543
+ conversation_id: conversationId
4544
+ });
4545
+ return false;
4546
+ }
4547
+ return true;
4548
+ } catch (err) {
4549
+ this.log({
4550
+ level: "debug",
4551
+ 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)}`,
4552
+ conversation_id: conversationId
4553
+ });
4554
+ return false;
4555
+ }
4556
+ }
4390
4557
  /**
4391
4558
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
4392
4559
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -4445,6 +4612,84 @@ var ChannelDriver = class _ChannelDriver {
4445
4612
  }
4446
4613
  return false;
4447
4614
  }
4615
+ /**
4616
+ * LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
4617
+ * sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
4618
+ * in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
4619
+ *
4620
+ * Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
4621
+ * cross-check above): that method judges liveness from the child's OWN
4622
+ * TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
4623
+ * on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
4624
+ * path the local opencode server IS running, so its in-memory status map is
4625
+ * live and authoritative — and per ADR-0047 §4a ("the child has its own entry
4626
+ * [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
4627
+ * ENTIRE turn (including any tool call it is itself executing), not a
4628
+ * per-message transcript snapshot. This sidesteps the "child's own tool is
4629
+ * executing, between its step's completion and the next generation step"
4630
+ * transcript gap that a transcript-based check would need a second,
4631
+ * sustained-window bound to guard against — it is simply not derived from
4632
+ * message timestamps at all.
4633
+ *
4634
+ * Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
4635
+ * status, as the recovery path does per §4a)? Because on the LIVE path the
4636
+ * root session can be shared: a SECOND, unrelated user message can land on the
4637
+ * SAME session (issue #721's own root cause) and keep the root `busy` for a
4638
+ * reason that has nothing to do with THIS message's delegation. A `task`
4639
+ * descendant session is spawned for exactly one delegated turn and never
4640
+ * reused, so its OWN status-map entry is unambiguous evidence about that one
4641
+ * delegation — which the root's status is not.
4642
+ *
4643
+ * Why membership is checked via `resolveSessionMembership`, NOT
4644
+ * `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
4645
+ * `GET /session/:id` fetch failure into "not a descendant", which would
4646
+ * silently drop a genuinely-live candidate from consideration on the one
4647
+ * unlucky tick its membership-walk fetch hiccups (#721).
4648
+ * `resolveSessionMembership` keeps that failure mode as a distinct `null`
4649
+ * (indeterminate) so it is folded into THIS method's own `indeterminate` flag
4650
+ * instead.
4651
+ *
4652
+ * Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
4653
+ * - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
4654
+ * - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
4655
+ * confirmed either way (`resolveSessionMembership` never
4656
+ * returned `null`), and every CONFIRMED descendant's status read
4657
+ * succeeded and is not ongoing (includes "no descendant session
4658
+ * exists at all" — e.g. a plain, non-`task` tool call).
4659
+ * - `null` → INDETERMINATE: `listSessions` failed, OR at least one
4660
+ * candidate's MEMBERSHIP could not be confirmed
4661
+ * (`resolveSessionMembership` returned `null` — a fetch failure
4662
+ * or pathological chain partway through the parent walk), OR at
4663
+ * least one CONFIRMED descendant's `isSessionOngoing` read
4664
+ * failed — and no OTHER candidate was already confirmed `true`.
4665
+ * The caller MUST NOT treat `null` the same as `false` here
4666
+ * (unlike the recovery cross-check's contract) — see
4667
+ * `isB2AbandonmentConfirmed`.
4668
+ */
4669
+ async isAnyDescendantSessionOngoing(rootSessionId) {
4670
+ const sessions = await listSessions(this.port);
4671
+ if (!sessions) {
4672
+ this.log({
4673
+ level: "warn",
4674
+ message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
4675
+ });
4676
+ return null;
4677
+ }
4678
+ let indeterminate = false;
4679
+ for (const candidate of sessions) {
4680
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
4681
+ const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
4682
+ if (membership === null) {
4683
+ indeterminate = true;
4684
+ continue;
4685
+ }
4686
+ if (membership === false) continue;
4687
+ const ongoing = await isSessionOngoing(this.port, candidate.id);
4688
+ if (ongoing === true) return true;
4689
+ if (ongoing === null) indeterminate = true;
4690
+ }
4691
+ return indeterminate ? null : false;
4692
+ }
4448
4693
  /**
4449
4694
  * Cheap decision-telemetry label for a running row's LAST correlated reply
4450
4695
  * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
@@ -4905,7 +5150,7 @@ async function ensureOpenCodeRunning(ctx) {
4905
5150
  console.log(chalk5.yellow("Tip: Run with the correct port:"));
4906
5151
  console.log(
4907
5152
  chalk5.dim(
4908
- ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
5153
+ ` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
4909
5154
  )
4910
5155
  );
4911
5156
  }
@@ -5035,21 +5280,21 @@ async function resolveAgentIdFromKey(authHeader) {
5035
5280
  return { agent_id: data.agent_id };
5036
5281
  }
5037
5282
  return {
5038
- error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
5283
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
5039
5284
  };
5040
5285
  } catch (error2) {
5041
5286
  const message = error2 instanceof Error ? error2.message : "Unknown error";
5042
5287
  return { error: `Failed to resolve runner from key: ${message}` };
5043
5288
  }
5044
5289
  }
5045
- var OFFLINE_NOTIFY_TIMEOUT_MS = 2e3;
5290
+ var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
5046
5291
  async function notifyAgentDisconnected(agentId, authHeader) {
5047
5292
  const apiUrl = getApiUrlConfig();
5048
5293
  try {
5049
5294
  const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
5050
5295
  method: "POST",
5051
5296
  headers: { Authorization: authHeader },
5052
- signal: AbortSignal.timeout(OFFLINE_NOTIFY_TIMEOUT_MS)
5297
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5053
5298
  });
5054
5299
  if (!response.ok) {
5055
5300
  const serverMessage = await readErrorMessage(response);
@@ -5060,11 +5305,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
5060
5305
  }
5061
5306
  return { ok: true };
5062
5307
  } catch (error2) {
5063
- const name = error2?.name;
5064
- if (name === "TimeoutError" || name === "AbortError") {
5065
- return { ok: false, error: `timed out after ${OFFLINE_NOTIFY_TIMEOUT_MS}ms` };
5308
+ return { ok: false, error: describeBestEffortError(error2) };
5309
+ }
5310
+ }
5311
+ function describeBestEffortError(error2) {
5312
+ const name = error2?.name;
5313
+ if (name === "TimeoutError" || name === "AbortError") {
5314
+ return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5315
+ }
5316
+ return error2 instanceof Error ? error2.message : String(error2);
5317
+ }
5318
+ async function reportMicrovmId(agentId, authHeader, microvmId) {
5319
+ try {
5320
+ const apiUrl = getApiUrlConfig();
5321
+ const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5322
+ method: "POST",
5323
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
5324
+ body: JSON.stringify({ microvm_id: microvmId }),
5325
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5326
+ });
5327
+ if (!response.ok) {
5328
+ const serverMessage = await readErrorMessage(response);
5329
+ return {
5330
+ ok: false,
5331
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5332
+ };
5066
5333
  }
5067
- return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
5334
+ return { ok: true };
5335
+ } catch (error2) {
5336
+ return { ok: false, error: describeBestEffortError(error2) };
5068
5337
  }
5069
5338
  }
5070
5339
  async function getAgentInfo(agentId, authHeader) {
@@ -5714,6 +5983,21 @@ async function run(options) {
5714
5983
  }
5715
5984
  spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
5716
5985
  state.agentName = validation.agent.name;
5986
+ const microvmId = process.env.MICROVM_ID?.trim();
5987
+ if (microvmId) {
5988
+ const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
5989
+ if (reported.ok) {
5990
+ log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
5991
+ } else {
5992
+ const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
5993
+ log2(state, message, "warn");
5994
+ if (state.interactive && !state.json) {
5995
+ logActivity(state, { type: "info", level: "warn", message });
5996
+ }
5997
+ }
5998
+ } else {
5999
+ log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
6000
+ }
5717
6001
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
5718
6002
  try {
5719
6003
  const oc = await ensureOpenCodeRunning({