@evident-ai/cli 3.1.1-dev.6a74c3d → 3.1.1-dev.6db490b
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 +397 -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.
|
|
@@ -5042,12 +5287,42 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
5042
5287
|
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5043
5288
|
}
|
|
5044
5289
|
}
|
|
5290
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
5045
5291
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5046
5292
|
const apiUrl = getApiUrlConfig();
|
|
5047
5293
|
try {
|
|
5048
5294
|
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5049
5295
|
method: "POST",
|
|
5050
|
-
headers: { Authorization: authHeader }
|
|
5296
|
+
headers: { Authorization: authHeader },
|
|
5297
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5298
|
+
});
|
|
5299
|
+
if (!response.ok) {
|
|
5300
|
+
const serverMessage = await readErrorMessage(response);
|
|
5301
|
+
return {
|
|
5302
|
+
ok: false,
|
|
5303
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5304
|
+
};
|
|
5305
|
+
}
|
|
5306
|
+
return { ok: true };
|
|
5307
|
+
} catch (error2) {
|
|
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)
|
|
5051
5326
|
});
|
|
5052
5327
|
if (!response.ok) {
|
|
5053
5328
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -5058,7 +5333,7 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
5058
5333
|
}
|
|
5059
5334
|
return { ok: true };
|
|
5060
5335
|
} catch (error2) {
|
|
5061
|
-
return { ok: false, error:
|
|
5336
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5062
5337
|
}
|
|
5063
5338
|
}
|
|
5064
5339
|
async function getAgentInfo(agentId, authHeader) {
|
|
@@ -5108,6 +5383,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
5108
5383
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
5109
5384
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
5110
5385
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
5386
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
5111
5387
|
function resolveLogLevel(options) {
|
|
5112
5388
|
const accepted = Object.keys(LOG_LEVELS);
|
|
5113
5389
|
const validate = (value, source) => {
|
|
@@ -5434,7 +5710,18 @@ async function notifyOffline(state) {
|
|
|
5434
5710
|
if (state.interactive) displayStatus(state);
|
|
5435
5711
|
}
|
|
5436
5712
|
}
|
|
5713
|
+
async function timeShutdownPhase(state, durations, name, run2) {
|
|
5714
|
+
const startedAt = Date.now();
|
|
5715
|
+
try {
|
|
5716
|
+
return await run2();
|
|
5717
|
+
} finally {
|
|
5718
|
+
const elapsedMs = Date.now() - startedAt;
|
|
5719
|
+
durations[name] = elapsedMs;
|
|
5720
|
+
log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5437
5723
|
async function cleanup(state, opts = {}) {
|
|
5724
|
+
const durations = {};
|
|
5438
5725
|
state.running = false;
|
|
5439
5726
|
for (const timer of state.sessionCleanupTimers) {
|
|
5440
5727
|
clearInterval(timer);
|
|
@@ -5448,7 +5735,13 @@ async function cleanup(state, opts = {}) {
|
|
|
5448
5735
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
5449
5736
|
displayStatus(state);
|
|
5450
5737
|
}
|
|
5451
|
-
const
|
|
5738
|
+
const driver = state.channelDriver;
|
|
5739
|
+
const settled = await timeShutdownPhase(
|
|
5740
|
+
state,
|
|
5741
|
+
durations,
|
|
5742
|
+
"drain",
|
|
5743
|
+
() => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
5744
|
+
);
|
|
5452
5745
|
if (!settled) {
|
|
5453
5746
|
logActivity(state, {
|
|
5454
5747
|
type: "info",
|
|
@@ -5457,13 +5750,15 @@ async function cleanup(state, opts = {}) {
|
|
|
5457
5750
|
if (state.interactive) displayStatus(state);
|
|
5458
5751
|
}
|
|
5459
5752
|
}
|
|
5460
|
-
await notifyOffline(state);
|
|
5753
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
5461
5754
|
if (state.connection) {
|
|
5462
|
-
state.connection
|
|
5755
|
+
const connection = state.connection;
|
|
5756
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
5463
5757
|
state.connection = null;
|
|
5464
5758
|
}
|
|
5465
5759
|
if (state.opencodeProcess) {
|
|
5466
|
-
|
|
5760
|
+
const opencodeProcess = state.opencodeProcess;
|
|
5761
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
5467
5762
|
if (state.interactive) {
|
|
5468
5763
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
5469
5764
|
displayStatus(state);
|
|
@@ -5472,6 +5767,7 @@ async function cleanup(state, opts = {}) {
|
|
|
5472
5767
|
}
|
|
5473
5768
|
state.opencodeProcess = null;
|
|
5474
5769
|
}
|
|
5770
|
+
return durations;
|
|
5475
5771
|
}
|
|
5476
5772
|
async function run(options) {
|
|
5477
5773
|
const interactive = isInteractive(options.json);
|
|
@@ -5542,14 +5838,38 @@ async function run(options) {
|
|
|
5542
5838
|
const handleSignal = async () => {
|
|
5543
5839
|
if (state.shuttingDown) return;
|
|
5544
5840
|
state.shuttingDown = true;
|
|
5841
|
+
const shutdownStartedAt = Date.now();
|
|
5545
5842
|
if (state.interactive) {
|
|
5546
5843
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
5547
5844
|
displayStatus(state);
|
|
5548
5845
|
} else {
|
|
5549
5846
|
log2(state, "Shutting down...");
|
|
5550
5847
|
}
|
|
5551
|
-
await cleanup(state, { graceful: true });
|
|
5552
|
-
|
|
5848
|
+
const durations = await cleanup(state, { graceful: true });
|
|
5849
|
+
const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
|
|
5850
|
+
await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
|
|
5851
|
+
let timer;
|
|
5852
|
+
const flushed = shutdownTelemetry().then(
|
|
5853
|
+
() => true,
|
|
5854
|
+
(error2) => {
|
|
5855
|
+
log2(
|
|
5856
|
+
state,
|
|
5857
|
+
`Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
5858
|
+
"warn"
|
|
5859
|
+
);
|
|
5860
|
+
return true;
|
|
5861
|
+
}
|
|
5862
|
+
);
|
|
5863
|
+
const timedOut = new Promise((resolve3) => {
|
|
5864
|
+
timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
|
|
5865
|
+
});
|
|
5866
|
+
if (!await Promise.race([flushed, timedOut])) {
|
|
5867
|
+
log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
|
|
5868
|
+
}
|
|
5869
|
+
clearTimeout(timer);
|
|
5870
|
+
});
|
|
5871
|
+
const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
|
|
5872
|
+
log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
|
|
5553
5873
|
process.exit(0);
|
|
5554
5874
|
};
|
|
5555
5875
|
process.on("SIGINT", handleSignal);
|
|
@@ -5663,6 +5983,21 @@ async function run(options) {
|
|
|
5663
5983
|
}
|
|
5664
5984
|
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
5665
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
|
+
}
|
|
5666
6001
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
5667
6002
|
try {
|
|
5668
6003
|
const oc = await ensureOpenCodeRunning({
|