@evident-ai/cli 3.1.1-dev.8dd11a1 → 3.1.1-dev.92063de
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 +375 -63
- 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);
|
|
@@ -2073,6 +2076,18 @@ var RunnerConnection = class {
|
|
|
2073
2076
|
}
|
|
2074
2077
|
};
|
|
2075
2078
|
|
|
2079
|
+
// src/lib/tunnel/ready-marker.ts
|
|
2080
|
+
import { writeFileSync } from "fs";
|
|
2081
|
+
function writeTunnelReadyMarker(path, agentId) {
|
|
2082
|
+
try {
|
|
2083
|
+
writeFileSync(path, `${agentId}
|
|
2084
|
+
`);
|
|
2085
|
+
return { ok: true };
|
|
2086
|
+
} catch (error2) {
|
|
2087
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2076
2091
|
// src/lib/channels/driver.ts
|
|
2077
2092
|
import { homedir } from "os";
|
|
2078
2093
|
|
|
@@ -2499,6 +2514,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
|
2499
2514
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
2500
2515
|
var HEARTBEAT_MS = 6e4;
|
|
2501
2516
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
2517
|
+
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
2518
|
+
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
2502
2519
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2503
2520
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
2504
2521
|
var ChannelAuthError = class extends Error {
|
|
@@ -3317,12 +3334,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3317
3334
|
stuckReported: false,
|
|
3318
3335
|
lastAliveAt: 0,
|
|
3319
3336
|
aliveInFlight: false,
|
|
3337
|
+
titleSynced: false,
|
|
3338
|
+
titleSyncInFlight: false,
|
|
3320
3339
|
awaitingHumanLatched: false,
|
|
3321
3340
|
pausedOnQuestion: false,
|
|
3322
3341
|
pausedOnPermission: false,
|
|
3323
3342
|
pausedClearConfirmed: false,
|
|
3324
3343
|
pausedInFlight: false,
|
|
3325
|
-
deliveryDeadlineAnchored: false
|
|
3344
|
+
deliveryDeadlineAnchored: false,
|
|
3345
|
+
b2PinnedSinceMs: 0,
|
|
3346
|
+
b2LastDescendantCheckMs: 0,
|
|
3347
|
+
b2AbandonedSignalled: false
|
|
3326
3348
|
});
|
|
3327
3349
|
}
|
|
3328
3350
|
/**
|
|
@@ -3390,12 +3412,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3390
3412
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
3391
3413
|
lastAliveAt: 0,
|
|
3392
3414
|
aliveInFlight: false,
|
|
3415
|
+
titleSynced: false,
|
|
3416
|
+
titleSyncInFlight: false,
|
|
3393
3417
|
awaitingHumanLatched: false,
|
|
3394
3418
|
pausedOnQuestion: false,
|
|
3395
3419
|
pausedOnPermission: false,
|
|
3396
3420
|
pausedClearConfirmed: false,
|
|
3397
3421
|
pausedInFlight: false,
|
|
3398
|
-
deliveryDeadlineAnchored: false
|
|
3422
|
+
deliveryDeadlineAnchored: false,
|
|
3423
|
+
b2PinnedSinceMs: 0,
|
|
3424
|
+
b2LastDescendantCheckMs: 0,
|
|
3425
|
+
b2AbandonedSignalled: false
|
|
3399
3426
|
});
|
|
3400
3427
|
}
|
|
3401
3428
|
/**
|
|
@@ -3557,58 +3584,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3557
3584
|
}
|
|
3558
3585
|
}
|
|
3559
3586
|
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);
|
|
3587
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3612
3588
|
return;
|
|
3613
3589
|
}
|
|
3614
3590
|
if (state === "failed") {
|
|
@@ -3668,6 +3644,44 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3668
3644
|
});
|
|
3669
3645
|
}
|
|
3670
3646
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
3647
|
+
const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
|
|
3648
|
+
const snapshotReadable = messages != null && messages.length > 0;
|
|
3649
|
+
if (!pinnedNow) {
|
|
3650
|
+
if (snapshotReadable) {
|
|
3651
|
+
inFlight.b2PinnedSinceMs = 0;
|
|
3652
|
+
inFlight.b2LastDescendantCheckMs = 0;
|
|
3653
|
+
inFlight.b2AbandonedSignalled = false;
|
|
3654
|
+
}
|
|
3655
|
+
} else {
|
|
3656
|
+
if (inFlight.b2AbandonedSignalled) {
|
|
3657
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3660
|
+
if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
|
|
3661
|
+
const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
|
|
3662
|
+
if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
|
|
3663
|
+
inFlight.b2LastDescendantCheckMs = this.now();
|
|
3664
|
+
const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
|
|
3665
|
+
if (isB2AbandonmentConfirmed({
|
|
3666
|
+
pinnedForMs,
|
|
3667
|
+
minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
|
|
3668
|
+
descendantOngoing
|
|
3669
|
+
})) {
|
|
3670
|
+
inFlight.b2AbandonedSignalled = true;
|
|
3671
|
+
this.log({
|
|
3672
|
+
level: "warn",
|
|
3673
|
+
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`,
|
|
3674
|
+
conversation_id: conv.id,
|
|
3675
|
+
message_id: id
|
|
3676
|
+
});
|
|
3677
|
+
void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
|
|
3678
|
+
watched_for_ms: pinnedForMs
|
|
3679
|
+
});
|
|
3680
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3671
3685
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
3672
3686
|
this.log({
|
|
3673
3687
|
level: "warn",
|
|
@@ -3687,6 +3701,18 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3687
3701
|
inFlight.aliveInFlight = false;
|
|
3688
3702
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
3689
3703
|
});
|
|
3704
|
+
if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
|
|
3705
|
+
inFlight.titleSyncInFlight = true;
|
|
3706
|
+
void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
|
|
3707
|
+
if (!title) {
|
|
3708
|
+
inFlight.titleSyncInFlight = false;
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
const ok = await this.patchConversationTitle(conv.id, title);
|
|
3712
|
+
inFlight.titleSyncInFlight = false;
|
|
3713
|
+
if (ok) inFlight.titleSynced = true;
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3690
3716
|
}
|
|
3691
3717
|
if (awaitingHuman) {
|
|
3692
3718
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -3724,6 +3750,70 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3724
3750
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3725
3751
|
}
|
|
3726
3752
|
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Settle a message whose run-state has resolved `'done'` — extracted verbatim
|
|
3755
|
+
* (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
|
|
3756
|
+
* inline `state === 'done'` branch body, so a SECOND caller (the #721
|
|
3757
|
+
* b2-abandonment resolution) can reach the exact same completion behavior
|
|
3758
|
+
* (delivery-deadline anchoring, title resolution, usage extraction, and
|
|
3759
|
+
* `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
|
|
3760
|
+
* and risking the two copies silently drifting apart.
|
|
3761
|
+
*/
|
|
3762
|
+
async settleMessageDone(sessionId, watcher, inFlight, messages) {
|
|
3763
|
+
const conv = watcher.conv;
|
|
3764
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
3765
|
+
if (!inFlight.done) {
|
|
3766
|
+
this.log({
|
|
3767
|
+
level: "info",
|
|
3768
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
3769
|
+
conversation_id: conv.id,
|
|
3770
|
+
message_id: inFlight.evidentMessageId
|
|
3771
|
+
});
|
|
3772
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3773
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
3774
|
+
try {
|
|
3775
|
+
await this.markDone(
|
|
3776
|
+
conv.id,
|
|
3777
|
+
inFlight.evidentMessageId,
|
|
3778
|
+
sessionId,
|
|
3779
|
+
inFlight.opencodeMessageId,
|
|
3780
|
+
title,
|
|
3781
|
+
usage
|
|
3782
|
+
);
|
|
3783
|
+
} catch (err) {
|
|
3784
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
3785
|
+
if (err instanceof ChannelTerminalError) {
|
|
3786
|
+
this.log({
|
|
3787
|
+
level: "warn",
|
|
3788
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
3789
|
+
conversation_id: conv.id,
|
|
3790
|
+
message_id: inFlight.evidentMessageId
|
|
3791
|
+
});
|
|
3792
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
if (this.now() >= inFlight.deadline) {
|
|
3796
|
+
this.log({
|
|
3797
|
+
level: "warn",
|
|
3798
|
+
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)}`,
|
|
3799
|
+
conversation_id: conv.id,
|
|
3800
|
+
message_id: inFlight.evidentMessageId
|
|
3801
|
+
});
|
|
3802
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
this.log({
|
|
3806
|
+
level: "warn",
|
|
3807
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
3808
|
+
conversation_id: conv.id,
|
|
3809
|
+
message_id: inFlight.evidentMessageId
|
|
3810
|
+
});
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
inFlight.done = true;
|
|
3814
|
+
}
|
|
3815
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3816
|
+
}
|
|
3727
3817
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
3728
3818
|
/**
|
|
3729
3819
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
@@ -4303,6 +4393,47 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4303
4393
|
}
|
|
4304
4394
|
return false;
|
|
4305
4395
|
}
|
|
4396
|
+
/**
|
|
4397
|
+
* Tri-state variant of the upward parentID membership walk (#721), used ONLY
|
|
4398
|
+
* by `isAnyDescendantSessionOngoing`. Walks the SAME cached
|
|
4399
|
+
* `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
|
|
4400
|
+
* `sessionBelongsTo`, which deliberately collapses "confirmed not a
|
|
4401
|
+
* descendant" and "the walk's fetch failed" into the same `false` (safe for
|
|
4402
|
+
* its OTHER callers: interaction attribution and the recovery-path
|
|
4403
|
+
* `isAnyDescendantSessionAlive`, both of which just retry next tick with no
|
|
4404
|
+
* safety consequence either way) — this variant keeps those two outcomes
|
|
4405
|
+
* SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
|
|
4406
|
+
* (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
|
|
4407
|
+
* not ongoing".
|
|
4408
|
+
*
|
|
4409
|
+
* Return contract:
|
|
4410
|
+
* - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
|
|
4411
|
+
* - `false` → the walk reached a definitive, parent-less root session
|
|
4412
|
+
* WITHOUT ever matching `rootSessionId` — `sessionId` is
|
|
4413
|
+
* CONFIRMED NOT a descendant of it.
|
|
4414
|
+
* - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
|
|
4415
|
+
* through the walk (`resolveSessionParent` returned `undefined`),
|
|
4416
|
+
* or the depth cap (32) was hit without a definitive answer (a
|
|
4417
|
+
* pathological/cyclic chain proves nothing either way). NEVER
|
|
4418
|
+
* treat this the same as `false` — see `sessionBelongsTo`'s own
|
|
4419
|
+
* doc comment above for why that collapse is safe THERE but not
|
|
4420
|
+
* here.
|
|
4421
|
+
*
|
|
4422
|
+
* `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
|
|
4423
|
+
* to the live-path descendant check, not a modification of shared code used
|
|
4424
|
+
* by interaction attribution or the recovery path.
|
|
4425
|
+
*/
|
|
4426
|
+
async resolveSessionMembership(sessionId, rootSessionId) {
|
|
4427
|
+
let current = sessionId;
|
|
4428
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
4429
|
+
if (current === rootSessionId) return true;
|
|
4430
|
+
const parent = await this.resolveSessionParent(current);
|
|
4431
|
+
if (parent === void 0) return null;
|
|
4432
|
+
if (parent === null) return false;
|
|
4433
|
+
current = parent;
|
|
4434
|
+
}
|
|
4435
|
+
return null;
|
|
4436
|
+
}
|
|
4306
4437
|
/**
|
|
4307
4438
|
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
4308
4439
|
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
@@ -4387,6 +4518,54 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4387
4518
|
}
|
|
4388
4519
|
return null;
|
|
4389
4520
|
}
|
|
4521
|
+
/**
|
|
4522
|
+
* Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
|
|
4523
|
+
* session title onto the conversation via the PLAIN conversation-update
|
|
4524
|
+
* endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
|
|
4525
|
+
* message-status endpoint `markProcessing`/`markDone` use. Deliberately a
|
|
4526
|
+
* separate, lighter call: it carries no `status`, so it cannot re-trigger the
|
|
4527
|
+
* `processing`/`done` transition side effects (Slack notices, activity-log
|
|
4528
|
+
* rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
|
|
4529
|
+
* ever touches `conversations.title`. That route (`routes/conversations.ts`)
|
|
4530
|
+
* skips a title write matching the stored value, so a redundant call with the
|
|
4531
|
+
* same title is a real no-op — it does not bump `updated_at`, which the
|
|
4532
|
+
* conversation list sorts and paginates on. (Note this is a DIFFERENT guard
|
|
4533
|
+
* from `threads.ts`'s "non-empty AND changed" one, which only covers the
|
|
4534
|
+
* message-status PATCH; the non-empty half is enforced here instead, by
|
|
4535
|
+
* `resolveSessionTitle` never returning an empty/placeholder title.)
|
|
4536
|
+
*
|
|
4537
|
+
* Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
|
|
4538
|
+
* is logged and the title is simply retried on the next heartbeat tick (the
|
|
4539
|
+
* caller only latches `titleSynced` on `true`).
|
|
4540
|
+
*/
|
|
4541
|
+
async patchConversationTitle(conversationId, title) {
|
|
4542
|
+
try {
|
|
4543
|
+
const res = await this.fetchImpl(
|
|
4544
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
|
|
4545
|
+
{
|
|
4546
|
+
method: "PATCH",
|
|
4547
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
4548
|
+
body: JSON.stringify({ title })
|
|
4549
|
+
}
|
|
4550
|
+
);
|
|
4551
|
+
if (!res.ok) {
|
|
4552
|
+
this.log({
|
|
4553
|
+
level: "debug",
|
|
4554
|
+
message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
|
|
4555
|
+
conversation_id: conversationId
|
|
4556
|
+
});
|
|
4557
|
+
return false;
|
|
4558
|
+
}
|
|
4559
|
+
return true;
|
|
4560
|
+
} catch (err) {
|
|
4561
|
+
this.log({
|
|
4562
|
+
level: "debug",
|
|
4563
|
+
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)}`,
|
|
4564
|
+
conversation_id: conversationId
|
|
4565
|
+
});
|
|
4566
|
+
return false;
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4390
4569
|
/**
|
|
4391
4570
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
4392
4571
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -4445,6 +4624,84 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4445
4624
|
}
|
|
4446
4625
|
return false;
|
|
4447
4626
|
}
|
|
4627
|
+
/**
|
|
4628
|
+
* LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
|
|
4629
|
+
* sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
|
|
4630
|
+
* in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
|
|
4631
|
+
*
|
|
4632
|
+
* Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
|
|
4633
|
+
* cross-check above): that method judges liveness from the child's OWN
|
|
4634
|
+
* TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
|
|
4635
|
+
* on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
|
|
4636
|
+
* path the local opencode server IS running, so its in-memory status map is
|
|
4637
|
+
* live and authoritative — and per ADR-0047 §4a ("the child has its own entry
|
|
4638
|
+
* [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
|
|
4639
|
+
* ENTIRE turn (including any tool call it is itself executing), not a
|
|
4640
|
+
* per-message transcript snapshot. This sidesteps the "child's own tool is
|
|
4641
|
+
* executing, between its step's completion and the next generation step"
|
|
4642
|
+
* transcript gap that a transcript-based check would need a second,
|
|
4643
|
+
* sustained-window bound to guard against — it is simply not derived from
|
|
4644
|
+
* message timestamps at all.
|
|
4645
|
+
*
|
|
4646
|
+
* Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
|
|
4647
|
+
* status, as the recovery path does per §4a)? Because on the LIVE path the
|
|
4648
|
+
* root session can be shared: a SECOND, unrelated user message can land on the
|
|
4649
|
+
* SAME session (issue #721's own root cause) and keep the root `busy` for a
|
|
4650
|
+
* reason that has nothing to do with THIS message's delegation. A `task`
|
|
4651
|
+
* descendant session is spawned for exactly one delegated turn and never
|
|
4652
|
+
* reused, so its OWN status-map entry is unambiguous evidence about that one
|
|
4653
|
+
* delegation — which the root's status is not.
|
|
4654
|
+
*
|
|
4655
|
+
* Why membership is checked via `resolveSessionMembership`, NOT
|
|
4656
|
+
* `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
|
|
4657
|
+
* `GET /session/:id` fetch failure into "not a descendant", which would
|
|
4658
|
+
* silently drop a genuinely-live candidate from consideration on the one
|
|
4659
|
+
* unlucky tick its membership-walk fetch hiccups (#721).
|
|
4660
|
+
* `resolveSessionMembership` keeps that failure mode as a distinct `null`
|
|
4661
|
+
* (indeterminate) so it is folded into THIS method's own `indeterminate` flag
|
|
4662
|
+
* instead.
|
|
4663
|
+
*
|
|
4664
|
+
* Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
|
|
4665
|
+
* - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
|
|
4666
|
+
* - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
|
|
4667
|
+
* confirmed either way (`resolveSessionMembership` never
|
|
4668
|
+
* returned `null`), and every CONFIRMED descendant's status read
|
|
4669
|
+
* succeeded and is not ongoing (includes "no descendant session
|
|
4670
|
+
* exists at all" — e.g. a plain, non-`task` tool call).
|
|
4671
|
+
* - `null` → INDETERMINATE: `listSessions` failed, OR at least one
|
|
4672
|
+
* candidate's MEMBERSHIP could not be confirmed
|
|
4673
|
+
* (`resolveSessionMembership` returned `null` — a fetch failure
|
|
4674
|
+
* or pathological chain partway through the parent walk), OR at
|
|
4675
|
+
* least one CONFIRMED descendant's `isSessionOngoing` read
|
|
4676
|
+
* failed — and no OTHER candidate was already confirmed `true`.
|
|
4677
|
+
* The caller MUST NOT treat `null` the same as `false` here
|
|
4678
|
+
* (unlike the recovery cross-check's contract) — see
|
|
4679
|
+
* `isB2AbandonmentConfirmed`.
|
|
4680
|
+
*/
|
|
4681
|
+
async isAnyDescendantSessionOngoing(rootSessionId) {
|
|
4682
|
+
const sessions = await listSessions(this.port);
|
|
4683
|
+
if (!sessions) {
|
|
4684
|
+
this.log({
|
|
4685
|
+
level: "warn",
|
|
4686
|
+
message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
|
|
4687
|
+
});
|
|
4688
|
+
return null;
|
|
4689
|
+
}
|
|
4690
|
+
let indeterminate = false;
|
|
4691
|
+
for (const candidate of sessions) {
|
|
4692
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
4693
|
+
const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
|
|
4694
|
+
if (membership === null) {
|
|
4695
|
+
indeterminate = true;
|
|
4696
|
+
continue;
|
|
4697
|
+
}
|
|
4698
|
+
if (membership === false) continue;
|
|
4699
|
+
const ongoing = await isSessionOngoing(this.port, candidate.id);
|
|
4700
|
+
if (ongoing === true) return true;
|
|
4701
|
+
if (ongoing === null) indeterminate = true;
|
|
4702
|
+
}
|
|
4703
|
+
return indeterminate ? null : false;
|
|
4704
|
+
}
|
|
4448
4705
|
/**
|
|
4449
4706
|
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
4450
4707
|
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
@@ -4905,7 +5162,7 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
4905
5162
|
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
4906
5163
|
console.log(
|
|
4907
5164
|
chalk5.dim(
|
|
4908
|
-
` ${getCliName()} run --
|
|
5165
|
+
` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
4909
5166
|
)
|
|
4910
5167
|
);
|
|
4911
5168
|
}
|
|
@@ -5035,21 +5292,21 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
5035
5292
|
return { agent_id: data.agent_id };
|
|
5036
5293
|
}
|
|
5037
5294
|
return {
|
|
5038
|
-
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --
|
|
5295
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
5039
5296
|
};
|
|
5040
5297
|
} catch (error2) {
|
|
5041
5298
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5042
5299
|
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5043
5300
|
}
|
|
5044
5301
|
}
|
|
5045
|
-
var
|
|
5302
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
5046
5303
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5047
5304
|
const apiUrl = getApiUrlConfig();
|
|
5048
5305
|
try {
|
|
5049
5306
|
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5050
5307
|
method: "POST",
|
|
5051
5308
|
headers: { Authorization: authHeader },
|
|
5052
|
-
signal: AbortSignal.timeout(
|
|
5309
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5053
5310
|
});
|
|
5054
5311
|
if (!response.ok) {
|
|
5055
5312
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -5060,11 +5317,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
5060
5317
|
}
|
|
5061
5318
|
return { ok: true };
|
|
5062
5319
|
} catch (error2) {
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5320
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
function describeBestEffortError(error2) {
|
|
5324
|
+
const name = error2?.name;
|
|
5325
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
5326
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5327
|
+
}
|
|
5328
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
5329
|
+
}
|
|
5330
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5331
|
+
try {
|
|
5332
|
+
const apiUrl = getApiUrlConfig();
|
|
5333
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5334
|
+
method: "POST",
|
|
5335
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5336
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5337
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5338
|
+
});
|
|
5339
|
+
if (!response.ok) {
|
|
5340
|
+
const serverMessage = await readErrorMessage(response);
|
|
5341
|
+
return {
|
|
5342
|
+
ok: false,
|
|
5343
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5344
|
+
};
|
|
5066
5345
|
}
|
|
5067
|
-
return { ok:
|
|
5346
|
+
return { ok: true };
|
|
5347
|
+
} catch (error2) {
|
|
5348
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5068
5349
|
}
|
|
5069
5350
|
}
|
|
5070
5351
|
async function getAgentInfo(agentId, authHeader) {
|
|
@@ -5714,6 +5995,21 @@ async function run(options) {
|
|
|
5714
5995
|
}
|
|
5715
5996
|
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
5716
5997
|
state.agentName = validation.agent.name;
|
|
5998
|
+
const microvmId = process.env.MICROVM_ID?.trim();
|
|
5999
|
+
if (microvmId) {
|
|
6000
|
+
const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
|
|
6001
|
+
if (reported.ok) {
|
|
6002
|
+
log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
|
|
6003
|
+
} else {
|
|
6004
|
+
const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
|
|
6005
|
+
log2(state, message, "warn");
|
|
6006
|
+
if (state.interactive && !state.json) {
|
|
6007
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
6008
|
+
}
|
|
6009
|
+
}
|
|
6010
|
+
} else {
|
|
6011
|
+
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
6012
|
+
}
|
|
5717
6013
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
5718
6014
|
try {
|
|
5719
6015
|
const oc = await ensureOpenCodeRunning({
|
|
@@ -5792,6 +6088,18 @@ async function run(options) {
|
|
|
5792
6088
|
type: "info",
|
|
5793
6089
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
5794
6090
|
});
|
|
6091
|
+
if (options.tunnelReadyFile) {
|
|
6092
|
+
const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
|
|
6093
|
+
if (marker.ok) {
|
|
6094
|
+
log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
|
|
6095
|
+
} else {
|
|
6096
|
+
log2(
|
|
6097
|
+
state,
|
|
6098
|
+
`Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
|
|
6099
|
+
"error"
|
|
6100
|
+
);
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
5795
6103
|
emitAgentConnected(state.agentId, {
|
|
5796
6104
|
port: state.port,
|
|
5797
6105
|
cli_version: getCliVersion(),
|
|
@@ -5956,6 +6264,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
5956
6264
|
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
5957
6265
|
(value, previous) => previous.concat([value]),
|
|
5958
6266
|
[]
|
|
6267
|
+
).option(
|
|
6268
|
+
"--tunnel-ready-file <path>",
|
|
6269
|
+
"Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
|
|
5959
6270
|
).action(
|
|
5960
6271
|
(options) => {
|
|
5961
6272
|
run({
|
|
@@ -5975,7 +6286,8 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
5975
6286
|
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
5976
6287
|
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
5977
6288
|
// resolveFileSyncDirectories.
|
|
5978
|
-
enableFileSyncTo: options.enableFileSyncTo
|
|
6289
|
+
enableFileSyncTo: options.enableFileSyncTo,
|
|
6290
|
+
tunnelReadyFile: options.tunnelReadyFile
|
|
5979
6291
|
});
|
|
5980
6292
|
}
|
|
5981
6293
|
);
|