@evident-ai/cli 3.1.1-dev.c3e7117 → 3.1.1-dev.c65cbfc
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 +346 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 --
|
|
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 --
|
|
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
|
|
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(
|
|
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
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
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:
|
|
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({
|