@evident-ai/cli 3.1.1-dev.7ab010b → 3.1.1-dev.7b93f9f
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 +449 -82
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,8 +11,8 @@ import chalk2 from "chalk";
|
|
|
11
11
|
|
|
12
12
|
// src/lib/config.ts
|
|
13
13
|
import Conf from "conf";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { chmodSync, existsSync, statSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
16
|
var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
|
|
17
17
|
var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
|
|
18
18
|
var defaults = {
|
|
@@ -47,8 +47,35 @@ var credentials = new Conf({
|
|
|
47
47
|
projectName: "evident",
|
|
48
48
|
projectSuffix: "",
|
|
49
49
|
configName: "credentials",
|
|
50
|
-
defaults: {}
|
|
50
|
+
defaults: {},
|
|
51
|
+
configFileMode: 384
|
|
51
52
|
});
|
|
53
|
+
var CREDENTIALS_FILE_MODE = 384;
|
|
54
|
+
var CREDENTIALS_DIR_MODE = 448;
|
|
55
|
+
var permissionWarningEmitted = false;
|
|
56
|
+
function hardenCredentialsPermissions() {
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const file = credentials.path;
|
|
61
|
+
for (const [path, mode] of [
|
|
62
|
+
[file, CREDENTIALS_FILE_MODE],
|
|
63
|
+
[dirname(file), CREDENTIALS_DIR_MODE]
|
|
64
|
+
]) {
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
|
|
67
|
+
chmodSync(path, mode);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!permissionWarningEmitted) {
|
|
71
|
+
permissionWarningEmitted = true;
|
|
72
|
+
console.error(
|
|
73
|
+
`[config] could not restrict permissions on ${path}; the credentials file may be readable by other users on this machine: ${err instanceof Error ? err.message : String(err)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
52
79
|
function getApiUrlConfig() {
|
|
53
80
|
return getApiUrl();
|
|
54
81
|
}
|
|
@@ -59,6 +86,7 @@ function credentialsKey() {
|
|
|
59
86
|
return getApiUrl();
|
|
60
87
|
}
|
|
61
88
|
function getCredentials() {
|
|
89
|
+
hardenCredentialsPermissions();
|
|
62
90
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
63
91
|
return byEndpoint[credentialsKey()] ?? {};
|
|
64
92
|
}
|
|
@@ -70,14 +98,17 @@ function setCredentials(creds) {
|
|
|
70
98
|
expiresAt: creds.expiresAt
|
|
71
99
|
};
|
|
72
100
|
credentials.set("byEndpoint", byEndpoint);
|
|
101
|
+
hardenCredentialsPermissions();
|
|
73
102
|
}
|
|
74
103
|
function clearCredentials() {
|
|
75
104
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
76
105
|
delete byEndpoint[credentialsKey()];
|
|
77
106
|
credentials.set("byEndpoint", byEndpoint);
|
|
107
|
+
hardenCredentialsPermissions();
|
|
78
108
|
}
|
|
79
109
|
function clearAllCredentials() {
|
|
80
110
|
credentials.clear();
|
|
111
|
+
hardenCredentialsPermissions();
|
|
81
112
|
}
|
|
82
113
|
function getCliName() {
|
|
83
114
|
const argv1 = process.argv[1] || "";
|
|
@@ -373,7 +404,8 @@ async function deviceFlowLogin(options) {
|
|
|
373
404
|
}
|
|
374
405
|
async function tokenLogin() {
|
|
375
406
|
console.log("Token login mode.");
|
|
376
|
-
console.log("
|
|
407
|
+
console.log("Run `evident login` on a machine with a browser to get a token.");
|
|
408
|
+
console.log("Manage or revoke existing tokens under Settings \u2192 CLI tokens.");
|
|
377
409
|
blank();
|
|
378
410
|
process.stdout.write("Paste token: ");
|
|
379
411
|
const token = await new Promise((resolve3) => {
|
|
@@ -467,8 +499,8 @@ async function whoami() {
|
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
// src/commands/run.ts
|
|
470
|
-
import { homedir as
|
|
471
|
-
import { isAbsolute as isAbsolute2, join as
|
|
502
|
+
import { homedir as homedir2 } from "os";
|
|
503
|
+
import { isAbsolute as isAbsolute2, join as join2, parse, resolve as resolvePath } from "path";
|
|
472
504
|
import chalk6 from "chalk";
|
|
473
505
|
|
|
474
506
|
// ../../packages/types/src/telemetry/index.ts
|
|
@@ -1500,6 +1532,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
1500
1532
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1501
1533
|
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1502
1534
|
}
|
|
1535
|
+
function isB2AbandonmentConfirmed(params) {
|
|
1536
|
+
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
1537
|
+
}
|
|
1503
1538
|
function messageError(messages, userMessageId) {
|
|
1504
1539
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1505
1540
|
const error2 = errorOf(reply);
|
|
@@ -2042,12 +2077,12 @@ var RunnerConnection = class {
|
|
|
2042
2077
|
};
|
|
2043
2078
|
|
|
2044
2079
|
// src/lib/channels/driver.ts
|
|
2045
|
-
import { homedir
|
|
2080
|
+
import { homedir } from "os";
|
|
2046
2081
|
|
|
2047
2082
|
// src/lib/file-push.ts
|
|
2048
2083
|
import { randomUUID } from "crypto";
|
|
2049
2084
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2050
|
-
import { basename, dirname, isAbsolute, join
|
|
2085
|
+
import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
|
|
2051
2086
|
var FILE_MODE = 384;
|
|
2052
2087
|
var DIRECTORY_MODE = 448;
|
|
2053
2088
|
async function writePushedFile(request) {
|
|
@@ -2078,9 +2113,9 @@ async function writePushedFile(request) {
|
|
|
2078
2113
|
}
|
|
2079
2114
|
try {
|
|
2080
2115
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2081
|
-
|
|
2116
|
+
dirname2(candidate)
|
|
2082
2117
|
);
|
|
2083
|
-
const realTarget =
|
|
2118
|
+
const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
|
|
2084
2119
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2085
2120
|
if (allowedDirectory === null) {
|
|
2086
2121
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
@@ -2090,8 +2125,8 @@ async function writePushedFile(request) {
|
|
|
2090
2125
|
}
|
|
2091
2126
|
if (missingSegments.length > 0) {
|
|
2092
2127
|
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2093
|
-
const realParent = await realpath(
|
|
2094
|
-
if (realParent !==
|
|
2128
|
+
const realParent = await realpath(dirname2(realTarget));
|
|
2129
|
+
if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2095
2130
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2096
2131
|
path: realTarget,
|
|
2097
2132
|
bytes,
|
|
@@ -2116,7 +2151,7 @@ function expandAndValidate(requestedPath, homeDir) {
|
|
|
2116
2151
|
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2117
2152
|
return null;
|
|
2118
2153
|
}
|
|
2119
|
-
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ?
|
|
2154
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2120
2155
|
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2121
2156
|
return null;
|
|
2122
2157
|
}
|
|
@@ -2134,7 +2169,7 @@ async function resolveNearestExistingAncestor(directory) {
|
|
|
2134
2169
|
try {
|
|
2135
2170
|
return { existingAncestor: await realpath(current), missingSegments };
|
|
2136
2171
|
} catch (err) {
|
|
2137
|
-
const parent =
|
|
2172
|
+
const parent = dirname2(current);
|
|
2138
2173
|
if (err.code !== "ENOENT" || parent === current) {
|
|
2139
2174
|
throw err;
|
|
2140
2175
|
}
|
|
@@ -2189,13 +2224,13 @@ function contains(realDirectory, realTarget) {
|
|
|
2189
2224
|
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2190
2225
|
let current = existingAncestor;
|
|
2191
2226
|
for (const segment of missingSegments) {
|
|
2192
|
-
current =
|
|
2227
|
+
current = join(current, segment);
|
|
2193
2228
|
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2194
2229
|
await chmod(current, DIRECTORY_MODE);
|
|
2195
2230
|
}
|
|
2196
2231
|
}
|
|
2197
2232
|
async function writeAtomically(realTarget, content) {
|
|
2198
|
-
const temporaryPath =
|
|
2233
|
+
const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2199
2234
|
let handle;
|
|
2200
2235
|
try {
|
|
2201
2236
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -2467,6 +2502,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
|
2467
2502
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
2468
2503
|
var HEARTBEAT_MS = 6e4;
|
|
2469
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;
|
|
2470
2507
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2471
2508
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
2472
2509
|
var ChannelAuthError = class extends Error {
|
|
@@ -2708,7 +2745,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2708
2745
|
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
2709
2746
|
this.now = config2.now ?? (() => Date.now());
|
|
2710
2747
|
this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
|
|
2711
|
-
this.homeDir = config2.homeDir ??
|
|
2748
|
+
this.homeDir = config2.homeDir ?? homedir();
|
|
2712
2749
|
}
|
|
2713
2750
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
2714
2751
|
get opencodeBase() {
|
|
@@ -3285,12 +3322,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3285
3322
|
stuckReported: false,
|
|
3286
3323
|
lastAliveAt: 0,
|
|
3287
3324
|
aliveInFlight: false,
|
|
3325
|
+
titleSynced: false,
|
|
3326
|
+
titleSyncInFlight: false,
|
|
3288
3327
|
awaitingHumanLatched: false,
|
|
3289
3328
|
pausedOnQuestion: false,
|
|
3290
3329
|
pausedOnPermission: false,
|
|
3291
3330
|
pausedClearConfirmed: false,
|
|
3292
3331
|
pausedInFlight: false,
|
|
3293
|
-
deliveryDeadlineAnchored: false
|
|
3332
|
+
deliveryDeadlineAnchored: false,
|
|
3333
|
+
b2PinnedSinceMs: 0,
|
|
3334
|
+
b2LastDescendantCheckMs: 0,
|
|
3335
|
+
b2AbandonedSignalled: false
|
|
3294
3336
|
});
|
|
3295
3337
|
}
|
|
3296
3338
|
/**
|
|
@@ -3358,12 +3400,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3358
3400
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
3359
3401
|
lastAliveAt: 0,
|
|
3360
3402
|
aliveInFlight: false,
|
|
3403
|
+
titleSynced: false,
|
|
3404
|
+
titleSyncInFlight: false,
|
|
3361
3405
|
awaitingHumanLatched: false,
|
|
3362
3406
|
pausedOnQuestion: false,
|
|
3363
3407
|
pausedOnPermission: false,
|
|
3364
3408
|
pausedClearConfirmed: false,
|
|
3365
3409
|
pausedInFlight: false,
|
|
3366
|
-
deliveryDeadlineAnchored: false
|
|
3410
|
+
deliveryDeadlineAnchored: false,
|
|
3411
|
+
b2PinnedSinceMs: 0,
|
|
3412
|
+
b2LastDescendantCheckMs: 0,
|
|
3413
|
+
b2AbandonedSignalled: false
|
|
3367
3414
|
});
|
|
3368
3415
|
}
|
|
3369
3416
|
/**
|
|
@@ -3525,58 +3572,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3525
3572
|
}
|
|
3526
3573
|
}
|
|
3527
3574
|
if (state === "done") {
|
|
3528
|
-
this.
|
|
3529
|
-
if (!inFlight.done) {
|
|
3530
|
-
this.log({
|
|
3531
|
-
level: "info",
|
|
3532
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
3533
|
-
conversation_id: conv.id,
|
|
3534
|
-
message_id: inFlight.evidentMessageId
|
|
3535
|
-
});
|
|
3536
|
-
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3537
|
-
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
3538
|
-
try {
|
|
3539
|
-
await this.markDone(
|
|
3540
|
-
conv.id,
|
|
3541
|
-
inFlight.evidentMessageId,
|
|
3542
|
-
sessionId,
|
|
3543
|
-
inFlight.opencodeMessageId,
|
|
3544
|
-
title,
|
|
3545
|
-
usage
|
|
3546
|
-
);
|
|
3547
|
-
} catch (err) {
|
|
3548
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
3549
|
-
if (err instanceof ChannelTerminalError) {
|
|
3550
|
-
this.log({
|
|
3551
|
-
level: "warn",
|
|
3552
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
3553
|
-
conversation_id: conv.id,
|
|
3554
|
-
message_id: inFlight.evidentMessageId
|
|
3555
|
-
});
|
|
3556
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3557
|
-
return;
|
|
3558
|
-
}
|
|
3559
|
-
if (this.now() >= inFlight.deadline) {
|
|
3560
|
-
this.log({
|
|
3561
|
-
level: "warn",
|
|
3562
|
-
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)}`,
|
|
3563
|
-
conversation_id: conv.id,
|
|
3564
|
-
message_id: inFlight.evidentMessageId
|
|
3565
|
-
});
|
|
3566
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3567
|
-
return;
|
|
3568
|
-
}
|
|
3569
|
-
this.log({
|
|
3570
|
-
level: "warn",
|
|
3571
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
3572
|
-
conversation_id: conv.id,
|
|
3573
|
-
message_id: inFlight.evidentMessageId
|
|
3574
|
-
});
|
|
3575
|
-
return;
|
|
3576
|
-
}
|
|
3577
|
-
inFlight.done = true;
|
|
3578
|
-
}
|
|
3579
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3575
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3580
3576
|
return;
|
|
3581
3577
|
}
|
|
3582
3578
|
if (state === "failed") {
|
|
@@ -3636,6 +3632,44 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3636
3632
|
});
|
|
3637
3633
|
}
|
|
3638
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
|
+
}
|
|
3639
3673
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
3640
3674
|
this.log({
|
|
3641
3675
|
level: "warn",
|
|
@@ -3655,6 +3689,18 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3655
3689
|
inFlight.aliveInFlight = false;
|
|
3656
3690
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
3657
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
|
+
}
|
|
3658
3704
|
}
|
|
3659
3705
|
if (awaitingHuman) {
|
|
3660
3706
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -3692,6 +3738,70 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3692
3738
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3693
3739
|
}
|
|
3694
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
|
+
}
|
|
3695
3805
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
3696
3806
|
/**
|
|
3697
3807
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
@@ -4271,6 +4381,47 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4271
4381
|
}
|
|
4272
4382
|
return false;
|
|
4273
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
|
+
}
|
|
4274
4425
|
/**
|
|
4275
4426
|
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
4276
4427
|
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
@@ -4355,6 +4506,54 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4355
4506
|
}
|
|
4356
4507
|
return null;
|
|
4357
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
|
+
}
|
|
4358
4557
|
/**
|
|
4359
4558
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
4360
4559
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -4413,6 +4612,84 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4413
4612
|
}
|
|
4414
4613
|
return false;
|
|
4415
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
|
+
}
|
|
4416
4693
|
/**
|
|
4417
4694
|
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
4418
4695
|
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
@@ -5010,12 +5287,14 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
5010
5287
|
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5011
5288
|
}
|
|
5012
5289
|
}
|
|
5290
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
5013
5291
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5014
5292
|
const apiUrl = getApiUrlConfig();
|
|
5015
5293
|
try {
|
|
5016
5294
|
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5017
5295
|
method: "POST",
|
|
5018
|
-
headers: { Authorization: authHeader }
|
|
5296
|
+
headers: { Authorization: authHeader },
|
|
5297
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5019
5298
|
});
|
|
5020
5299
|
if (!response.ok) {
|
|
5021
5300
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -5026,7 +5305,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
5026
5305
|
}
|
|
5027
5306
|
return { ok: true };
|
|
5028
5307
|
} catch (error2) {
|
|
5029
|
-
return { ok: false, error:
|
|
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
|
+
};
|
|
5333
|
+
}
|
|
5334
|
+
return { ok: true };
|
|
5335
|
+
} catch (error2) {
|
|
5336
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5030
5337
|
}
|
|
5031
5338
|
}
|
|
5032
5339
|
async function getAgentInfo(agentId, authHeader) {
|
|
@@ -5076,6 +5383,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
5076
5383
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
5077
5384
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
5078
5385
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
5386
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
5079
5387
|
function resolveLogLevel(options) {
|
|
5080
5388
|
const accepted = Object.keys(LOG_LEVELS);
|
|
5081
5389
|
const validate = (value, source) => {
|
|
@@ -5106,7 +5414,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
5106
5414
|
if (trimmed === "") {
|
|
5107
5415
|
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5108
5416
|
}
|
|
5109
|
-
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ?
|
|
5417
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
|
|
5110
5418
|
if (!isAbsolute2(expanded)) {
|
|
5111
5419
|
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5112
5420
|
}
|
|
@@ -5402,7 +5710,18 @@ async function notifyOffline(state) {
|
|
|
5402
5710
|
if (state.interactive) displayStatus(state);
|
|
5403
5711
|
}
|
|
5404
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
|
+
}
|
|
5405
5723
|
async function cleanup(state, opts = {}) {
|
|
5724
|
+
const durations = {};
|
|
5406
5725
|
state.running = false;
|
|
5407
5726
|
for (const timer of state.sessionCleanupTimers) {
|
|
5408
5727
|
clearInterval(timer);
|
|
@@ -5416,7 +5735,13 @@ async function cleanup(state, opts = {}) {
|
|
|
5416
5735
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
5417
5736
|
displayStatus(state);
|
|
5418
5737
|
}
|
|
5419
|
-
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
|
+
);
|
|
5420
5745
|
if (!settled) {
|
|
5421
5746
|
logActivity(state, {
|
|
5422
5747
|
type: "info",
|
|
@@ -5425,13 +5750,15 @@ async function cleanup(state, opts = {}) {
|
|
|
5425
5750
|
if (state.interactive) displayStatus(state);
|
|
5426
5751
|
}
|
|
5427
5752
|
}
|
|
5428
|
-
await notifyOffline(state);
|
|
5753
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
5429
5754
|
if (state.connection) {
|
|
5430
|
-
state.connection
|
|
5755
|
+
const connection = state.connection;
|
|
5756
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
5431
5757
|
state.connection = null;
|
|
5432
5758
|
}
|
|
5433
5759
|
if (state.opencodeProcess) {
|
|
5434
|
-
|
|
5760
|
+
const opencodeProcess = state.opencodeProcess;
|
|
5761
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
5435
5762
|
if (state.interactive) {
|
|
5436
5763
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
5437
5764
|
displayStatus(state);
|
|
@@ -5440,6 +5767,7 @@ async function cleanup(state, opts = {}) {
|
|
|
5440
5767
|
}
|
|
5441
5768
|
state.opencodeProcess = null;
|
|
5442
5769
|
}
|
|
5770
|
+
return durations;
|
|
5443
5771
|
}
|
|
5444
5772
|
async function run(options) {
|
|
5445
5773
|
const interactive = isInteractive(options.json);
|
|
@@ -5447,7 +5775,7 @@ async function run(options) {
|
|
|
5447
5775
|
let fileSyncDirectories;
|
|
5448
5776
|
try {
|
|
5449
5777
|
logLevel = resolveLogLevel(options);
|
|
5450
|
-
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo,
|
|
5778
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
|
|
5451
5779
|
} catch (error2) {
|
|
5452
5780
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
5453
5781
|
if (options.json) {
|
|
@@ -5510,14 +5838,38 @@ async function run(options) {
|
|
|
5510
5838
|
const handleSignal = async () => {
|
|
5511
5839
|
if (state.shuttingDown) return;
|
|
5512
5840
|
state.shuttingDown = true;
|
|
5841
|
+
const shutdownStartedAt = Date.now();
|
|
5513
5842
|
if (state.interactive) {
|
|
5514
5843
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
5515
5844
|
displayStatus(state);
|
|
5516
5845
|
} else {
|
|
5517
5846
|
log2(state, "Shutting down...");
|
|
5518
5847
|
}
|
|
5519
|
-
await cleanup(state, { graceful: true });
|
|
5520
|
-
|
|
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})`);
|
|
5521
5873
|
process.exit(0);
|
|
5522
5874
|
};
|
|
5523
5875
|
process.on("SIGINT", handleSignal);
|
|
@@ -5631,6 +5983,21 @@ async function run(options) {
|
|
|
5631
5983
|
}
|
|
5632
5984
|
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
5633
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
|
+
}
|
|
5634
6001
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
5635
6002
|
try {
|
|
5636
6003
|
const oc = await ensureOpenCodeRunning({
|
|
@@ -5682,7 +6049,7 @@ async function run(options) {
|
|
|
5682
6049
|
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
5683
6050
|
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
5684
6051
|
fileSyncDirectories,
|
|
5685
|
-
homeDir:
|
|
6052
|
+
homeDir: homedir2(),
|
|
5686
6053
|
log: (entry) => (
|
|
5687
6054
|
// Thread the driver's real level straight through so `debug`/`warn`
|
|
5688
6055
|
// survive the sink filter (they no longer collapse to info). `type`
|