@evident-ai/cli 3.1.1-dev.dcb2f6d → 3.1.1-dev.dd048b6
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/README.md +68 -13
- package/dist/index.js +2082 -219
- 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 = {
|
|
@@ -38,17 +38,39 @@ function getApiUrl() {
|
|
|
38
38
|
function getTunnelUrl() {
|
|
39
39
|
return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
|
|
40
40
|
}
|
|
41
|
-
var config = new Conf({
|
|
42
|
-
projectName: "evident",
|
|
43
|
-
projectSuffix: "",
|
|
44
|
-
defaults
|
|
45
|
-
});
|
|
46
41
|
var credentials = new Conf({
|
|
47
42
|
projectName: "evident",
|
|
48
43
|
projectSuffix: "",
|
|
49
44
|
configName: "credentials",
|
|
50
|
-
defaults: {}
|
|
45
|
+
defaults: {},
|
|
46
|
+
configFileMode: 384
|
|
51
47
|
});
|
|
48
|
+
var CREDENTIALS_FILE_MODE = 384;
|
|
49
|
+
var CREDENTIALS_DIR_MODE = 448;
|
|
50
|
+
var permissionWarningEmitted = false;
|
|
51
|
+
function hardenCredentialsPermissions() {
|
|
52
|
+
if (process.platform === "win32") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const file = credentials.path;
|
|
56
|
+
for (const [path, mode] of [
|
|
57
|
+
[file, CREDENTIALS_FILE_MODE],
|
|
58
|
+
[dirname(file), CREDENTIALS_DIR_MODE]
|
|
59
|
+
]) {
|
|
60
|
+
try {
|
|
61
|
+
if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
|
|
62
|
+
chmodSync(path, mode);
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!permissionWarningEmitted) {
|
|
66
|
+
permissionWarningEmitted = true;
|
|
67
|
+
console.error(
|
|
68
|
+
`[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)}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
52
74
|
function getApiUrlConfig() {
|
|
53
75
|
return getApiUrl();
|
|
54
76
|
}
|
|
@@ -59,6 +81,7 @@ function credentialsKey() {
|
|
|
59
81
|
return getApiUrl();
|
|
60
82
|
}
|
|
61
83
|
function getCredentials() {
|
|
84
|
+
hardenCredentialsPermissions();
|
|
62
85
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
63
86
|
return byEndpoint[credentialsKey()] ?? {};
|
|
64
87
|
}
|
|
@@ -70,14 +93,17 @@ function setCredentials(creds) {
|
|
|
70
93
|
expiresAt: creds.expiresAt
|
|
71
94
|
};
|
|
72
95
|
credentials.set("byEndpoint", byEndpoint);
|
|
96
|
+
hardenCredentialsPermissions();
|
|
73
97
|
}
|
|
74
98
|
function clearCredentials() {
|
|
75
99
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
76
100
|
delete byEndpoint[credentialsKey()];
|
|
77
101
|
credentials.set("byEndpoint", byEndpoint);
|
|
102
|
+
hardenCredentialsPermissions();
|
|
78
103
|
}
|
|
79
104
|
function clearAllCredentials() {
|
|
80
105
|
credentials.clear();
|
|
106
|
+
hardenCredentialsPermissions();
|
|
81
107
|
}
|
|
82
108
|
function getCliName() {
|
|
83
109
|
const argv1 = process.argv[1] || "";
|
|
@@ -236,16 +262,28 @@ async function getToken() {
|
|
|
236
262
|
}
|
|
237
263
|
return null;
|
|
238
264
|
}
|
|
265
|
+
function toError(err) {
|
|
266
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
267
|
+
}
|
|
239
268
|
async function deleteToken(options = {}) {
|
|
240
269
|
const keytar = await getKeytar();
|
|
270
|
+
const failures = [];
|
|
241
271
|
if (keytar) {
|
|
242
272
|
if (options.all) {
|
|
243
|
-
|
|
273
|
+
let accounts = [];
|
|
274
|
+
try {
|
|
275
|
+
accounts = await keytar.findCredentials(SERVICE_NAME);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
failures.push({ type: "enumerate", error: toError(err) });
|
|
278
|
+
}
|
|
244
279
|
await Promise.all(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
280
|
+
accounts.map(async (entry) => {
|
|
281
|
+
try {
|
|
282
|
+
await keytar.deletePassword(SERVICE_NAME, entry.account);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
failures.push({ type: "delete", account: entry.account, error: toError(err) });
|
|
285
|
+
}
|
|
286
|
+
})
|
|
249
287
|
);
|
|
250
288
|
} else {
|
|
251
289
|
await keytar.deletePassword(SERVICE_NAME, keychainAccount());
|
|
@@ -256,6 +294,7 @@ async function deleteToken(options = {}) {
|
|
|
256
294
|
} else {
|
|
257
295
|
clearCredentials();
|
|
258
296
|
}
|
|
297
|
+
return { failures };
|
|
259
298
|
}
|
|
260
299
|
|
|
261
300
|
// src/utils/ui.ts
|
|
@@ -285,14 +324,14 @@ function blank() {
|
|
|
285
324
|
console.log();
|
|
286
325
|
}
|
|
287
326
|
function waitForEnter(prompt = "Press Enter to continue...") {
|
|
288
|
-
return new Promise((
|
|
327
|
+
return new Promise((resolve3) => {
|
|
289
328
|
process.stdout.write(chalk.dim(prompt));
|
|
290
329
|
const handler = () => {
|
|
291
330
|
process.stdin.removeListener("data", handler);
|
|
292
331
|
process.stdin.setRawMode?.(false);
|
|
293
332
|
process.stdin.pause();
|
|
294
333
|
console.log();
|
|
295
|
-
|
|
334
|
+
resolve3();
|
|
296
335
|
};
|
|
297
336
|
if (process.stdin.isTTY) {
|
|
298
337
|
process.stdin.setRawMode?.(true);
|
|
@@ -302,7 +341,7 @@ function waitForEnter(prompt = "Press Enter to continue...") {
|
|
|
302
341
|
});
|
|
303
342
|
}
|
|
304
343
|
function sleep(ms) {
|
|
305
|
-
return new Promise((
|
|
344
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
306
345
|
}
|
|
307
346
|
|
|
308
347
|
// src/commands/login.ts
|
|
@@ -373,22 +412,25 @@ async function deviceFlowLogin(options) {
|
|
|
373
412
|
}
|
|
374
413
|
async function tokenLogin() {
|
|
375
414
|
console.log("Token login mode.");
|
|
376
|
-
console.log("
|
|
415
|
+
console.log("Create a token under Settings \u2192 CLI tokens in the dashboard, then paste it below.");
|
|
416
|
+
console.log(
|
|
417
|
+
"(Alternatively, run `evident login` on a machine with a browser, or set EVIDENT_TOKEN for CI.)"
|
|
418
|
+
);
|
|
377
419
|
blank();
|
|
378
420
|
process.stdout.write("Paste token: ");
|
|
379
|
-
const token = await new Promise((
|
|
421
|
+
const token = await new Promise((resolve3) => {
|
|
380
422
|
let data = "";
|
|
381
423
|
process.stdin.setEncoding("utf8");
|
|
382
424
|
process.stdin.on("data", (chunk) => {
|
|
383
425
|
data += chunk;
|
|
384
426
|
});
|
|
385
427
|
process.stdin.on("end", () => {
|
|
386
|
-
|
|
428
|
+
resolve3(data.trim());
|
|
387
429
|
});
|
|
388
430
|
if (process.stdin.isTTY) {
|
|
389
431
|
process.stdin.once("data", (chunk) => {
|
|
390
432
|
process.stdin.pause();
|
|
391
|
-
|
|
433
|
+
resolve3(chunk.toString().trim());
|
|
392
434
|
});
|
|
393
435
|
process.stdin.resume();
|
|
394
436
|
}
|
|
@@ -397,13 +439,22 @@ async function tokenLogin() {
|
|
|
397
439
|
printError("No token provided.");
|
|
398
440
|
process.exit(1);
|
|
399
441
|
}
|
|
442
|
+
await validateAndStoreToken(token);
|
|
443
|
+
}
|
|
444
|
+
async function validateAndStoreToken(token) {
|
|
400
445
|
const spinner = ora("Validating token...").start();
|
|
401
446
|
try {
|
|
402
|
-
const result = await api.
|
|
447
|
+
const result = await api.get("/me", {
|
|
448
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
449
|
+
});
|
|
450
|
+
if (!result.user) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
"This token is not a user login (e.g. a runner key). Paste a CLI token instead."
|
|
453
|
+
);
|
|
454
|
+
}
|
|
403
455
|
await storeToken({
|
|
404
456
|
token,
|
|
405
|
-
user: result.user
|
|
406
|
-
expiresAt: result.expires_at
|
|
457
|
+
user: { email: result.user.email }
|
|
407
458
|
});
|
|
408
459
|
spinner.stop();
|
|
409
460
|
printSuccess(`Logged in as ${chalk2.bold(result.user.email)}`);
|
|
@@ -423,9 +474,22 @@ async function login(options) {
|
|
|
423
474
|
}
|
|
424
475
|
|
|
425
476
|
// src/commands/logout.ts
|
|
477
|
+
function describeFailure(failure) {
|
|
478
|
+
if (failure.type === "enumerate") {
|
|
479
|
+
return `could not list stored keychain entries (${failure.error.message})`;
|
|
480
|
+
}
|
|
481
|
+
return `${failure.account} (${failure.error.message})`;
|
|
482
|
+
}
|
|
426
483
|
async function logout(options = {}) {
|
|
427
484
|
if (options.all) {
|
|
428
|
-
await deleteToken({ all: true });
|
|
485
|
+
const result = await deleteToken({ all: true });
|
|
486
|
+
if (result.failures.length > 0) {
|
|
487
|
+
printError(
|
|
488
|
+
`Failed to fully clear your keychain: ${result.failures.map(describeFailure).join("; ")}. Your local credentials file was cleared, but stale keychain entries may remain \u2014 run \`evident logout --all\` again, or remove them manually from your OS keychain / credential manager.`
|
|
489
|
+
);
|
|
490
|
+
process.exitCode = 1;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
429
493
|
printSuccess("Logged out of all endpoints.");
|
|
430
494
|
return;
|
|
431
495
|
}
|
|
@@ -450,7 +514,9 @@ async function whoami() {
|
|
|
450
514
|
blank();
|
|
451
515
|
console.log(keyValue("Endpoint", apiUrl));
|
|
452
516
|
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
453
|
-
|
|
517
|
+
if (credentials2.user.id) {
|
|
518
|
+
console.log(keyValue("User ID", credentials2.user.id));
|
|
519
|
+
}
|
|
454
520
|
if (credentials2.expiresAt) {
|
|
455
521
|
const expiresAt = new Date(credentials2.expiresAt);
|
|
456
522
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -466,10 +532,125 @@ async function whoami() {
|
|
|
466
532
|
blank();
|
|
467
533
|
}
|
|
468
534
|
|
|
535
|
+
// src/lib/claude-usage.ts
|
|
536
|
+
import { execFileSync } from "child_process";
|
|
537
|
+
import { readFileSync } from "fs";
|
|
538
|
+
import { homedir } from "os";
|
|
539
|
+
import { join } from "path";
|
|
540
|
+
var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
541
|
+
var KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
542
|
+
function parseClaudeCliCredentials(raw) {
|
|
543
|
+
let parsed;
|
|
544
|
+
try {
|
|
545
|
+
parsed = JSON.parse(raw);
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const data = parsed.claudeAiOauth ?? parsed;
|
|
550
|
+
const creds = data;
|
|
551
|
+
if (typeof creds.accessToken !== "string" || typeof creds.expiresAt !== "number") {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return { accessToken: creds.accessToken, expiresAt: creds.expiresAt };
|
|
555
|
+
}
|
|
556
|
+
function readClaudeCliCredentials() {
|
|
557
|
+
if (process.platform === "darwin") {
|
|
558
|
+
try {
|
|
559
|
+
const raw = execFileSync(
|
|
560
|
+
"/usr/bin/security",
|
|
561
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
|
|
562
|
+
{ encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
|
|
563
|
+
);
|
|
564
|
+
return parseClaudeCliCredentials(raw);
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
|
|
571
|
+
return parseClaudeCliCredentials(raw);
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
var ClaudeUsageError = class extends Error {
|
|
577
|
+
constructor(message, reason) {
|
|
578
|
+
super(message);
|
|
579
|
+
this.reason = reason;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
function isLocalCredentialProblem(err) {
|
|
583
|
+
return err instanceof ClaudeUsageError && (err.reason === "no_credentials" || err.reason === "credentials_expired");
|
|
584
|
+
}
|
|
585
|
+
function toWindow(value) {
|
|
586
|
+
if (!value || typeof value !== "object") {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const window = value;
|
|
590
|
+
if (typeof window.utilization !== "number" || typeof window.resets_at !== "string") {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
return { utilization: window.utilization, resetsAt: window.resets_at };
|
|
594
|
+
}
|
|
595
|
+
async function getClaudeUsage() {
|
|
596
|
+
const credentials2 = readClaudeCliCredentials();
|
|
597
|
+
if (!credentials2) {
|
|
598
|
+
throw new ClaudeUsageError(
|
|
599
|
+
"No local Claude Code login found. Run `claude` once to sign in with your Claude subscription.",
|
|
600
|
+
"no_credentials"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
if (credentials2.expiresAt < Date.now()) {
|
|
604
|
+
throw new ClaudeUsageError(
|
|
605
|
+
"Claude Code credentials have expired. Run `claude` to refresh them.",
|
|
606
|
+
"credentials_expired"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const res = await fetch(CLAUDE_USAGE_URL, {
|
|
610
|
+
headers: {
|
|
611
|
+
Authorization: `Bearer ${credentials2.accessToken}`,
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
"anthropic-version": "2023-06-01"
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
if (!res.ok) {
|
|
617
|
+
throw new ClaudeUsageError(`Claude usage request failed: HTTP ${res.status}`, "request_failed");
|
|
618
|
+
}
|
|
619
|
+
const body = await res.json();
|
|
620
|
+
return {
|
|
621
|
+
fiveHour: toWindow(body.five_hour),
|
|
622
|
+
sevenDay: toWindow(body.seven_day)
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/commands/claude-usage.ts
|
|
627
|
+
function formatWindow(label, window) {
|
|
628
|
+
if (!window) {
|
|
629
|
+
return keyValue(label, "not available for this plan");
|
|
630
|
+
}
|
|
631
|
+
const resetsAt = new Date(window.resetsAt);
|
|
632
|
+
return keyValue(label, `${window.utilization}% used, resets ${resetsAt.toLocaleString()}`);
|
|
633
|
+
}
|
|
634
|
+
async function claudeUsage() {
|
|
635
|
+
try {
|
|
636
|
+
const usage = await getClaudeUsage();
|
|
637
|
+
blank();
|
|
638
|
+
console.log(formatWindow("5-hour session", usage.fiveHour));
|
|
639
|
+
console.log(formatWindow("7-day", usage.sevenDay));
|
|
640
|
+
blank();
|
|
641
|
+
} catch (err) {
|
|
642
|
+
if (err instanceof ClaudeUsageError) {
|
|
643
|
+
printError(err.message);
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
throw err;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
469
650
|
// src/commands/run.ts
|
|
651
|
+
import { homedir as homedir3 } from "os";
|
|
652
|
+
import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
|
|
470
653
|
import chalk6 from "chalk";
|
|
471
|
-
import ora3 from "ora";
|
|
472
|
-
import { select as select3 } from "@inquirer/prompts";
|
|
473
654
|
|
|
474
655
|
// ../../packages/types/src/telemetry/index.ts
|
|
475
656
|
var TelemetryEventTypes = {
|
|
@@ -478,13 +659,20 @@ var TelemetryEventTypes = {
|
|
|
478
659
|
AGENT_DISCONNECTED: "agent.disconnected",
|
|
479
660
|
AGENT_MESSAGE_PROCESSING: "agent.message_processing",
|
|
480
661
|
AGENT_MESSAGE_DONE: "agent.message_done",
|
|
481
|
-
AGENT_MESSAGE_FAILED: "agent.message_failed"
|
|
662
|
+
AGENT_MESSAGE_FAILED: "agent.message_failed",
|
|
663
|
+
// A `warn`/`error` runner-side log line forwarded server-side for
|
|
664
|
+
// observability (issue #916) — see `apps/cli/src/lib/runner-activity-telemetry.ts`.
|
|
665
|
+
RUNNER_ACTIVITY: "runner.activity"
|
|
482
666
|
};
|
|
483
667
|
|
|
484
668
|
// ../../packages/types/src/tunnel/index.ts
|
|
485
669
|
var MAX_FRAME_BYTES = 256 * 1024;
|
|
486
670
|
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
487
671
|
|
|
672
|
+
// ../../packages/types/src/runner-files.ts
|
|
673
|
+
var MAX_FILE_PUSH_BYTES = 64 * 1024;
|
|
674
|
+
var MAX_FILE_SYNC_DIRECTORIES = 16;
|
|
675
|
+
|
|
488
676
|
// ../../packages/types/src/logging/index.ts
|
|
489
677
|
var CORRELATION_ID_HEADER = "x-evident-correlation-id";
|
|
490
678
|
function log(level, event, fields) {
|
|
@@ -499,6 +687,12 @@ function log(level, event, fields) {
|
|
|
499
687
|
);
|
|
500
688
|
}
|
|
501
689
|
}
|
|
690
|
+
function errorFields(err) {
|
|
691
|
+
if (err instanceof Error) {
|
|
692
|
+
return { error: err.message, error_name: err.name };
|
|
693
|
+
}
|
|
694
|
+
return { error: String(err) };
|
|
695
|
+
}
|
|
502
696
|
function stripQuery(url) {
|
|
503
697
|
try {
|
|
504
698
|
return new URL(url).pathname;
|
|
@@ -508,6 +702,10 @@ function stripQuery(url) {
|
|
|
508
702
|
}
|
|
509
703
|
}
|
|
510
704
|
|
|
705
|
+
// src/commands/run.ts
|
|
706
|
+
import ora3 from "ora";
|
|
707
|
+
import { select as select3 } from "@inquirer/prompts";
|
|
708
|
+
|
|
511
709
|
// src/lib/telemetry.ts
|
|
512
710
|
var CLI_VERSION = (true ? "3.0.0" : void 0) ?? process.env.npm_package_version ?? "unknown";
|
|
513
711
|
function getCliVersion() {
|
|
@@ -519,6 +717,13 @@ var isShuttingDown = false;
|
|
|
519
717
|
var FLUSH_INTERVAL_MS = 5e3;
|
|
520
718
|
var MAX_BUFFER_SIZE = 50;
|
|
521
719
|
var FLUSH_TIMEOUT_MS = 3e3;
|
|
720
|
+
var authProvider = null;
|
|
721
|
+
function setTelemetryAuthProvider(provider) {
|
|
722
|
+
authProvider = provider;
|
|
723
|
+
}
|
|
724
|
+
var FLUSH_FAILURE_LOG_INTERVAL_MS = 6e4;
|
|
725
|
+
var lastFlushFailureLoggedAt = 0;
|
|
726
|
+
var suppressedFlushFailureCount = 0;
|
|
522
727
|
function logEvent(eventType, options = {}) {
|
|
523
728
|
const event = {
|
|
524
729
|
event_type: eventType,
|
|
@@ -553,9 +758,16 @@ async function flushEvents() {
|
|
|
553
758
|
flushTimeout = null;
|
|
554
759
|
}
|
|
555
760
|
try {
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
761
|
+
const providerContext = authProvider?.();
|
|
762
|
+
let authHeader;
|
|
763
|
+
if (providerContext?.authHeader) {
|
|
764
|
+
authHeader = providerContext.authHeader;
|
|
765
|
+
} else {
|
|
766
|
+
const credentials2 = await getToken();
|
|
767
|
+
if (!credentials2) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
authHeader = `Bearer ${credentials2.token}`;
|
|
559
771
|
}
|
|
560
772
|
const apiUrl = getApiUrlConfig();
|
|
561
773
|
const controller = new AbortController();
|
|
@@ -570,7 +782,7 @@ async function flushEvents() {
|
|
|
570
782
|
method: "POST",
|
|
571
783
|
headers: {
|
|
572
784
|
"Content-Type": "application/json",
|
|
573
|
-
Authorization:
|
|
785
|
+
Authorization: authHeader
|
|
574
786
|
},
|
|
575
787
|
body: JSON.stringify(request),
|
|
576
788
|
signal: controller.signal
|
|
@@ -582,8 +794,15 @@ async function flushEvents() {
|
|
|
582
794
|
clearTimeout(timeout);
|
|
583
795
|
}
|
|
584
796
|
} catch (error2) {
|
|
585
|
-
|
|
586
|
-
|
|
797
|
+
const now = Date.now();
|
|
798
|
+
if (now - lastFlushFailureLoggedAt >= FLUSH_FAILURE_LOG_INTERVAL_MS) {
|
|
799
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
800
|
+
const suffix = suppressedFlushFailureCount > 0 ? ` (${suppressedFlushFailureCount} more suppressed in the last ${FLUSH_FAILURE_LOG_INTERVAL_MS / 1e3}s)` : "";
|
|
801
|
+
console.error(`Telemetry flush error: ${message}${suffix}`);
|
|
802
|
+
lastFlushFailureLoggedAt = now;
|
|
803
|
+
suppressedFlushFailureCount = 0;
|
|
804
|
+
} else {
|
|
805
|
+
suppressedFlushFailureCount++;
|
|
587
806
|
}
|
|
588
807
|
}
|
|
589
808
|
}
|
|
@@ -645,14 +864,90 @@ var EventTypes = {
|
|
|
645
864
|
// CLI lifecycle
|
|
646
865
|
CLI_STARTED: "cli.started",
|
|
647
866
|
CLI_COMMAND: "cli.command",
|
|
648
|
-
CLI_ERROR: "cli.error"
|
|
867
|
+
CLI_ERROR: "cli.error",
|
|
868
|
+
// Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
|
|
869
|
+
// names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
|
|
870
|
+
DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
|
|
871
|
+
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
649
872
|
};
|
|
650
873
|
|
|
874
|
+
// src/lib/runner-activity-telemetry.ts
|
|
875
|
+
var FORWARDED_LEVELS = /* @__PURE__ */ new Set(["warn", "error"]);
|
|
876
|
+
var SEVERITY_BY_LEVEL = {
|
|
877
|
+
warn: "warning",
|
|
878
|
+
error: "error"
|
|
879
|
+
};
|
|
880
|
+
var MAX_MESSAGE_LENGTH = 500;
|
|
881
|
+
var TRUNCATION_MARKER = "\u2026";
|
|
882
|
+
function redact(message) {
|
|
883
|
+
return message.replace(/esk_[A-Za-z0-9_-]+/g, "esk_***").replace(/ct_[A-Za-z0-9_-]+/g, "ct_***").replace(/https?:\/\/\S+/g, "<url>");
|
|
884
|
+
}
|
|
885
|
+
function truncate(message) {
|
|
886
|
+
if (message.length <= MAX_MESSAGE_LENGTH) return message;
|
|
887
|
+
return message.slice(0, MAX_MESSAGE_LENGTH - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
888
|
+
}
|
|
889
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
890
|
+
var RATE_LIMIT_MAX_EVENTS = 30;
|
|
891
|
+
var windowStartedAt = 0;
|
|
892
|
+
var windowCount = 0;
|
|
893
|
+
var windowDroppedCount = 0;
|
|
894
|
+
function admitUnderRateLimit(now) {
|
|
895
|
+
if (now - windowStartedAt >= RATE_LIMIT_WINDOW_MS) {
|
|
896
|
+
if (windowDroppedCount > 0) {
|
|
897
|
+
console.error(
|
|
898
|
+
`[runner-activity-telemetry] rate cap reached: dropped ${windowDroppedCount} ${windowDroppedCount === 1 ? "entry" : "entries"} in the last ${RATE_LIMIT_WINDOW_MS / 1e3}s (cap ${RATE_LIMIT_MAX_EVENTS}/min)`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
windowStartedAt = now;
|
|
902
|
+
windowCount = 0;
|
|
903
|
+
windowDroppedCount = 0;
|
|
904
|
+
}
|
|
905
|
+
if (windowCount >= RATE_LIMIT_MAX_EVENTS) {
|
|
906
|
+
windowDroppedCount++;
|
|
907
|
+
if (windowDroppedCount === 1) {
|
|
908
|
+
console.error(
|
|
909
|
+
`[runner-activity-telemetry] rate cap reached (${RATE_LIMIT_MAX_EVENTS} per ${RATE_LIMIT_WINDOW_MS / 1e3}s) \u2014 dropping further entries this window`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
windowCount++;
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
function forwardRunnerActivity(entry, context) {
|
|
918
|
+
try {
|
|
919
|
+
if (!FORWARDED_LEVELS.has(entry.level)) return;
|
|
920
|
+
if (!context.agentId || !context.authHeader) return;
|
|
921
|
+
if (!admitUnderRateLimit(Date.now())) return;
|
|
922
|
+
const rawMessage = entry.error ?? entry.message ?? "";
|
|
923
|
+
const message = truncate(redact(rawMessage));
|
|
924
|
+
logEvent(TelemetryEventTypes.RUNNER_ACTIVITY, {
|
|
925
|
+
severity: SEVERITY_BY_LEVEL[entry.level],
|
|
926
|
+
message,
|
|
927
|
+
metadata: { source: "cli.run" },
|
|
928
|
+
agentId: context.agentId
|
|
929
|
+
});
|
|
930
|
+
} catch (err) {
|
|
931
|
+
console.error(
|
|
932
|
+
`[runner-activity-telemetry] failed to forward runner activity: ${err instanceof Error ? err.message : String(err)}`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
651
937
|
// src/lib/auth.ts
|
|
652
938
|
async function getAuthCredentials() {
|
|
939
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
653
940
|
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
941
|
+
if (runnerKey) {
|
|
942
|
+
return {
|
|
943
|
+
token: runnerKey,
|
|
944
|
+
authType: "agent_key",
|
|
945
|
+
keySource: "runner_key",
|
|
946
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
947
|
+
};
|
|
948
|
+
}
|
|
654
949
|
if (agentKey) {
|
|
655
|
-
return { token: agentKey, authType: "agent_key" };
|
|
950
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
656
951
|
}
|
|
657
952
|
const userToken = process.env.EVIDENT_TOKEN;
|
|
658
953
|
if (userToken) {
|
|
@@ -706,7 +1001,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
706
1001
|
if (health.healthy) {
|
|
707
1002
|
return health;
|
|
708
1003
|
}
|
|
709
|
-
await new Promise((
|
|
1004
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
710
1005
|
}
|
|
711
1006
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
712
1007
|
}
|
|
@@ -721,7 +1016,7 @@ function buildOpenCodeVersionWarning(version2) {
|
|
|
721
1016
|
if (isQueueValidatedVersion(version2)) return null;
|
|
722
1017
|
const detected = version2 ? `v${version2}` : "unknown";
|
|
723
1018
|
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
724
|
-
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack
|
|
1019
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
725
1020
|
}
|
|
726
1021
|
|
|
727
1022
|
// src/lib/opencode/process.ts
|
|
@@ -1013,6 +1308,12 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
1013
1308
|
return action;
|
|
1014
1309
|
}
|
|
1015
1310
|
|
|
1311
|
+
// src/lib/opencode/provider-check.ts
|
|
1312
|
+
function buildNoProviderWarning(hasProvider) {
|
|
1313
|
+
if (hasProvider !== false) return null;
|
|
1314
|
+
return "Warning: opencode has no authenticated model provider configured, so it won't be able to answer prompts. Run `opencode auth login` to set one up (see https://opencode.ai for details).";
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1016
1317
|
// src/lib/opencode/session.ts
|
|
1017
1318
|
function opencodeBase(port) {
|
|
1018
1319
|
return `http://127.0.0.1:${port}`;
|
|
@@ -1215,6 +1516,11 @@ async function getModelAttachmentCapability(port, model) {
|
|
|
1215
1516
|
}
|
|
1216
1517
|
const entry = provider.models[modelId];
|
|
1217
1518
|
if (!entry || typeof entry !== "object") return null;
|
|
1519
|
+
if (entry.capabilities && typeof entry.capabilities === "object") {
|
|
1520
|
+
if (typeof entry.capabilities.attachment === "boolean") {
|
|
1521
|
+
return entry.capabilities.attachment;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1218
1524
|
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1219
1525
|
} catch (err) {
|
|
1220
1526
|
console.error(
|
|
@@ -1243,6 +1549,16 @@ async function buildFileParts(attachments, capable) {
|
|
|
1243
1549
|
);
|
|
1244
1550
|
dataUrl = null;
|
|
1245
1551
|
}
|
|
1552
|
+
if (dataUrl !== null && typeof dataUrl === "object") {
|
|
1553
|
+
outcomes.push({
|
|
1554
|
+
index: a.index,
|
|
1555
|
+
mime: a.mime,
|
|
1556
|
+
filename: a.filename,
|
|
1557
|
+
status: "failed",
|
|
1558
|
+
reason: "needs_reauth"
|
|
1559
|
+
});
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1246
1562
|
if (dataUrl == null) {
|
|
1247
1563
|
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1248
1564
|
continue;
|
|
@@ -1324,7 +1640,7 @@ async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
|
1324
1640
|
}
|
|
1325
1641
|
}
|
|
1326
1642
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1327
|
-
await new Promise((
|
|
1643
|
+
await new Promise((resolve3) => setTimeout(resolve3, READ_BACK_DELAY_MS));
|
|
1328
1644
|
}
|
|
1329
1645
|
}
|
|
1330
1646
|
return null;
|
|
@@ -1370,6 +1686,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
|
|
|
1370
1686
|
}
|
|
1371
1687
|
return lastOk ?? last;
|
|
1372
1688
|
}
|
|
1689
|
+
function messageUsage(messages, userMessageId) {
|
|
1690
|
+
if (!messages || messages.length === 0) return null;
|
|
1691
|
+
const byParentAll = messages.filter(
|
|
1692
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1693
|
+
);
|
|
1694
|
+
const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
|
|
1695
|
+
const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
|
|
1696
|
+
let correlated;
|
|
1697
|
+
if (byParent.length > 0) {
|
|
1698
|
+
correlated = byParent;
|
|
1699
|
+
} else {
|
|
1700
|
+
const reply = findAssistantReplyAfter(messages, userMessageId);
|
|
1701
|
+
correlated = reply ? [reply] : [];
|
|
1702
|
+
}
|
|
1703
|
+
if (correlated.length === 0) return null;
|
|
1704
|
+
let sawAnyUsage = false;
|
|
1705
|
+
let inputSum = 0;
|
|
1706
|
+
let outputSum = 0;
|
|
1707
|
+
let reasoningSum = 0;
|
|
1708
|
+
let cacheReadSum = 0;
|
|
1709
|
+
let cacheWriteSum = 0;
|
|
1710
|
+
let costSum = 0;
|
|
1711
|
+
let sawCost = false;
|
|
1712
|
+
let modelId = null;
|
|
1713
|
+
let providerId = null;
|
|
1714
|
+
for (const m of correlated) {
|
|
1715
|
+
const info = m.info;
|
|
1716
|
+
if (!info) continue;
|
|
1717
|
+
const tokens = info.tokens;
|
|
1718
|
+
if (tokens) {
|
|
1719
|
+
sawAnyUsage = true;
|
|
1720
|
+
inputSum += tokens.input ?? 0;
|
|
1721
|
+
outputSum += tokens.output ?? 0;
|
|
1722
|
+
reasoningSum += tokens.reasoning ?? 0;
|
|
1723
|
+
cacheReadSum += tokens.cache?.read ?? 0;
|
|
1724
|
+
cacheWriteSum += tokens.cache?.write ?? 0;
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof info.cost === "number") {
|
|
1727
|
+
sawAnyUsage = true;
|
|
1728
|
+
sawCost = true;
|
|
1729
|
+
costSum += info.cost;
|
|
1730
|
+
}
|
|
1731
|
+
if (typeof info.modelID === "string") {
|
|
1732
|
+
sawAnyUsage = true;
|
|
1733
|
+
modelId = info.modelID;
|
|
1734
|
+
}
|
|
1735
|
+
if (typeof info.providerID === "string") {
|
|
1736
|
+
sawAnyUsage = true;
|
|
1737
|
+
providerId = info.providerID;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (!sawAnyUsage) return null;
|
|
1741
|
+
return {
|
|
1742
|
+
usage_provider_id: providerId,
|
|
1743
|
+
usage_model_id: modelId,
|
|
1744
|
+
usage_tokens_input: inputSum,
|
|
1745
|
+
usage_tokens_output: outputSum,
|
|
1746
|
+
usage_tokens_reasoning: reasoningSum,
|
|
1747
|
+
usage_tokens_cache_read: cacheReadSum,
|
|
1748
|
+
usage_tokens_cache_write: cacheWriteSum,
|
|
1749
|
+
// NULL means "OpenCode never reported a cost" (never inferred from
|
|
1750
|
+
// tokens) — distinct from a genuine 0-cost turn, which would set
|
|
1751
|
+
// `sawCost` true with `costSum === 0`.
|
|
1752
|
+
usage_cost_usd: sawCost ? costSum : null
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1373
1755
|
function messageRunState(messages, userMessageId) {
|
|
1374
1756
|
if (!messages || messages.length === 0) return "unknown";
|
|
1375
1757
|
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
@@ -1386,6 +1768,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
1386
1768
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1387
1769
|
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1388
1770
|
}
|
|
1771
|
+
function isB2AbandonmentConfirmed(params) {
|
|
1772
|
+
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
1773
|
+
}
|
|
1389
1774
|
function messageError(messages, userMessageId) {
|
|
1390
1775
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1391
1776
|
const error2 = errorOf(reply);
|
|
@@ -1399,12 +1784,79 @@ function messageError(messages, userMessageId) {
|
|
|
1399
1784
|
}
|
|
1400
1785
|
return "The agent run failed.";
|
|
1401
1786
|
}
|
|
1787
|
+
function messageFailure(messages, userMessageId) {
|
|
1788
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1789
|
+
const error2 = errorOf(reply);
|
|
1790
|
+
if (error2 == null || typeof error2 !== "object") return null;
|
|
1791
|
+
const e = error2;
|
|
1792
|
+
const replyProviderId = reply?.info?.providerID ?? null;
|
|
1793
|
+
const replyModelId = reply?.info?.modelID ?? null;
|
|
1794
|
+
if (e.name === "ProviderAuthError") {
|
|
1795
|
+
const data = e.data;
|
|
1796
|
+
const providerId = typeof data?.providerID === "string" && data.providerID || replyProviderId;
|
|
1797
|
+
return { kind: "model_auth", providerId, modelId: replyModelId, reason: "missing" };
|
|
1798
|
+
}
|
|
1799
|
+
if (e.name === "APIError") {
|
|
1800
|
+
const data = e.data;
|
|
1801
|
+
const statusCode = data?.statusCode;
|
|
1802
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
1803
|
+
return {
|
|
1804
|
+
kind: "model_auth",
|
|
1805
|
+
providerId: replyProviderId,
|
|
1806
|
+
modelId: replyModelId,
|
|
1807
|
+
reason: "rejected"
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
function applyZeroProviderFallback(classified, hasConfiguredProvider, replyProviderId, replyModelId = null) {
|
|
1814
|
+
if (classified != null) return classified;
|
|
1815
|
+
if (hasConfiguredProvider !== false) return null;
|
|
1816
|
+
return {
|
|
1817
|
+
kind: "model_auth",
|
|
1818
|
+
providerId: replyProviderId,
|
|
1819
|
+
modelId: replyModelId,
|
|
1820
|
+
reason: "missing"
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1402
1823
|
function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
1403
1824
|
if (!messages || messages.length === 0) return false;
|
|
1404
1825
|
return messages.some(
|
|
1405
1826
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1406
1827
|
);
|
|
1407
1828
|
}
|
|
1829
|
+
async function hasAnyConfiguredProvider(port) {
|
|
1830
|
+
try {
|
|
1831
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1832
|
+
if (!res.ok) {
|
|
1833
|
+
console.error(
|
|
1834
|
+
`[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1835
|
+
);
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
const body = await res.json();
|
|
1839
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1840
|
+
console.error(
|
|
1841
|
+
`[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
|
|
1842
|
+
);
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
const defaults2 = body.default;
|
|
1846
|
+
if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
|
|
1847
|
+
console.error(
|
|
1848
|
+
`[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
|
|
1849
|
+
);
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
return Object.keys(defaults2).length > 0;
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
console.error(
|
|
1855
|
+
`[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1856
|
+
);
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1408
1860
|
|
|
1409
1861
|
// src/lib/opencode/session-cleanup.ts
|
|
1410
1862
|
var DURATION_UNIT_MS = {
|
|
@@ -1563,10 +2015,11 @@ var StreamForwarder = class {
|
|
|
1563
2015
|
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1564
2016
|
*/
|
|
1565
2017
|
abortAll() {
|
|
1566
|
-
for (const stream of this.inflight.
|
|
2018
|
+
for (const [sid, stream] of this.inflight.entries()) {
|
|
1567
2019
|
try {
|
|
1568
2020
|
stream.abort();
|
|
1569
|
-
} catch {
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
|
|
1570
2023
|
}
|
|
1571
2024
|
}
|
|
1572
2025
|
this.inflight.clear();
|
|
@@ -1600,12 +2053,12 @@ var StreamForwarder = class {
|
|
|
1600
2053
|
let endBody;
|
|
1601
2054
|
if (has_body) {
|
|
1602
2055
|
const chunks = [];
|
|
1603
|
-
bodyPromise = new Promise((
|
|
2056
|
+
bodyPromise = new Promise((resolve3) => {
|
|
1604
2057
|
pushBody = (buf) => {
|
|
1605
2058
|
chunks.push(buf);
|
|
1606
2059
|
};
|
|
1607
2060
|
endBody = () => {
|
|
1608
|
-
|
|
2061
|
+
resolve3(Buffer.concat(chunks));
|
|
1609
2062
|
};
|
|
1610
2063
|
});
|
|
1611
2064
|
}
|
|
@@ -1716,31 +2169,20 @@ function connectTunnel(options) {
|
|
|
1716
2169
|
onConnected,
|
|
1717
2170
|
onDisconnected,
|
|
1718
2171
|
onError,
|
|
1719
|
-
onRequest,
|
|
1720
2172
|
onResponse,
|
|
1721
2173
|
onInfo,
|
|
1722
2174
|
onDrainPing
|
|
1723
2175
|
} = options;
|
|
1724
2176
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1725
2177
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1726
|
-
return new Promise((
|
|
2178
|
+
return new Promise((resolve3, reject) => {
|
|
1727
2179
|
const ws = new WebSocket2(url, {
|
|
1728
2180
|
headers: {
|
|
1729
2181
|
Authorization: authHeader
|
|
1730
2182
|
}
|
|
1731
2183
|
});
|
|
1732
|
-
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1733
2184
|
const forwarder = new StreamForwarder(ws, port, {
|
|
1734
|
-
|
|
1735
|
-
if (path === TUNNEL_DRAIN_PING_PATH) return;
|
|
1736
|
-
streamStartTimes.set(sid, Date.now());
|
|
1737
|
-
onRequest?.(method, path, sid);
|
|
1738
|
-
},
|
|
1739
|
-
onHead: (sid, status) => {
|
|
1740
|
-
const startedAt = streamStartTimes.get(sid);
|
|
1741
|
-
streamStartTimes.delete(sid);
|
|
1742
|
-
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1743
|
-
},
|
|
2185
|
+
onHead: () => onResponse?.(),
|
|
1744
2186
|
onDrainPing: () => onDrainPing?.()
|
|
1745
2187
|
});
|
|
1746
2188
|
const connectionTimeout = setTimeout(() => {
|
|
@@ -1788,7 +2230,7 @@ function connectTunnel(options) {
|
|
|
1788
2230
|
clearTimeout(connectionTimeout);
|
|
1789
2231
|
const connectedAgentId = message.agent_id ?? agentId;
|
|
1790
2232
|
onConnected?.(connectedAgentId);
|
|
1791
|
-
|
|
2233
|
+
resolve3({
|
|
1792
2234
|
ws,
|
|
1793
2235
|
close: () => ws.close(1e3, "CLI shutdown")
|
|
1794
2236
|
});
|
|
@@ -1816,7 +2258,6 @@ function connectTunnel(options) {
|
|
|
1816
2258
|
ws.on("close", (code, reason) => {
|
|
1817
2259
|
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1818
2260
|
forwarder.abortAll();
|
|
1819
|
-
streamStartTimes.clear();
|
|
1820
2261
|
onDisconnected?.(code, reasonStr);
|
|
1821
2262
|
});
|
|
1822
2263
|
});
|
|
@@ -1851,7 +2292,11 @@ var RunnerConnection = class {
|
|
|
1851
2292
|
if (this.connection) {
|
|
1852
2293
|
try {
|
|
1853
2294
|
this.connection.close();
|
|
1854
|
-
} catch {
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
log("error", "runner_connection_close_failed", {
|
|
2297
|
+
agent_id: this.resolvedAgentId,
|
|
2298
|
+
...errorFields(err)
|
|
2299
|
+
});
|
|
1855
2300
|
}
|
|
1856
2301
|
this.connection = null;
|
|
1857
2302
|
}
|
|
@@ -1903,6 +2348,443 @@ var RunnerConnection = class {
|
|
|
1903
2348
|
}
|
|
1904
2349
|
};
|
|
1905
2350
|
|
|
2351
|
+
// src/lib/tunnel/ready-marker.ts
|
|
2352
|
+
import { writeFileSync } from "fs";
|
|
2353
|
+
function writeTunnelReadyMarker(path, agentId) {
|
|
2354
|
+
try {
|
|
2355
|
+
writeFileSync(path, `${agentId}
|
|
2356
|
+
`);
|
|
2357
|
+
return { ok: true };
|
|
2358
|
+
} catch (error2) {
|
|
2359
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// src/lib/claude-usage-reporting.ts
|
|
2364
|
+
var VALID_MODES = ["auto", "on", "off"];
|
|
2365
|
+
function resolveClaudeUsageReportingMode(flagValue, env) {
|
|
2366
|
+
const raw = flagValue ?? env.EVIDENT_CLAUDE_USAGE_REPORTING;
|
|
2367
|
+
if (raw === void 0 || raw === "") {
|
|
2368
|
+
return { mode: "auto", warnings: [] };
|
|
2369
|
+
}
|
|
2370
|
+
const normalized = raw.trim().toLowerCase();
|
|
2371
|
+
if (VALID_MODES.includes(normalized)) {
|
|
2372
|
+
return { mode: normalized, warnings: [] };
|
|
2373
|
+
}
|
|
2374
|
+
const source = flagValue !== void 0 ? "--claude-usage-reporting" : "EVIDENT_CLAUDE_USAGE_REPORTING";
|
|
2375
|
+
return {
|
|
2376
|
+
mode: "auto",
|
|
2377
|
+
warnings: [
|
|
2378
|
+
`Ignoring invalid ${source} "${raw}": expected one of ${VALID_MODES.join(", ")}; using auto`
|
|
2379
|
+
]
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
var BASE_REPORT_DELAY_MS = 10 * 6e4;
|
|
2383
|
+
var REPORT_DELAY_JITTER_FRACTION = 0.2;
|
|
2384
|
+
function nextReportDelayMs(random = Math.random) {
|
|
2385
|
+
const jitterRangeMs = BASE_REPORT_DELAY_MS * REPORT_DELAY_JITTER_FRACTION;
|
|
2386
|
+
return BASE_REPORT_DELAY_MS - jitterRangeMs + random() * (2 * jitterRangeMs);
|
|
2387
|
+
}
|
|
2388
|
+
var FIRST_REPORT_DELAY_MS = 5e3 + Math.random() * 1e4;
|
|
2389
|
+
|
|
2390
|
+
// src/lib/channels/driver.ts
|
|
2391
|
+
import { homedir as homedir2 } from "os";
|
|
2392
|
+
|
|
2393
|
+
// src/lib/file-push.ts
|
|
2394
|
+
import { randomUUID } from "crypto";
|
|
2395
|
+
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2396
|
+
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
|
|
2397
|
+
var FILE_MODE = 384;
|
|
2398
|
+
var DIRECTORY_MODE = 448;
|
|
2399
|
+
async function writePushedFile(request) {
|
|
2400
|
+
const { requestedPath, content, allowedDirectories, homeDir } = request;
|
|
2401
|
+
const bytes = content.byteLength;
|
|
2402
|
+
if (allowedDirectories.length === 0) {
|
|
2403
|
+
return refuse("file_sync_disabled", "File sync is not enabled on this runner.", {
|
|
2404
|
+
path: requestedPath,
|
|
2405
|
+
bytes
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
if (bytes > MAX_FILE_PUSH_BYTES) {
|
|
2409
|
+
return refuse(
|
|
2410
|
+
"file_too_large",
|
|
2411
|
+
`File is ${bytes} bytes; the limit is ${MAX_FILE_PUSH_BYTES}.`,
|
|
2412
|
+
{
|
|
2413
|
+
path: requestedPath,
|
|
2414
|
+
bytes
|
|
2415
|
+
}
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
const candidate = expandAndValidate(requestedPath, homeDir);
|
|
2419
|
+
if (candidate === null) {
|
|
2420
|
+
return refuse("invalid_path", "The requested path is not a valid absolute file path.", {
|
|
2421
|
+
path: requestedPath,
|
|
2422
|
+
bytes
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
try {
|
|
2426
|
+
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2427
|
+
dirname2(candidate)
|
|
2428
|
+
);
|
|
2429
|
+
const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
|
|
2430
|
+
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2431
|
+
if (allowedDirectory === null) {
|
|
2432
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2433
|
+
path: realTarget,
|
|
2434
|
+
bytes
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
if (missingSegments.length > 0) {
|
|
2438
|
+
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2439
|
+
const realParent = await realpath(dirname2(realTarget));
|
|
2440
|
+
if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2441
|
+
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2442
|
+
path: realTarget,
|
|
2443
|
+
bytes,
|
|
2444
|
+
reason: "parent_changed_after_create"
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
await writeAtomically(realTarget, content);
|
|
2449
|
+
log("info", "file_push_written", { path: realTarget, bytes });
|
|
2450
|
+
return { ok: true, path: realTarget };
|
|
2451
|
+
} catch (err) {
|
|
2452
|
+
const errno = err.code ?? "UNKNOWN";
|
|
2453
|
+
return refuse("write_failed", `The runner could not write the file (${errno}).`, {
|
|
2454
|
+
path: candidate,
|
|
2455
|
+
bytes,
|
|
2456
|
+
errno,
|
|
2457
|
+
...errorFields(err)
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
function expandAndValidate(requestedPath, homeDir) {
|
|
2462
|
+
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2463
|
+
return null;
|
|
2464
|
+
}
|
|
2465
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2466
|
+
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2467
|
+
return null;
|
|
2468
|
+
}
|
|
2469
|
+
if (!isAbsolute(expanded)) {
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
const candidate = resolve2(expanded);
|
|
2473
|
+
const name = basename(candidate);
|
|
2474
|
+
return name === "" || name === "." || name === ".." ? null : candidate;
|
|
2475
|
+
}
|
|
2476
|
+
async function resolveNearestExistingAncestor(directory) {
|
|
2477
|
+
const missingSegments = [];
|
|
2478
|
+
let current = directory;
|
|
2479
|
+
for (; ; ) {
|
|
2480
|
+
try {
|
|
2481
|
+
return { existingAncestor: await realpath(current), missingSegments };
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
const parent = dirname2(current);
|
|
2484
|
+
if (err.code !== "ENOENT" || parent === current) {
|
|
2485
|
+
throw err;
|
|
2486
|
+
}
|
|
2487
|
+
missingSegments.unshift(basename(current));
|
|
2488
|
+
current = parent;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async function findContainingAllowedDirectory(allowedDirectories, realTarget) {
|
|
2493
|
+
for (const directory of allowedDirectories) {
|
|
2494
|
+
if (!isAbsolute(directory)) {
|
|
2495
|
+
log("warn", "file_push_allowed_directory_skipped", { directory, reason: "not_absolute" });
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
const realDirectory = await realpathCreatingIfMissing(directory);
|
|
2499
|
+
if (realDirectory !== null && contains(realDirectory, realTarget)) {
|
|
2500
|
+
return realDirectory;
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
return null;
|
|
2504
|
+
}
|
|
2505
|
+
async function realpathCreatingIfMissing(directory) {
|
|
2506
|
+
try {
|
|
2507
|
+
return await realpath(directory);
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
if (err.code !== "ENOENT") {
|
|
2510
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2511
|
+
directory,
|
|
2512
|
+
reason: "unresolvable",
|
|
2513
|
+
...errorFields(err)
|
|
2514
|
+
});
|
|
2515
|
+
return null;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
try {
|
|
2519
|
+
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
2520
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
2521
|
+
return await realpath(directory);
|
|
2522
|
+
} catch (err) {
|
|
2523
|
+
log("warn", "file_push_allowed_directory_skipped", {
|
|
2524
|
+
directory,
|
|
2525
|
+
reason: "create_failed",
|
|
2526
|
+
...errorFields(err)
|
|
2527
|
+
});
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
function contains(realDirectory, realTarget) {
|
|
2532
|
+
const rel = relative(realDirectory, realTarget);
|
|
2533
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
2534
|
+
}
|
|
2535
|
+
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2536
|
+
let current = existingAncestor;
|
|
2537
|
+
for (const segment of missingSegments) {
|
|
2538
|
+
current = join2(current, segment);
|
|
2539
|
+
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2540
|
+
await chmod(current, DIRECTORY_MODE);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
async function writeAtomically(realTarget, content) {
|
|
2544
|
+
const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2545
|
+
let handle;
|
|
2546
|
+
try {
|
|
2547
|
+
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
2548
|
+
await handle.writeFile(content);
|
|
2549
|
+
await handle.chmod(FILE_MODE);
|
|
2550
|
+
await handle.close();
|
|
2551
|
+
handle = void 0;
|
|
2552
|
+
await rename(temporaryPath, realTarget);
|
|
2553
|
+
} catch (err) {
|
|
2554
|
+
await discardTemporaryFile(temporaryPath, handle);
|
|
2555
|
+
throw err;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
async function discardTemporaryFile(temporaryPath, handle) {
|
|
2559
|
+
try {
|
|
2560
|
+
await handle?.close();
|
|
2561
|
+
} catch (err) {
|
|
2562
|
+
log("warn", "file_push_temp_close_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2563
|
+
}
|
|
2564
|
+
try {
|
|
2565
|
+
await unlink(temporaryPath);
|
|
2566
|
+
} catch (err) {
|
|
2567
|
+
const errno = err.code;
|
|
2568
|
+
if (errno !== "ENOENT" && errno !== "ENOTDIR") {
|
|
2569
|
+
log("warn", "file_push_temp_cleanup_failed", { path: temporaryPath, ...errorFields(err) });
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
function refuse(code, message, fields) {
|
|
2574
|
+
log(code === "write_failed" ? "error" : "warn", "file_push_refused", { code, ...fields });
|
|
2575
|
+
return { ok: false, code, message };
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// src/lib/runner-file-sync.ts
|
|
2579
|
+
var MAX_ACK_ATTEMPTS = 5;
|
|
2580
|
+
async function syncPendingRunnerFiles(options) {
|
|
2581
|
+
const pending = await listPendingFiles(options);
|
|
2582
|
+
const pendingIds = new Set(pending.map((file) => file.id));
|
|
2583
|
+
for (const id of options.ackFailures.keys()) {
|
|
2584
|
+
if (!pendingIds.has(id)) options.ackFailures.delete(id);
|
|
2585
|
+
}
|
|
2586
|
+
if (pending.length === 0) return 0;
|
|
2587
|
+
options.log({
|
|
2588
|
+
level: "info",
|
|
2589
|
+
message: `Runner file sync: ${pending.length} file(s) queued for this runner`
|
|
2590
|
+
});
|
|
2591
|
+
let applied = 0;
|
|
2592
|
+
for (const file of pending) {
|
|
2593
|
+
if ((options.ackFailures.get(file.id) ?? 0) >= MAX_ACK_ATTEMPTS) continue;
|
|
2594
|
+
if (await applyOne(options, file)) applied += 1;
|
|
2595
|
+
}
|
|
2596
|
+
return applied;
|
|
2597
|
+
}
|
|
2598
|
+
async function listPendingFiles(options) {
|
|
2599
|
+
let res;
|
|
2600
|
+
try {
|
|
2601
|
+
res = await options.fetchImpl(`${options.apiUrl}/runners/${options.agentId}/files/pending`, {
|
|
2602
|
+
headers: { Authorization: options.getAuthHeader() }
|
|
2603
|
+
});
|
|
2604
|
+
} catch (err) {
|
|
2605
|
+
options.log({
|
|
2606
|
+
level: "warn",
|
|
2607
|
+
message: `Could not list pending runner files \u2014 retrying on the next drain: ${describe(err)}`
|
|
2608
|
+
});
|
|
2609
|
+
return [];
|
|
2610
|
+
}
|
|
2611
|
+
if (!res.ok) {
|
|
2612
|
+
options.log({
|
|
2613
|
+
level: res.status === 404 ? "debug" : "warn",
|
|
2614
|
+
message: `Listing pending runner files returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2615
|
+
});
|
|
2616
|
+
return [];
|
|
2617
|
+
}
|
|
2618
|
+
let body;
|
|
2619
|
+
try {
|
|
2620
|
+
body = await res.json();
|
|
2621
|
+
} catch (err) {
|
|
2622
|
+
options.log({
|
|
2623
|
+
level: "warn",
|
|
2624
|
+
message: `Pending runner file list was not readable JSON \u2014 retrying on the next drain: ${describe(err)}`
|
|
2625
|
+
});
|
|
2626
|
+
return [];
|
|
2627
|
+
}
|
|
2628
|
+
if (!Array.isArray(body)) {
|
|
2629
|
+
options.log({
|
|
2630
|
+
level: "warn",
|
|
2631
|
+
message: "Pending runner file list was not an array \u2014 ignoring it for this drain"
|
|
2632
|
+
});
|
|
2633
|
+
return [];
|
|
2634
|
+
}
|
|
2635
|
+
const files = [];
|
|
2636
|
+
for (const entry of body) {
|
|
2637
|
+
const file = asPendingFile(entry);
|
|
2638
|
+
if (file === null) {
|
|
2639
|
+
options.log({
|
|
2640
|
+
level: "warn",
|
|
2641
|
+
message: "Ignoring a malformed pending runner file entry (expected id, path and size)"
|
|
2642
|
+
});
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
files.push(file);
|
|
2646
|
+
}
|
|
2647
|
+
return files;
|
|
2648
|
+
}
|
|
2649
|
+
function asPendingFile(entry) {
|
|
2650
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
2651
|
+
const { id, path, size } = entry;
|
|
2652
|
+
if (typeof id !== "string" || id === "") return null;
|
|
2653
|
+
if (typeof path !== "string" || path === "") return null;
|
|
2654
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
|
|
2655
|
+
return { id, path, size };
|
|
2656
|
+
}
|
|
2657
|
+
async function applyOne(options, file) {
|
|
2658
|
+
const label = `${file.id.slice(0, 8)} (${file.path})`;
|
|
2659
|
+
if (options.allowedDirectories.length === 0) {
|
|
2660
|
+
options.log({
|
|
2661
|
+
level: "warn",
|
|
2662
|
+
message: `Runner file ${label} rejected: file sync is not enabled on this runner (start it with --enable-file-sync-to)`
|
|
2663
|
+
});
|
|
2664
|
+
await ack(options, file, "rejected", "file_sync_disabled");
|
|
2665
|
+
return false;
|
|
2666
|
+
}
|
|
2667
|
+
if (file.size > MAX_FILE_PUSH_BYTES) {
|
|
2668
|
+
options.log({
|
|
2669
|
+
level: "warn",
|
|
2670
|
+
message: `Runner file ${label} rejected: declared ${file.size} bytes, the limit is ${MAX_FILE_PUSH_BYTES}`
|
|
2671
|
+
});
|
|
2672
|
+
await ack(options, file, "rejected", "file_too_large");
|
|
2673
|
+
return false;
|
|
2674
|
+
}
|
|
2675
|
+
const download = await downloadContent(options, file, label);
|
|
2676
|
+
if (!download.ok) {
|
|
2677
|
+
if (download.terminal) await ack(options, file, "rejected", download.code);
|
|
2678
|
+
return false;
|
|
2679
|
+
}
|
|
2680
|
+
let outcome;
|
|
2681
|
+
try {
|
|
2682
|
+
outcome = await writePushedFile({
|
|
2683
|
+
requestedPath: file.path,
|
|
2684
|
+
content: download.content,
|
|
2685
|
+
allowedDirectories: options.allowedDirectories,
|
|
2686
|
+
homeDir: options.homeDir
|
|
2687
|
+
});
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
options.log({
|
|
2690
|
+
level: "error",
|
|
2691
|
+
message: `Runner file ${label} could not be written: ${describe(err)}`
|
|
2692
|
+
});
|
|
2693
|
+
await ack(options, file, "rejected", "write_failed");
|
|
2694
|
+
return false;
|
|
2695
|
+
}
|
|
2696
|
+
if (!outcome.ok) {
|
|
2697
|
+
options.log({
|
|
2698
|
+
level: "warn",
|
|
2699
|
+
message: `Runner file ${label} rejected (${outcome.code}): ${outcome.message}`
|
|
2700
|
+
});
|
|
2701
|
+
await ack(options, file, "rejected", outcome.code);
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
options.log({
|
|
2705
|
+
level: "info",
|
|
2706
|
+
message: `Runner file ${label} applied (${download.content.byteLength} bytes)`
|
|
2707
|
+
});
|
|
2708
|
+
await ack(options, file, "applied");
|
|
2709
|
+
return true;
|
|
2710
|
+
}
|
|
2711
|
+
function durableDownloadCode(status) {
|
|
2712
|
+
return status === 413 ? "file_too_large" : "write_failed";
|
|
2713
|
+
}
|
|
2714
|
+
async function downloadContent(options, file, label) {
|
|
2715
|
+
try {
|
|
2716
|
+
const res = await options.fetchImpl(
|
|
2717
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/content`,
|
|
2718
|
+
{ headers: { Authorization: options.getAuthHeader() } }
|
|
2719
|
+
);
|
|
2720
|
+
if (!res.ok) {
|
|
2721
|
+
const terminal = res.status >= 400 && res.status < 500 && res.status !== 401 && res.status !== 403 && res.status !== 408 && res.status !== 429;
|
|
2722
|
+
if (!terminal) {
|
|
2723
|
+
options.log({
|
|
2724
|
+
level: "warn",
|
|
2725
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 retrying on the next drain`
|
|
2726
|
+
});
|
|
2727
|
+
return { ok: false, terminal: false };
|
|
2728
|
+
}
|
|
2729
|
+
const code = durableDownloadCode(res.status);
|
|
2730
|
+
options.log({
|
|
2731
|
+
level: "error",
|
|
2732
|
+
message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 rejecting it as ${code} (the bytes never reached the writer)`
|
|
2733
|
+
});
|
|
2734
|
+
return { ok: false, terminal: true, code };
|
|
2735
|
+
}
|
|
2736
|
+
return { ok: true, content: Buffer.from(await res.arrayBuffer()) };
|
|
2737
|
+
} catch (err) {
|
|
2738
|
+
options.log({
|
|
2739
|
+
level: "warn",
|
|
2740
|
+
message: `Downloading runner file ${label} failed \u2014 retrying on the next drain: ${describe(err)}`
|
|
2741
|
+
});
|
|
2742
|
+
return { ok: false, terminal: false };
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
async function ack(options, file, status, reason) {
|
|
2746
|
+
const outcome = `${status}${reason ? ` (${reason})` : ""}`;
|
|
2747
|
+
try {
|
|
2748
|
+
const res = await options.fetchImpl(
|
|
2749
|
+
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
2750
|
+
{
|
|
2751
|
+
method: "POST",
|
|
2752
|
+
headers: {
|
|
2753
|
+
Authorization: options.getAuthHeader(),
|
|
2754
|
+
"Content-Type": "application/json"
|
|
2755
|
+
},
|
|
2756
|
+
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
2757
|
+
}
|
|
2758
|
+
);
|
|
2759
|
+
if (!res.ok) {
|
|
2760
|
+
recordAckFailure(
|
|
2761
|
+
options,
|
|
2762
|
+
file,
|
|
2763
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} returned HTTP ${res.status}`
|
|
2764
|
+
);
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
options.ackFailures.delete(file.id);
|
|
2768
|
+
} catch (err) {
|
|
2769
|
+
recordAckFailure(
|
|
2770
|
+
options,
|
|
2771
|
+
file,
|
|
2772
|
+
`Acking runner file ${file.id.slice(0, 8)} as ${outcome} failed: ${describe(err)}`
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
function recordAckFailure(options, file, what) {
|
|
2777
|
+
const attempts = (options.ackFailures.get(file.id) ?? 0) + 1;
|
|
2778
|
+
options.ackFailures.set(file.id, attempts);
|
|
2779
|
+
options.log({
|
|
2780
|
+
level: "error",
|
|
2781
|
+
message: attempts >= MAX_ACK_ATTEMPTS ? `${what} \u2014 giving up after ${attempts} attempts. It stays pending until the server expires it; restart the runner to retry.` : `${what} \u2014 it stays pending until a later drain re-acks it (attempt ${attempts} of ${MAX_ACK_ATTEMPTS})`
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
function describe(err) {
|
|
2785
|
+
return err instanceof Error ? err.message : String(err);
|
|
2786
|
+
}
|
|
2787
|
+
|
|
1906
2788
|
// src/lib/channels/driver.ts
|
|
1907
2789
|
function messageIdOf(m) {
|
|
1908
2790
|
if (!m || typeof m !== "object") return void 0;
|
|
@@ -1931,7 +2813,10 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
|
1931
2813
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1932
2814
|
var HEARTBEAT_MS = 6e4;
|
|
1933
2815
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
2816
|
+
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
2817
|
+
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
1934
2818
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2819
|
+
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
1935
2820
|
var ChannelAuthError = class extends Error {
|
|
1936
2821
|
constructor(message) {
|
|
1937
2822
|
super(message);
|
|
@@ -1954,7 +2839,7 @@ function backoffDelay(attempt, policy) {
|
|
|
1954
2839
|
function isRetryableStatus(status) {
|
|
1955
2840
|
return status === 429 || status >= 500 && status <= 599;
|
|
1956
2841
|
}
|
|
1957
|
-
var ChannelDriver = class {
|
|
2842
|
+
var ChannelDriver = class _ChannelDriver {
|
|
1958
2843
|
agentId;
|
|
1959
2844
|
port;
|
|
1960
2845
|
apiUrl;
|
|
@@ -1968,8 +2853,38 @@ var ChannelDriver = class {
|
|
|
1968
2853
|
pausedMaxWaitMs;
|
|
1969
2854
|
stuckQueuedMs;
|
|
1970
2855
|
now;
|
|
2856
|
+
fileSyncDirectories;
|
|
2857
|
+
homeDir;
|
|
1971
2858
|
/** Cache of conversationId → opencode sessionId. */
|
|
1972
2859
|
sessions = /* @__PURE__ */ new Map();
|
|
2860
|
+
/**
|
|
2861
|
+
* conversationId → the opencode session this runner has ABANDONED as that
|
|
2862
|
+
* conversation's binding (#553), after a genuine (`sessionExists === true`)
|
|
2863
|
+
* dispatch failure: the session still exists but is wedged, so #485's self-heal
|
|
2864
|
+
* must bind a fresh one.
|
|
2865
|
+
*
|
|
2866
|
+
* Dropping the local binding + clearing the server row is not enough on its own:
|
|
2867
|
+
* a SIBLING message dispatched earlier in the same drain is still in-flight under
|
|
2868
|
+
* the same session, and its watcher's routine status writes carry
|
|
2869
|
+
* `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
|
|
2870
|
+
* and `ensureSession`'s persisted-id fallback then reuses it, defeating the
|
|
2871
|
+
* self-heal. This map makes the runner authoritative instead of racing those
|
|
2872
|
+
* writes: *`ensureSession` never reuses an abandoned id for that conversation,
|
|
2873
|
+
* whatever the server row says* — which holds even when the resurrecting write
|
|
2874
|
+
* is one we deliberately keep (see `markDone`).
|
|
2875
|
+
*
|
|
2876
|
+
* Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
|
|
2877
|
+
* one conversation hold ONE entry (the newest abandonment replaces the older), and
|
|
2878
|
+
* hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
|
|
2879
|
+
* NEWEST abandoned id per conversation is guarded: after a second abandonment a
|
|
2880
|
+
* late sibling of the FIRST session can write that id back and `ensureSession`
|
|
2881
|
+
* will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
|
|
2882
|
+
* NOT dropped when the session's watcher tears down: `markDone` still writes the
|
|
2883
|
+
* abandoned id back (it must, or the reply is lost), so the guard has to outlive
|
|
2884
|
+
* the turn that resurrects it. In-memory only — a restart forgets it, at the same
|
|
2885
|
+
* bounded cost.
|
|
2886
|
+
*/
|
|
2887
|
+
supersededSessions = /* @__PURE__ */ new Map();
|
|
1973
2888
|
/**
|
|
1974
2889
|
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1975
2890
|
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
@@ -2079,9 +2994,12 @@ var ChannelDriver = class {
|
|
|
2079
2994
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2080
2995
|
/**
|
|
2081
2996
|
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2082
|
-
* NON-EMPTY name is stored (terminal — a real session name
|
|
2083
|
-
* so we do NOT re-GET `/session/:id` every tick.
|
|
2084
|
-
*
|
|
2997
|
+
* NON-EMPTY, non-placeholder name is stored (terminal — a real session name
|
|
2998
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
|
|
2999
|
+
* excludes OpenCode's synchronous default title (see
|
|
3000
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
|
|
3001
|
+
* as an empty title so it never latches. A missing entry = not yet resolved OR
|
|
3002
|
+
* resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
|
|
2085
3003
|
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2086
3004
|
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2087
3005
|
* no watcher) can resolve the title.
|
|
@@ -2089,6 +3007,24 @@ var ChannelDriver = class {
|
|
|
2089
3007
|
sessionTitles = /* @__PURE__ */ new Map();
|
|
2090
3008
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
2091
3009
|
draining = false;
|
|
3010
|
+
/**
|
|
3011
|
+
* Serialises runner-file syncs (#559) so the ~2s poll tick and a concurrent
|
|
3012
|
+
* drain ping don't download, write and ack the same file twice.
|
|
3013
|
+
*/
|
|
3014
|
+
syncingFiles = false;
|
|
3015
|
+
/**
|
|
3016
|
+
* Consecutive failed acks per pending file (#559). Lives on the driver so it
|
|
3017
|
+
* survives across drains — without it, a file whose ack keeps failing is
|
|
3018
|
+
* re-downloaded and re-written every ~2s until the server expires it.
|
|
3019
|
+
*/
|
|
3020
|
+
fileAckFailures = /* @__PURE__ */ new Map();
|
|
3021
|
+
/**
|
|
3022
|
+
* Monotonic count of files this runner has pulled and written (#559). Only
|
|
3023
|
+
* ever increases, so `run.ts` detects work by comparing it against the value
|
|
3024
|
+
* it saw on the previous cycle — including work that landed mid-sleep, the
|
|
3025
|
+
* same trick `lastProxiedActivityAt` uses.
|
|
3026
|
+
*/
|
|
3027
|
+
appliedFileCount = 0;
|
|
2092
3028
|
/**
|
|
2093
3029
|
* The currently-executing `drainPending()` promise, or null when idle. Lets a
|
|
2094
3030
|
* graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
|
|
@@ -2104,21 +3040,23 @@ var ChannelDriver = class {
|
|
|
2104
3040
|
* and stops opencode.
|
|
2105
3041
|
*/
|
|
2106
3042
|
stopped = false;
|
|
2107
|
-
constructor(
|
|
2108
|
-
this.agentId =
|
|
2109
|
-
this.port =
|
|
2110
|
-
this.apiUrl =
|
|
2111
|
-
this.getAuthHeader =
|
|
2112
|
-
this.conversationFilter =
|
|
2113
|
-
this.retry = { ...DEFAULT_RETRY_POLICY, ...
|
|
2114
|
-
this.log =
|
|
3043
|
+
constructor(config) {
|
|
3044
|
+
this.agentId = config.agentId;
|
|
3045
|
+
this.port = config.port;
|
|
3046
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, "");
|
|
3047
|
+
this.getAuthHeader = config.getAuthHeader;
|
|
3048
|
+
this.conversationFilter = config.conversationFilter ?? null;
|
|
3049
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config.retry };
|
|
3050
|
+
this.log = config.log ?? (() => {
|
|
2115
3051
|
});
|
|
2116
|
-
this.fetchImpl =
|
|
2117
|
-
this.sleep =
|
|
2118
|
-
this.pausedPollIntervalMs =
|
|
2119
|
-
this.pausedMaxWaitMs =
|
|
2120
|
-
this.stuckQueuedMs =
|
|
2121
|
-
this.now =
|
|
3052
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
3053
|
+
this.sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3054
|
+
this.pausedPollIntervalMs = config.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
3055
|
+
this.pausedMaxWaitMs = config.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
3056
|
+
this.stuckQueuedMs = config.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
3057
|
+
this.now = config.now ?? (() => Date.now());
|
|
3058
|
+
this.fileSyncDirectories = config.fileSyncDirectories ?? [];
|
|
3059
|
+
this.homeDir = config.homeDir ?? homedir2();
|
|
2122
3060
|
}
|
|
2123
3061
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
2124
3062
|
get opencodeBase() {
|
|
@@ -2146,6 +3084,47 @@ var ChannelDriver = class {
|
|
|
2146
3084
|
);
|
|
2147
3085
|
return run2;
|
|
2148
3086
|
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Pull-and-apply any files Evident has queued for this runner (#559), riding
|
|
3089
|
+
* the EXISTING drain cycle — `run.ts` calls it from the same ~2s channel poll
|
|
3090
|
+
* and drain ping that call `drainPending()`. There is deliberately no channel,
|
|
3091
|
+
* control frame or poll loop of its own: worst-case latency is one poll tick.
|
|
3092
|
+
*
|
|
3093
|
+
* NEVER throws and never surfaces a `ChannelAuthError`: a file failure must not
|
|
3094
|
+
* cost a conversation turn. Failures are logged and either acked as a terminal
|
|
3095
|
+
* outcome or left pending for the next drain (see `runner-file-sync.ts`).
|
|
3096
|
+
*
|
|
3097
|
+
* Re-entrant calls are skipped (the poll tick and a drain ping can overlap).
|
|
3098
|
+
*
|
|
3099
|
+
* @returns the number of files written to disk.
|
|
3100
|
+
*/
|
|
3101
|
+
async syncPendingFiles() {
|
|
3102
|
+
if (this.stopped) return 0;
|
|
3103
|
+
if (this.syncingFiles) return 0;
|
|
3104
|
+
this.syncingFiles = true;
|
|
3105
|
+
try {
|
|
3106
|
+
const applied = await syncPendingRunnerFiles({
|
|
3107
|
+
agentId: this.agentId,
|
|
3108
|
+
apiUrl: this.apiUrl,
|
|
3109
|
+
getAuthHeader: this.getAuthHeader,
|
|
3110
|
+
fetchImpl: this.fetchImpl,
|
|
3111
|
+
allowedDirectories: this.fileSyncDirectories,
|
|
3112
|
+
homeDir: this.homeDir,
|
|
3113
|
+
ackFailures: this.fileAckFailures,
|
|
3114
|
+
log: this.log
|
|
3115
|
+
});
|
|
3116
|
+
this.appliedFileCount += applied;
|
|
3117
|
+
return applied;
|
|
3118
|
+
} catch (err) {
|
|
3119
|
+
this.log({
|
|
3120
|
+
level: "error",
|
|
3121
|
+
message: `Runner file sync failed unexpectedly (message processing is unaffected): ${err instanceof Error ? err.message : String(err)}`
|
|
3122
|
+
});
|
|
3123
|
+
return 0;
|
|
3124
|
+
} finally {
|
|
3125
|
+
this.syncingFiles = false;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
2149
3128
|
async runDrain() {
|
|
2150
3129
|
let dispatched = 0;
|
|
2151
3130
|
try {
|
|
@@ -2179,6 +3158,28 @@ var ChannelDriver = class {
|
|
|
2179
3158
|
}
|
|
2180
3159
|
return false;
|
|
2181
3160
|
}
|
|
3161
|
+
/**
|
|
3162
|
+
* File-pull work, for `run.ts`'s idle accounting (#559).
|
|
3163
|
+
*
|
|
3164
|
+
* Pulling a file is real work that `drainPending()` knows nothing about, so
|
|
3165
|
+
* without this a near-idle runner counts a credential pull as an empty tick
|
|
3166
|
+
* and `--idle-timeout` can `process.exit` mid-pull — leaving a
|
|
3167
|
+
* `.evident-push-*.tmp` behind — or immediately after the write, before the
|
|
3168
|
+
* browser has run the authorize/callback that activates it (the user then sees
|
|
3169
|
+
* `saved_not_activated` for a runner that was fine).
|
|
3170
|
+
*
|
|
3171
|
+
* Two signals because one cannot cover both cases: `inFlight` is the pull
|
|
3172
|
+
* happening RIGHT NOW (it may outlive the tick that started it), and
|
|
3173
|
+
* `appliedFiles` is monotonic so a pull that started AND finished between two
|
|
3174
|
+
* idle checks still shows up as an advance.
|
|
3175
|
+
*
|
|
3176
|
+
* CALLER CONTRACT: sample `inFlight` BEFORE calling `syncPendingFiles()` for
|
|
3177
|
+
* the cycle. `syncPendingFiles` sets the flag synchronously, so a caller that
|
|
3178
|
+
* samples afterwards reads `true` every single cycle and can never idle out.
|
|
3179
|
+
*/
|
|
3180
|
+
fileSyncActivity() {
|
|
3181
|
+
return { appliedFiles: this.appliedFileCount, inFlight: this.syncingFiles };
|
|
3182
|
+
}
|
|
2182
3183
|
/**
|
|
2183
3184
|
* OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
|
|
2184
3185
|
* exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
|
|
@@ -2240,7 +3241,7 @@ var ChannelDriver = class {
|
|
|
2240
3241
|
await this.sleep(step);
|
|
2241
3242
|
}
|
|
2242
3243
|
}
|
|
2243
|
-
while (this.hasInFlightWatchers()) {
|
|
3244
|
+
while (this.hasInFlightWatchers() || this.syncingFiles) {
|
|
2244
3245
|
if (this.now() >= deadline) return false;
|
|
2245
3246
|
await this.sleep(step);
|
|
2246
3247
|
}
|
|
@@ -2275,10 +3276,15 @@ var ChannelDriver = class {
|
|
|
2275
3276
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
2276
3277
|
*/
|
|
2277
3278
|
async processConversation(conv) {
|
|
2278
|
-
const sessionId = await this.ensureSession(conv);
|
|
3279
|
+
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
2279
3280
|
const messages = await this.getPendingMessages(conv.id);
|
|
2280
3281
|
let dispatched = 0;
|
|
2281
3282
|
let skippedAlreadyDispatched = 0;
|
|
3283
|
+
if (refusedSessionId && messages.length > 0) {
|
|
3284
|
+
void this.postSignal(conv.id, messages[0].id, "session_superseded", {
|
|
3285
|
+
superseded_session_id: refusedSessionId
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
2282
3288
|
for (const message of messages) {
|
|
2283
3289
|
if (this.stopped) break;
|
|
2284
3290
|
if (this.dispatched.has(message.id)) {
|
|
@@ -2305,7 +3311,8 @@ var ChannelDriver = class {
|
|
|
2305
3311
|
} catch (err) {
|
|
2306
3312
|
if (err instanceof ChannelAuthError) throw err;
|
|
2307
3313
|
this.dispatched.delete(message.id);
|
|
2308
|
-
|
|
3314
|
+
const exists = await sessionExists(this.port, sessionId);
|
|
3315
|
+
if (exists === false) {
|
|
2309
3316
|
this.sessions.delete(conv.id);
|
|
2310
3317
|
this.log({
|
|
2311
3318
|
level: "warn",
|
|
@@ -2315,15 +3322,39 @@ var ChannelDriver = class {
|
|
|
2315
3322
|
});
|
|
2316
3323
|
break;
|
|
2317
3324
|
}
|
|
2318
|
-
|
|
3325
|
+
if (exists === null) {
|
|
3326
|
+
this.log({
|
|
3327
|
+
level: "warn",
|
|
3328
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed and session (${sessionId.slice(0, 8)}) existence could not be confirmed (opencode momentarily unreachable) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick rather than treating it as a genuine failure.`,
|
|
3329
|
+
conversation_id: conv.id,
|
|
3330
|
+
message_id: message.id
|
|
3331
|
+
});
|
|
3332
|
+
break;
|
|
3333
|
+
}
|
|
3334
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
3335
|
+
this.sessions.delete(conv.id);
|
|
3336
|
+
this.supersede(conv.id, sessionId);
|
|
3337
|
+
this.log({
|
|
3338
|
+
level: "warn",
|
|
3339
|
+
message: `Abandoning OpenCode session ${sessionId.slice(0, 8)} as the binding for conversation ${conv.id.slice(0, 8)} (it exists but failed to run a turn) \u2014 a fresh session is created on the next tick, whatever the persisted binding says by then.`,
|
|
3340
|
+
conversation_id: conv.id,
|
|
3341
|
+
message_id: message.id
|
|
3342
|
+
});
|
|
3343
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
3344
|
+
this.log({
|
|
3345
|
+
level: "warn",
|
|
3346
|
+
message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
3347
|
+
conversation_id: conv.id,
|
|
3348
|
+
message_id: message.id
|
|
3349
|
+
});
|
|
2319
3350
|
});
|
|
2320
3351
|
this.log({
|
|
2321
3352
|
level: "error",
|
|
2322
|
-
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${
|
|
3353
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
|
|
2323
3354
|
conversation_id: conv.id,
|
|
2324
3355
|
message_id: message.id
|
|
2325
3356
|
});
|
|
2326
|
-
|
|
3357
|
+
break;
|
|
2327
3358
|
}
|
|
2328
3359
|
if (opencodeMessageId === null) {
|
|
2329
3360
|
this.log({
|
|
@@ -2349,8 +3380,42 @@ var ChannelDriver = class {
|
|
|
2349
3380
|
this.ensureWatcherRunning(sessionId);
|
|
2350
3381
|
return dispatched;
|
|
2351
3382
|
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
3385
|
+
* (#553). Keyed by conversation and hard-capped, so it cannot grow with the
|
|
3386
|
+
* number of failures — see the `supersededSessions` field doc.
|
|
3387
|
+
*/
|
|
3388
|
+
supersede(conversationId, sessionId) {
|
|
3389
|
+
this.supersededSessions.delete(conversationId);
|
|
3390
|
+
this.supersededSessions.set(conversationId, sessionId);
|
|
3391
|
+
while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
|
|
3392
|
+
const oldest = this.supersededSessions.keys().next().value;
|
|
3393
|
+
if (oldest === void 0) return;
|
|
3394
|
+
this.supersededSessions.delete(oldest);
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
/** Whether `sessionId` is the session this conversation has abandoned (#553). */
|
|
3398
|
+
isSuperseded(conversationId, sessionId) {
|
|
3399
|
+
return this.supersededSessions.get(conversationId) === sessionId;
|
|
3400
|
+
}
|
|
3401
|
+
/**
|
|
3402
|
+
* Resolve the opencode session to run this conversation's turns in.
|
|
3403
|
+
*
|
|
3404
|
+
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
3405
|
+
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
3406
|
+
* happened and a fresh session was bound instead. The caller reports it.
|
|
3407
|
+
*/
|
|
2352
3408
|
async ensureSession(conv) {
|
|
2353
3409
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
3410
|
+
if (bound && this.isSuperseded(conv.id, bound)) {
|
|
3411
|
+
this.log({
|
|
3412
|
+
level: "warn",
|
|
3413
|
+
message: `OpenCode session ${bound.slice(0, 8)} was abandoned for conversation ${conv.id.slice(0, 8)} after a failed dispatch but is still bound to it (the persisted id was written back by a turn already in flight) \u2014 ignoring it and binding a fresh session.`,
|
|
3414
|
+
conversation_id: conv.id
|
|
3415
|
+
});
|
|
3416
|
+
this.sessions.delete(conv.id);
|
|
3417
|
+
return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
|
|
3418
|
+
}
|
|
2354
3419
|
if (bound) {
|
|
2355
3420
|
const exists = await sessionExists(this.port, bound);
|
|
2356
3421
|
if (exists === false) {
|
|
@@ -2360,12 +3425,12 @@ var ChannelDriver = class {
|
|
|
2360
3425
|
conversation_id: conv.id
|
|
2361
3426
|
});
|
|
2362
3427
|
this.sessions.delete(conv.id);
|
|
2363
|
-
return this.createAndBindSession(conv.id);
|
|
3428
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2364
3429
|
}
|
|
2365
3430
|
this.sessions.set(conv.id, bound);
|
|
2366
|
-
return bound;
|
|
3431
|
+
return { sessionId: bound };
|
|
2367
3432
|
}
|
|
2368
|
-
return this.createAndBindSession(conv.id);
|
|
3433
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2369
3434
|
}
|
|
2370
3435
|
/**
|
|
2371
3436
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -2376,7 +3441,12 @@ var ChannelDriver = class {
|
|
|
2376
3441
|
const directory = await this.resolveOpenCodeDirectory();
|
|
2377
3442
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
2378
3443
|
this.sessions.set(conversationId, sessionId);
|
|
2379
|
-
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
3444
|
+
await this.persistSession(conversationId, sessionId).catch((err) => {
|
|
3445
|
+
this.log({
|
|
3446
|
+
level: "warn",
|
|
3447
|
+
message: `Persisting the OpenCode session binding ${sessionId.slice(0, 8)} for conversation ${conversationId.slice(0, 8)} failed (best-effort, not retried) \u2014 the completion PATCH also carries opencode_session_id, so the binding is repaired when the turn finishes: ${err instanceof Error ? err.message : String(err)}`,
|
|
3448
|
+
conversation_id: conversationId
|
|
3449
|
+
});
|
|
2380
3450
|
});
|
|
2381
3451
|
return sessionId;
|
|
2382
3452
|
}
|
|
@@ -2445,7 +3515,7 @@ var ChannelDriver = class {
|
|
|
2445
3515
|
}
|
|
2446
3516
|
/**
|
|
2447
3517
|
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2448
|
-
* (`GET {apiUrl}/
|
|
3518
|
+
* (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2449
3519
|
* existing authenticated fetch, and base64-encode into a
|
|
2450
3520
|
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2451
3521
|
*
|
|
@@ -2453,15 +3523,38 @@ var ChannelDriver = class {
|
|
|
2453
3523
|
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2454
3524
|
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2455
3525
|
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2456
|
-
*
|
|
3526
|
+
* A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
|
|
3527
|
+
* a Slack `files:read` scope problem via `files.info`) instead resolves the
|
|
3528
|
+
* `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
|
|
3529
|
+
* user to reconnect Slack instead of a generic "unavailable". Failures are
|
|
3530
|
+
* logged with context (no silent swallow).
|
|
2457
3531
|
*/
|
|
2458
3532
|
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2459
3533
|
try {
|
|
2460
3534
|
const res = await this.fetchImpl(
|
|
2461
|
-
`${this.apiUrl}/
|
|
3535
|
+
`${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2462
3536
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2463
3537
|
);
|
|
2464
3538
|
if (!res.ok) {
|
|
3539
|
+
let reason;
|
|
3540
|
+
try {
|
|
3541
|
+
const body = await res.json();
|
|
3542
|
+
if (body && typeof body.reason === "string") reason = body.reason;
|
|
3543
|
+
} catch (parseErr) {
|
|
3544
|
+
this.log({
|
|
3545
|
+
level: "debug",
|
|
3546
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index}: error body was not JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}) \u2014 treating as a plain failure`,
|
|
3547
|
+
message_id: messageId
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
if (reason === "needs_reauth") {
|
|
3551
|
+
this.log({
|
|
3552
|
+
level: "error",
|
|
3553
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 server confirmed a Slack reauth/scope problem \u2014 omitting this image (text turn proceeds)`,
|
|
3554
|
+
message_id: messageId
|
|
3555
|
+
});
|
|
3556
|
+
return { needsReauth: true };
|
|
3557
|
+
}
|
|
2465
3558
|
this.log({
|
|
2466
3559
|
level: "error",
|
|
2467
3560
|
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
@@ -2501,6 +3594,9 @@ var ChannelDriver = class {
|
|
|
2501
3594
|
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2502
3595
|
this.attachmentsSkippedSignalled.add(messageId);
|
|
2503
3596
|
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
3597
|
+
const failedReason = outcomes.some(
|
|
3598
|
+
(o) => o.status === "failed" && o.reason === "needs_reauth"
|
|
3599
|
+
) ? "needs_reauth" : void 0;
|
|
2504
3600
|
this.log({
|
|
2505
3601
|
level: "info",
|
|
2506
3602
|
message: `Message ${messageId.slice(0, 8)}: ${skipped} image(s) skipped (${capabilityUnknown ? "capability was unreadable \u2014 failed open to text-only" : "model not attachment-capable"}), ${failed} image(s) unavailable (deleted-at-source or fetch failure) \u2014 noting to Evident`,
|
|
@@ -2510,7 +3606,8 @@ var ChannelDriver = class {
|
|
|
2510
3606
|
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2511
3607
|
skipped,
|
|
2512
3608
|
failed,
|
|
2513
|
-
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
3609
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {},
|
|
3610
|
+
...failedReason ? { failed_reason: failedReason } : {}
|
|
2514
3611
|
});
|
|
2515
3612
|
}
|
|
2516
3613
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
@@ -2541,12 +3638,17 @@ var ChannelDriver = class {
|
|
|
2541
3638
|
stuckReported: false,
|
|
2542
3639
|
lastAliveAt: 0,
|
|
2543
3640
|
aliveInFlight: false,
|
|
3641
|
+
titleSynced: false,
|
|
3642
|
+
titleSyncInFlight: false,
|
|
2544
3643
|
awaitingHumanLatched: false,
|
|
2545
3644
|
pausedOnQuestion: false,
|
|
2546
3645
|
pausedOnPermission: false,
|
|
2547
3646
|
pausedClearConfirmed: false,
|
|
2548
3647
|
pausedInFlight: false,
|
|
2549
|
-
deliveryDeadlineAnchored: false
|
|
3648
|
+
deliveryDeadlineAnchored: false,
|
|
3649
|
+
b2PinnedSinceMs: 0,
|
|
3650
|
+
b2LastDescendantCheckMs: 0,
|
|
3651
|
+
b2AbandonedSignalled: false
|
|
2550
3652
|
});
|
|
2551
3653
|
}
|
|
2552
3654
|
/**
|
|
@@ -2614,12 +3716,17 @@ var ChannelDriver = class {
|
|
|
2614
3716
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
2615
3717
|
lastAliveAt: 0,
|
|
2616
3718
|
aliveInFlight: false,
|
|
3719
|
+
titleSynced: false,
|
|
3720
|
+
titleSyncInFlight: false,
|
|
2617
3721
|
awaitingHumanLatched: false,
|
|
2618
3722
|
pausedOnQuestion: false,
|
|
2619
3723
|
pausedOnPermission: false,
|
|
2620
3724
|
pausedClearConfirmed: false,
|
|
2621
3725
|
pausedInFlight: false,
|
|
2622
|
-
deliveryDeadlineAnchored: false
|
|
3726
|
+
deliveryDeadlineAnchored: false,
|
|
3727
|
+
b2PinnedSinceMs: 0,
|
|
3728
|
+
b2LastDescendantCheckMs: 0,
|
|
3729
|
+
b2AbandonedSignalled: false
|
|
2623
3730
|
});
|
|
2624
3731
|
}
|
|
2625
3732
|
/**
|
|
@@ -2781,56 +3888,7 @@ var ChannelDriver = class {
|
|
|
2781
3888
|
}
|
|
2782
3889
|
}
|
|
2783
3890
|
if (state === "done") {
|
|
2784
|
-
this.
|
|
2785
|
-
if (!inFlight.done) {
|
|
2786
|
-
this.log({
|
|
2787
|
-
level: "info",
|
|
2788
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
2789
|
-
conversation_id: conv.id,
|
|
2790
|
-
message_id: inFlight.evidentMessageId
|
|
2791
|
-
});
|
|
2792
|
-
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2793
|
-
try {
|
|
2794
|
-
await this.markDone(
|
|
2795
|
-
conv.id,
|
|
2796
|
-
inFlight.evidentMessageId,
|
|
2797
|
-
sessionId,
|
|
2798
|
-
inFlight.opencodeMessageId,
|
|
2799
|
-
title
|
|
2800
|
-
);
|
|
2801
|
-
} catch (err) {
|
|
2802
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
2803
|
-
if (err instanceof ChannelTerminalError) {
|
|
2804
|
-
this.log({
|
|
2805
|
-
level: "warn",
|
|
2806
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2807
|
-
conversation_id: conv.id,
|
|
2808
|
-
message_id: inFlight.evidentMessageId
|
|
2809
|
-
});
|
|
2810
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2811
|
-
return;
|
|
2812
|
-
}
|
|
2813
|
-
if (this.now() >= inFlight.deadline) {
|
|
2814
|
-
this.log({
|
|
2815
|
-
level: "warn",
|
|
2816
|
-
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)}`,
|
|
2817
|
-
conversation_id: conv.id,
|
|
2818
|
-
message_id: inFlight.evidentMessageId
|
|
2819
|
-
});
|
|
2820
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2821
|
-
return;
|
|
2822
|
-
}
|
|
2823
|
-
this.log({
|
|
2824
|
-
level: "warn",
|
|
2825
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2826
|
-
conversation_id: conv.id,
|
|
2827
|
-
message_id: inFlight.evidentMessageId
|
|
2828
|
-
});
|
|
2829
|
-
return;
|
|
2830
|
-
}
|
|
2831
|
-
inFlight.done = true;
|
|
2832
|
-
}
|
|
2833
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3891
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
2834
3892
|
return;
|
|
2835
3893
|
}
|
|
2836
3894
|
if (state === "failed") {
|
|
@@ -2843,8 +3901,17 @@ var ChannelDriver = class {
|
|
|
2843
3901
|
conversation_id: conv.id,
|
|
2844
3902
|
message_id: inFlight.evidentMessageId
|
|
2845
3903
|
});
|
|
3904
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
3905
|
+
const failure = await this.classifyModelAuthFailure(messages, inFlight.opencodeMessageId);
|
|
2846
3906
|
try {
|
|
2847
|
-
await this.markFailed(
|
|
3907
|
+
await this.markFailed(
|
|
3908
|
+
conv.id,
|
|
3909
|
+
inFlight.evidentMessageId,
|
|
3910
|
+
sessionId,
|
|
3911
|
+
error2,
|
|
3912
|
+
usage,
|
|
3913
|
+
failure
|
|
3914
|
+
);
|
|
2848
3915
|
} catch (err) {
|
|
2849
3916
|
if (err instanceof ChannelAuthError) throw err;
|
|
2850
3917
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -2889,6 +3956,44 @@ var ChannelDriver = class {
|
|
|
2889
3956
|
});
|
|
2890
3957
|
}
|
|
2891
3958
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
3959
|
+
const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
|
|
3960
|
+
const snapshotReadable = messages != null && messages.length > 0;
|
|
3961
|
+
if (!pinnedNow) {
|
|
3962
|
+
if (snapshotReadable) {
|
|
3963
|
+
inFlight.b2PinnedSinceMs = 0;
|
|
3964
|
+
inFlight.b2LastDescendantCheckMs = 0;
|
|
3965
|
+
inFlight.b2AbandonedSignalled = false;
|
|
3966
|
+
}
|
|
3967
|
+
} else {
|
|
3968
|
+
if (inFlight.b2AbandonedSignalled) {
|
|
3969
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
|
|
3973
|
+
const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
|
|
3974
|
+
if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
|
|
3975
|
+
inFlight.b2LastDescendantCheckMs = this.now();
|
|
3976
|
+
const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
|
|
3977
|
+
if (isB2AbandonmentConfirmed({
|
|
3978
|
+
pinnedForMs,
|
|
3979
|
+
minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
|
|
3980
|
+
descendantOngoing
|
|
3981
|
+
})) {
|
|
3982
|
+
inFlight.b2AbandonedSignalled = true;
|
|
3983
|
+
this.log({
|
|
3984
|
+
level: "warn",
|
|
3985
|
+
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`,
|
|
3986
|
+
conversation_id: conv.id,
|
|
3987
|
+
message_id: id
|
|
3988
|
+
});
|
|
3989
|
+
void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
|
|
3990
|
+
watched_for_ms: pinnedForMs
|
|
3991
|
+
});
|
|
3992
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
2892
3997
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2893
3998
|
this.log({
|
|
2894
3999
|
level: "warn",
|
|
@@ -2908,6 +4013,18 @@ var ChannelDriver = class {
|
|
|
2908
4013
|
inFlight.aliveInFlight = false;
|
|
2909
4014
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
2910
4015
|
});
|
|
4016
|
+
if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
|
|
4017
|
+
inFlight.titleSyncInFlight = true;
|
|
4018
|
+
void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
|
|
4019
|
+
if (!title) {
|
|
4020
|
+
inFlight.titleSyncInFlight = false;
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
const ok = await this.patchConversationTitle(conv.id, title);
|
|
4024
|
+
inFlight.titleSyncInFlight = false;
|
|
4025
|
+
if (ok) inFlight.titleSynced = true;
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
2911
4028
|
}
|
|
2912
4029
|
if (awaitingHuman) {
|
|
2913
4030
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -2945,6 +4062,70 @@ var ChannelDriver = class {
|
|
|
2945
4062
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2946
4063
|
}
|
|
2947
4064
|
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Settle a message whose run-state has resolved `'done'` — extracted verbatim
|
|
4067
|
+
* (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
|
|
4068
|
+
* inline `state === 'done'` branch body, so a SECOND caller (the #721
|
|
4069
|
+
* b2-abandonment resolution) can reach the exact same completion behavior
|
|
4070
|
+
* (delivery-deadline anchoring, title resolution, usage extraction, and
|
|
4071
|
+
* `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
|
|
4072
|
+
* and risking the two copies silently drifting apart.
|
|
4073
|
+
*/
|
|
4074
|
+
async settleMessageDone(sessionId, watcher, inFlight, messages) {
|
|
4075
|
+
const conv = watcher.conv;
|
|
4076
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
4077
|
+
if (!inFlight.done) {
|
|
4078
|
+
this.log({
|
|
4079
|
+
level: "info",
|
|
4080
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
4081
|
+
conversation_id: conv.id,
|
|
4082
|
+
message_id: inFlight.evidentMessageId
|
|
4083
|
+
});
|
|
4084
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
4085
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
4086
|
+
try {
|
|
4087
|
+
await this.markDone(
|
|
4088
|
+
conv.id,
|
|
4089
|
+
inFlight.evidentMessageId,
|
|
4090
|
+
sessionId,
|
|
4091
|
+
inFlight.opencodeMessageId,
|
|
4092
|
+
title,
|
|
4093
|
+
usage
|
|
4094
|
+
);
|
|
4095
|
+
} catch (err) {
|
|
4096
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4097
|
+
if (err instanceof ChannelTerminalError) {
|
|
4098
|
+
this.log({
|
|
4099
|
+
level: "warn",
|
|
4100
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
4101
|
+
conversation_id: conv.id,
|
|
4102
|
+
message_id: inFlight.evidentMessageId
|
|
4103
|
+
});
|
|
4104
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4105
|
+
return;
|
|
4106
|
+
}
|
|
4107
|
+
if (this.now() >= inFlight.deadline) {
|
|
4108
|
+
this.log({
|
|
4109
|
+
level: "warn",
|
|
4110
|
+
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)}`,
|
|
4111
|
+
conversation_id: conv.id,
|
|
4112
|
+
message_id: inFlight.evidentMessageId
|
|
4113
|
+
});
|
|
4114
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
this.log({
|
|
4118
|
+
level: "warn",
|
|
4119
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
4120
|
+
conversation_id: conv.id,
|
|
4121
|
+
message_id: inFlight.evidentMessageId
|
|
4122
|
+
});
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
4125
|
+
inFlight.done = true;
|
|
4126
|
+
}
|
|
4127
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4128
|
+
}
|
|
2948
4129
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2949
4130
|
/**
|
|
2950
4131
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
@@ -3081,7 +4262,8 @@ var ChannelDriver = class {
|
|
|
3081
4262
|
});
|
|
3082
4263
|
try {
|
|
3083
4264
|
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
-
|
|
4265
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
4266
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
3085
4267
|
} catch (err) {
|
|
3086
4268
|
if (err instanceof ChannelAuthError) throw err;
|
|
3087
4269
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -3109,6 +4291,8 @@ var ChannelDriver = class {
|
|
|
3109
4291
|
}
|
|
3110
4292
|
if (state === "failed") {
|
|
3111
4293
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
4294
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
4295
|
+
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
3112
4296
|
this.log({
|
|
3113
4297
|
level: "error",
|
|
3114
4298
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -3116,7 +4300,7 @@ var ChannelDriver = class {
|
|
|
3116
4300
|
message_id: row.id
|
|
3117
4301
|
});
|
|
3118
4302
|
try {
|
|
3119
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
4303
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage, failure);
|
|
3120
4304
|
} catch (err) {
|
|
3121
4305
|
if (err instanceof ChannelAuthError) throw err;
|
|
3122
4306
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -3522,6 +4706,47 @@ var ChannelDriver = class {
|
|
|
3522
4706
|
}
|
|
3523
4707
|
return false;
|
|
3524
4708
|
}
|
|
4709
|
+
/**
|
|
4710
|
+
* Tri-state variant of the upward parentID membership walk (#721), used ONLY
|
|
4711
|
+
* by `isAnyDescendantSessionOngoing`. Walks the SAME cached
|
|
4712
|
+
* `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
|
|
4713
|
+
* `sessionBelongsTo`, which deliberately collapses "confirmed not a
|
|
4714
|
+
* descendant" and "the walk's fetch failed" into the same `false` (safe for
|
|
4715
|
+
* its OTHER callers: interaction attribution and the recovery-path
|
|
4716
|
+
* `isAnyDescendantSessionAlive`, both of which just retry next tick with no
|
|
4717
|
+
* safety consequence either way) — this variant keeps those two outcomes
|
|
4718
|
+
* SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
|
|
4719
|
+
* (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
|
|
4720
|
+
* not ongoing".
|
|
4721
|
+
*
|
|
4722
|
+
* Return contract:
|
|
4723
|
+
* - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
|
|
4724
|
+
* - `false` → the walk reached a definitive, parent-less root session
|
|
4725
|
+
* WITHOUT ever matching `rootSessionId` — `sessionId` is
|
|
4726
|
+
* CONFIRMED NOT a descendant of it.
|
|
4727
|
+
* - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
|
|
4728
|
+
* through the walk (`resolveSessionParent` returned `undefined`),
|
|
4729
|
+
* or the depth cap (32) was hit without a definitive answer (a
|
|
4730
|
+
* pathological/cyclic chain proves nothing either way). NEVER
|
|
4731
|
+
* treat this the same as `false` — see `sessionBelongsTo`'s own
|
|
4732
|
+
* doc comment above for why that collapse is safe THERE but not
|
|
4733
|
+
* here.
|
|
4734
|
+
*
|
|
4735
|
+
* `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
|
|
4736
|
+
* to the live-path descendant check, not a modification of shared code used
|
|
4737
|
+
* by interaction attribution or the recovery path.
|
|
4738
|
+
*/
|
|
4739
|
+
async resolveSessionMembership(sessionId, rootSessionId) {
|
|
4740
|
+
let current = sessionId;
|
|
4741
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
4742
|
+
if (current === rootSessionId) return true;
|
|
4743
|
+
const parent = await this.resolveSessionParent(current);
|
|
4744
|
+
if (parent === void 0) return null;
|
|
4745
|
+
if (parent === null) return false;
|
|
4746
|
+
current = parent;
|
|
4747
|
+
}
|
|
4748
|
+
return null;
|
|
4749
|
+
}
|
|
3525
4750
|
/**
|
|
3526
4751
|
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
3527
4752
|
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
@@ -3544,19 +4769,36 @@ var ChannelDriver = class {
|
|
|
3544
4769
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3545
4770
|
return parent;
|
|
3546
4771
|
}
|
|
4772
|
+
/**
|
|
4773
|
+
* OpenCode's synchronous default session title (e.g.
|
|
4774
|
+
* `"New session - 1737800000000"`), assigned immediately when a session is
|
|
4775
|
+
* created — before OpenCode's async LLM-based auto-titling later renames it
|
|
4776
|
+
* mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
|
|
4777
|
+
* timestamp suffix's exact format is deliberately NOT matched, since the prefix
|
|
4778
|
+
* alone is the stable, cheap signal and over-anchoring on the timestamp
|
|
4779
|
+
* representation risks silently breaking if OpenCode ever changes it. Accepted
|
|
4780
|
+
* trade-off: a genuine LLM-assigned title that happens to literally start with
|
|
4781
|
+
* this prefix would also fail to latch (see `resolveSessionTitle`) —
|
|
4782
|
+
* vanishingly unlikely in practice, and deliberately not engineered around.
|
|
4783
|
+
*/
|
|
4784
|
+
static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
|
|
3547
4785
|
/**
|
|
3548
4786
|
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3549
4787
|
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3550
4788
|
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3551
4789
|
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3552
4790
|
* Best-effort:
|
|
3553
|
-
* - a resolved NON-EMPTY title
|
|
4791
|
+
* - a resolved NON-EMPTY title that does NOT match
|
|
4792
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
|
|
3554
4793
|
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3555
|
-
* - while the title is still absent
|
|
3556
|
-
*
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3559
|
-
*
|
|
4794
|
+
* - while the title is still absent, empty, or matches the OpenCode
|
|
4795
|
+
* placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
|
|
4796
|
+
* asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
|
|
4797
|
+
* the cache unresolved and re-fetch on the next need so a later call (e.g. at
|
|
4798
|
+
* `done`) picks up the name assigned in the meantime. Such a call returns
|
|
4799
|
+
* `null` (omit the title on THIS PATCH) without caching. If a session is
|
|
4800
|
+
* never renamed, the title is omitted forever rather than ever persisting
|
|
4801
|
+
* the placeholder as a last resort;
|
|
3560
4802
|
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3561
4803
|
* and returns `null` — it must NEVER throw or block completion.
|
|
3562
4804
|
* A failure is logged with agent/session context (no silent catch).
|
|
@@ -3569,7 +4811,7 @@ var ChannelDriver = class {
|
|
|
3569
4811
|
if (res.ok) {
|
|
3570
4812
|
const body = await res.json();
|
|
3571
4813
|
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3572
|
-
if (title.length > 0) {
|
|
4814
|
+
if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
|
|
3573
4815
|
this.sessionTitles.set(sessionId, title);
|
|
3574
4816
|
return title;
|
|
3575
4817
|
}
|
|
@@ -3589,6 +4831,54 @@ var ChannelDriver = class {
|
|
|
3589
4831
|
}
|
|
3590
4832
|
return null;
|
|
3591
4833
|
}
|
|
4834
|
+
/**
|
|
4835
|
+
* Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
|
|
4836
|
+
* session title onto the conversation via the PLAIN conversation-update
|
|
4837
|
+
* endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
|
|
4838
|
+
* message-status endpoint `markProcessing`/`markDone` use. Deliberately a
|
|
4839
|
+
* separate, lighter call: it carries no `status`, so it cannot re-trigger the
|
|
4840
|
+
* `processing`/`done` transition side effects (Slack notices, activity-log
|
|
4841
|
+
* rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
|
|
4842
|
+
* ever touches `conversations.title`. That route (`routes/conversations.ts`)
|
|
4843
|
+
* skips a title write matching the stored value, so a redundant call with the
|
|
4844
|
+
* same title is a real no-op — it does not bump `updated_at`, which the
|
|
4845
|
+
* conversation list sorts and paginates on. (Note this is a DIFFERENT guard
|
|
4846
|
+
* from `threads.ts`'s "non-empty AND changed" one, which only covers the
|
|
4847
|
+
* message-status PATCH; the non-empty half is enforced here instead, by
|
|
4848
|
+
* `resolveSessionTitle` never returning an empty/placeholder title.)
|
|
4849
|
+
*
|
|
4850
|
+
* Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
|
|
4851
|
+
* is logged and the title is simply retried on the next heartbeat tick (the
|
|
4852
|
+
* caller only latches `titleSynced` on `true`).
|
|
4853
|
+
*/
|
|
4854
|
+
async patchConversationTitle(conversationId, title) {
|
|
4855
|
+
try {
|
|
4856
|
+
const res = await this.fetchImpl(
|
|
4857
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
|
|
4858
|
+
{
|
|
4859
|
+
method: "PATCH",
|
|
4860
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
4861
|
+
body: JSON.stringify({ title })
|
|
4862
|
+
}
|
|
4863
|
+
);
|
|
4864
|
+
if (!res.ok) {
|
|
4865
|
+
this.log({
|
|
4866
|
+
level: "debug",
|
|
4867
|
+
message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
|
|
4868
|
+
conversation_id: conversationId
|
|
4869
|
+
});
|
|
4870
|
+
return false;
|
|
4871
|
+
}
|
|
4872
|
+
return true;
|
|
4873
|
+
} catch (err) {
|
|
4874
|
+
this.log({
|
|
4875
|
+
level: "debug",
|
|
4876
|
+
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)}`,
|
|
4877
|
+
conversation_id: conversationId
|
|
4878
|
+
});
|
|
4879
|
+
return false;
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
3592
4882
|
/**
|
|
3593
4883
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3594
4884
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3647,6 +4937,84 @@ var ChannelDriver = class {
|
|
|
3647
4937
|
}
|
|
3648
4938
|
return false;
|
|
3649
4939
|
}
|
|
4940
|
+
/**
|
|
4941
|
+
* LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
|
|
4942
|
+
* sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
|
|
4943
|
+
* in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
|
|
4944
|
+
*
|
|
4945
|
+
* Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
|
|
4946
|
+
* cross-check above): that method judges liveness from the child's OWN
|
|
4947
|
+
* TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
|
|
4948
|
+
* on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
|
|
4949
|
+
* path the local opencode server IS running, so its in-memory status map is
|
|
4950
|
+
* live and authoritative — and per ADR-0047 §4a ("the child has its own entry
|
|
4951
|
+
* [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
|
|
4952
|
+
* ENTIRE turn (including any tool call it is itself executing), not a
|
|
4953
|
+
* per-message transcript snapshot. This sidesteps the "child's own tool is
|
|
4954
|
+
* executing, between its step's completion and the next generation step"
|
|
4955
|
+
* transcript gap that a transcript-based check would need a second,
|
|
4956
|
+
* sustained-window bound to guard against — it is simply not derived from
|
|
4957
|
+
* message timestamps at all.
|
|
4958
|
+
*
|
|
4959
|
+
* Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
|
|
4960
|
+
* status, as the recovery path does per §4a)? Because on the LIVE path the
|
|
4961
|
+
* root session can be shared: a SECOND, unrelated user message can land on the
|
|
4962
|
+
* SAME session (issue #721's own root cause) and keep the root `busy` for a
|
|
4963
|
+
* reason that has nothing to do with THIS message's delegation. A `task`
|
|
4964
|
+
* descendant session is spawned for exactly one delegated turn and never
|
|
4965
|
+
* reused, so its OWN status-map entry is unambiguous evidence about that one
|
|
4966
|
+
* delegation — which the root's status is not.
|
|
4967
|
+
*
|
|
4968
|
+
* Why membership is checked via `resolveSessionMembership`, NOT
|
|
4969
|
+
* `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
|
|
4970
|
+
* `GET /session/:id` fetch failure into "not a descendant", which would
|
|
4971
|
+
* silently drop a genuinely-live candidate from consideration on the one
|
|
4972
|
+
* unlucky tick its membership-walk fetch hiccups (#721).
|
|
4973
|
+
* `resolveSessionMembership` keeps that failure mode as a distinct `null`
|
|
4974
|
+
* (indeterminate) so it is folded into THIS method's own `indeterminate` flag
|
|
4975
|
+
* instead.
|
|
4976
|
+
*
|
|
4977
|
+
* Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
|
|
4978
|
+
* - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
|
|
4979
|
+
* - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
|
|
4980
|
+
* confirmed either way (`resolveSessionMembership` never
|
|
4981
|
+
* returned `null`), and every CONFIRMED descendant's status read
|
|
4982
|
+
* succeeded and is not ongoing (includes "no descendant session
|
|
4983
|
+
* exists at all" — e.g. a plain, non-`task` tool call).
|
|
4984
|
+
* - `null` → INDETERMINATE: `listSessions` failed, OR at least one
|
|
4985
|
+
* candidate's MEMBERSHIP could not be confirmed
|
|
4986
|
+
* (`resolveSessionMembership` returned `null` — a fetch failure
|
|
4987
|
+
* or pathological chain partway through the parent walk), OR at
|
|
4988
|
+
* least one CONFIRMED descendant's `isSessionOngoing` read
|
|
4989
|
+
* failed — and no OTHER candidate was already confirmed `true`.
|
|
4990
|
+
* The caller MUST NOT treat `null` the same as `false` here
|
|
4991
|
+
* (unlike the recovery cross-check's contract) — see
|
|
4992
|
+
* `isB2AbandonmentConfirmed`.
|
|
4993
|
+
*/
|
|
4994
|
+
async isAnyDescendantSessionOngoing(rootSessionId) {
|
|
4995
|
+
const sessions = await listSessions(this.port);
|
|
4996
|
+
if (!sessions) {
|
|
4997
|
+
this.log({
|
|
4998
|
+
level: "warn",
|
|
4999
|
+
message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
|
|
5000
|
+
});
|
|
5001
|
+
return null;
|
|
5002
|
+
}
|
|
5003
|
+
let indeterminate = false;
|
|
5004
|
+
for (const candidate of sessions) {
|
|
5005
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
5006
|
+
const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
|
|
5007
|
+
if (membership === null) {
|
|
5008
|
+
indeterminate = true;
|
|
5009
|
+
continue;
|
|
5010
|
+
}
|
|
5011
|
+
if (membership === false) continue;
|
|
5012
|
+
const ongoing = await isSessionOngoing(this.port, candidate.id);
|
|
5013
|
+
if (ongoing === true) return true;
|
|
5014
|
+
if (ongoing === null) indeterminate = true;
|
|
5015
|
+
}
|
|
5016
|
+
return indeterminate ? null : false;
|
|
5017
|
+
}
|
|
3650
5018
|
/**
|
|
3651
5019
|
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
3652
5020
|
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
@@ -3719,7 +5087,7 @@ var ChannelDriver = class {
|
|
|
3719
5087
|
// Evident API calls (combinedAuth thread routes)
|
|
3720
5088
|
async getPendingConversations() {
|
|
3721
5089
|
const res = await this.fetchImpl(
|
|
3722
|
-
`${this.apiUrl}/
|
|
5090
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
|
|
3723
5091
|
{
|
|
3724
5092
|
headers: { Authorization: this.getAuthHeader() }
|
|
3725
5093
|
}
|
|
@@ -3737,7 +5105,7 @@ var ChannelDriver = class {
|
|
|
3737
5105
|
}
|
|
3738
5106
|
async getPendingMessages(conversationId) {
|
|
3739
5107
|
const res = await this.fetchImpl(
|
|
3740
|
-
`${this.apiUrl}/
|
|
5108
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
3741
5109
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3742
5110
|
);
|
|
3743
5111
|
this.assertAuth(res, "fetching pending messages");
|
|
@@ -3761,7 +5129,7 @@ var ChannelDriver = class {
|
|
|
3761
5129
|
*/
|
|
3762
5130
|
async getProcessingMessages() {
|
|
3763
5131
|
const res = await this.fetchImpl(
|
|
3764
|
-
`${this.apiUrl}/
|
|
5132
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
|
|
3765
5133
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3766
5134
|
);
|
|
3767
5135
|
this.assertAuth(res, "fetching processing messages");
|
|
@@ -3775,6 +5143,32 @@ var ChannelDriver = class {
|
|
|
3775
5143
|
}
|
|
3776
5144
|
return messages;
|
|
3777
5145
|
}
|
|
5146
|
+
/**
|
|
5147
|
+
* The `opencode_session_id` fragment of a status PATCH body — `{}` when this
|
|
5148
|
+
* conversation has ABANDONED that session (#553). The field is optional
|
|
5149
|
+
* server-side and an absent one leaves the persisted binding untouched, so
|
|
5150
|
+
* omitting it is how a routine status write stops resurrecting it.
|
|
5151
|
+
*
|
|
5152
|
+
* ONLY for writes whose sole cost is a lost deep link. The `processing` notice
|
|
5153
|
+
* degrades to no "View in Evident" link (the reaction swap still fires) and the
|
|
5154
|
+
* turn-failure notice is built from the PATCH's own `error` text with a link off
|
|
5155
|
+
* the persisted row — neither loses content the user came for. `markDone`
|
|
5156
|
+
* deliberately does NOT use this helper: the server fetches the reply text
|
|
5157
|
+
* THROUGH the session id it is given, so suppressing there would replace the
|
|
5158
|
+
* agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
|
|
5159
|
+
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
5160
|
+
* stick.
|
|
5161
|
+
*/
|
|
5162
|
+
sessionIdBody(sessionId, conversationId, messageId, status) {
|
|
5163
|
+
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
5164
|
+
this.log({
|
|
5165
|
+
level: "debug",
|
|
5166
|
+
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
|
|
5167
|
+
conversation_id: conversationId,
|
|
5168
|
+
message_id: messageId
|
|
5169
|
+
});
|
|
5170
|
+
return {};
|
|
5171
|
+
}
|
|
3778
5172
|
/**
|
|
3779
5173
|
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
3780
5174
|
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
@@ -3798,13 +5192,13 @@ var ChannelDriver = class {
|
|
|
3798
5192
|
*/
|
|
3799
5193
|
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3800
5194
|
const res = await this.fetchImpl(
|
|
3801
|
-
`${this.apiUrl}/
|
|
5195
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3802
5196
|
{
|
|
3803
5197
|
method: "PATCH",
|
|
3804
5198
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3805
5199
|
body: JSON.stringify({
|
|
3806
5200
|
status: "processing",
|
|
3807
|
-
|
|
5201
|
+
...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
|
|
3808
5202
|
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
5203
|
...title ? { title } : {}
|
|
3810
5204
|
})
|
|
@@ -3845,17 +5239,23 @@ var ChannelDriver = class {
|
|
|
3845
5239
|
* watcher retries next tick within the
|
|
3846
5240
|
* deadline, Finding 4).
|
|
3847
5241
|
*/
|
|
3848
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
5242
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3849
5243
|
const res = await this.fetchImpl(
|
|
3850
|
-
`${this.apiUrl}/
|
|
5244
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3851
5245
|
{
|
|
3852
5246
|
method: "PATCH",
|
|
3853
5247
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3854
5248
|
body: JSON.stringify({
|
|
3855
5249
|
status: "done",
|
|
5250
|
+
// ALWAYS sent, even for a session this conversation has abandoned
|
|
5251
|
+
// (#553): the server reads the reply text back out of THIS session id
|
|
5252
|
+
// to deliver it. Omitting it would leave the user with "✅ Done!"
|
|
5253
|
+
// instead of the answer — a worse regression than the resurrection it
|
|
5254
|
+
// would prevent, which `ensureSession`'s guard handles anyway.
|
|
3856
5255
|
opencode_session_id: sessionId,
|
|
3857
5256
|
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
-
...title ? { title } : {}
|
|
5257
|
+
...title ? { title } : {},
|
|
5258
|
+
...usage ? usage : {}
|
|
3859
5259
|
})
|
|
3860
5260
|
}
|
|
3861
5261
|
);
|
|
@@ -3868,19 +5268,35 @@ var ChannelDriver = class {
|
|
|
3868
5268
|
}
|
|
3869
5269
|
/**
|
|
3870
5270
|
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3871
|
-
* when provided (issue #182)
|
|
3872
|
-
* `
|
|
3873
|
-
*
|
|
3874
|
-
*
|
|
5271
|
+
* when provided (issue #182). Three states for `sessionId`:
|
|
5272
|
+
* - omitted (`undefined`) → don't send the field, leave the persisted
|
|
5273
|
+
* session untouched (unused today; kept for API symmetry).
|
|
5274
|
+
* - a real id (`string`) → send it, update the persisted session (the
|
|
5275
|
+
* turn-failure call sites: an errored OpenCode turn).
|
|
5276
|
+
* - explicit `null` → send it, CLEAR the persisted session (issue
|
|
5277
|
+
* #485's dispatch-handoff-failure call site: the session id still
|
|
5278
|
+
* exists but is wedged, so the next attempt must get a fresh one
|
|
5279
|
+
* instead of reusing it — see WI-1's server-side null-clearing PATCH).
|
|
3875
5280
|
*/
|
|
3876
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
5281
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage, failure) {
|
|
3877
5282
|
const body = { status: "failed" };
|
|
3878
|
-
if (sessionId
|
|
5283
|
+
if (sessionId === null) {
|
|
5284
|
+
body.opencode_session_id = null;
|
|
5285
|
+
} else if (sessionId !== void 0) {
|
|
5286
|
+
Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
|
|
5287
|
+
}
|
|
3879
5288
|
if (error2 !== void 0) body.error = error2;
|
|
5289
|
+
if (usage) Object.assign(body, usage);
|
|
5290
|
+
if (failure) {
|
|
5291
|
+
body.failure_kind = failure.kind;
|
|
5292
|
+
body.failure_provider_id = failure.providerId;
|
|
5293
|
+
body.failure_model_id = failure.modelId;
|
|
5294
|
+
body.failure_reason = failure.reason;
|
|
5295
|
+
}
|
|
3880
5296
|
await this.callWithRetry(
|
|
3881
5297
|
"marking message as failed",
|
|
3882
5298
|
() => this.fetchImpl(
|
|
3883
|
-
`${this.apiUrl}/
|
|
5299
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3884
5300
|
{
|
|
3885
5301
|
method: "PATCH",
|
|
3886
5302
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3889,6 +5305,29 @@ var ChannelDriver = class {
|
|
|
3889
5305
|
)
|
|
3890
5306
|
);
|
|
3891
5307
|
}
|
|
5308
|
+
/**
|
|
5309
|
+
* Classify an errored turn as a model-auth failure (#736 P1-4), for `markFailed`.
|
|
5310
|
+
*
|
|
5311
|
+
* `messageFailure` alone (structured OpenCode error → `model_auth`) covers
|
|
5312
|
+
* most cases; when it returns `null` on this ALREADY-FAILED turn, fall back
|
|
5313
|
+
* to the P1-2b zero-provider check — one extra loopback call to
|
|
5314
|
+
* `hasAnyConfiguredProvider`, only reached when the structured classifier
|
|
5315
|
+
* couldn't place it. Fails open (never throws): a fallback probe failure
|
|
5316
|
+
* (`null`/indeterminate) leaves the classification `null`, which produces
|
|
5317
|
+
* today's byte-identical PATCH body via `markFailed`'s `if (failure)` guard.
|
|
5318
|
+
*/
|
|
5319
|
+
async classifyModelAuthFailure(messages, userMessageId) {
|
|
5320
|
+
const classified = messageFailure(messages, userMessageId);
|
|
5321
|
+
if (classified != null) return classified;
|
|
5322
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
5323
|
+
const hasProvider = await hasAnyConfiguredProvider(this.port);
|
|
5324
|
+
return applyZeroProviderFallback(
|
|
5325
|
+
classified,
|
|
5326
|
+
hasProvider,
|
|
5327
|
+
reply?.info?.providerID ?? null,
|
|
5328
|
+
reply?.info?.modelID ?? null
|
|
5329
|
+
);
|
|
5330
|
+
}
|
|
3892
5331
|
/**
|
|
3893
5332
|
* Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
|
|
3894
5333
|
* (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
|
|
@@ -3907,7 +5346,7 @@ var ChannelDriver = class {
|
|
|
3907
5346
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3908
5347
|
try {
|
|
3909
5348
|
const res = await this.fetchImpl(
|
|
3910
|
-
`${this.apiUrl}/
|
|
5349
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
3911
5350
|
{
|
|
3912
5351
|
method: "POST",
|
|
3913
5352
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3936,7 +5375,7 @@ var ChannelDriver = class {
|
|
|
3936
5375
|
}
|
|
3937
5376
|
async persistSession(conversationId, sessionId) {
|
|
3938
5377
|
const res = await this.fetchImpl(
|
|
3939
|
-
`${this.apiUrl}/
|
|
5378
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
|
|
3940
5379
|
{
|
|
3941
5380
|
method: "PATCH",
|
|
3942
5381
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3962,7 +5401,7 @@ var ChannelDriver = class {
|
|
|
3962
5401
|
await this.callWithRetry(
|
|
3963
5402
|
"reporting interactive event",
|
|
3964
5403
|
() => this.fetchImpl(
|
|
3965
|
-
`${this.apiUrl}/
|
|
5404
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
3966
5405
|
{
|
|
3967
5406
|
method: "POST",
|
|
3968
5407
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -4041,10 +5480,16 @@ var ChannelDriver = class {
|
|
|
4041
5480
|
import chalk5 from "chalk";
|
|
4042
5481
|
import ora2 from "ora";
|
|
4043
5482
|
import { select as select2 } from "@inquirer/prompts";
|
|
5483
|
+
var INTERACTIVE_START_TIMEOUT_MS = 3e4;
|
|
4044
5484
|
async function ensureOpenCodeRunning(ctx) {
|
|
4045
5485
|
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
4046
5486
|
if (healthCheck.healthy) {
|
|
4047
|
-
return {
|
|
5487
|
+
return {
|
|
5488
|
+
port: ctx.port,
|
|
5489
|
+
process: null,
|
|
5490
|
+
version: healthCheck.version ?? null,
|
|
5491
|
+
notReadyReason: null
|
|
5492
|
+
};
|
|
4048
5493
|
}
|
|
4049
5494
|
const runningInstances = await findHealthyOpenCodeInstances();
|
|
4050
5495
|
if (runningInstances.length > 0) {
|
|
@@ -4065,7 +5510,7 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
4065
5510
|
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
4066
5511
|
console.log(
|
|
4067
5512
|
chalk5.dim(
|
|
4068
|
-
` ${getCliName()} run --
|
|
5513
|
+
` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
4069
5514
|
)
|
|
4070
5515
|
);
|
|
4071
5516
|
}
|
|
@@ -4085,14 +5530,22 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
4085
5530
|
if (!ctx.interactive) {
|
|
4086
5531
|
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
4087
5532
|
const proc = await startOpenCode(ctx.port);
|
|
4088
|
-
const health = await waitForOpenCodeHealth(ctx.port,
|
|
5533
|
+
const health = await waitForOpenCodeHealth(ctx.port, ctx.startTimeoutMs);
|
|
4089
5534
|
if (!health.healthy) {
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
5535
|
+
return {
|
|
5536
|
+
port: ctx.port,
|
|
5537
|
+
process: proc,
|
|
5538
|
+
version: null,
|
|
5539
|
+
notReadyReason: `it did not become healthy within ${Math.round(ctx.startTimeoutMs / 1e3)}s`
|
|
5540
|
+
};
|
|
4093
5541
|
}
|
|
4094
5542
|
ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
|
|
4095
|
-
return {
|
|
5543
|
+
return {
|
|
5544
|
+
port: ctx.port,
|
|
5545
|
+
process: proc,
|
|
5546
|
+
version: health.version ?? null,
|
|
5547
|
+
notReadyReason: null
|
|
5548
|
+
};
|
|
4096
5549
|
}
|
|
4097
5550
|
let port = ctx.port;
|
|
4098
5551
|
if (isPortInUse(port)) {
|
|
@@ -4145,15 +5598,15 @@ Port ${port} is already in use.`));
|
|
|
4145
5598
|
if (action === "start") {
|
|
4146
5599
|
const spinner = ora2("Starting OpenCode...").start();
|
|
4147
5600
|
const proc = await startOpenCode(port);
|
|
4148
|
-
const health = await waitForOpenCodeHealth(port,
|
|
5601
|
+
const health = await waitForOpenCodeHealth(port, INTERACTIVE_START_TIMEOUT_MS);
|
|
4149
5602
|
if (!health.healthy) {
|
|
4150
5603
|
spinner.fail("Failed to start OpenCode");
|
|
4151
5604
|
throw new Error("OpenCode failed to start");
|
|
4152
5605
|
}
|
|
4153
5606
|
spinner.stop();
|
|
4154
|
-
return { port, process: proc, version: health.version ?? null };
|
|
5607
|
+
return { port, process: proc, version: health.version ?? null, notReadyReason: null };
|
|
4155
5608
|
}
|
|
4156
|
-
return { port, process: null, version: null };
|
|
5609
|
+
return { port, process: null, version: null, notReadyReason: "you chose to continue without it" };
|
|
4157
5610
|
}
|
|
4158
5611
|
|
|
4159
5612
|
// src/commands/agent-lookup.ts
|
|
@@ -4195,19 +5648,21 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
4195
5648
|
return { agent_id: data.agent_id };
|
|
4196
5649
|
}
|
|
4197
5650
|
return {
|
|
4198
|
-
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --
|
|
5651
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
4199
5652
|
};
|
|
4200
5653
|
} catch (error2) {
|
|
4201
5654
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
4202
5655
|
return { error: `Failed to resolve runner from key: ${message}` };
|
|
4203
5656
|
}
|
|
4204
5657
|
}
|
|
5658
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
4205
5659
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
4206
5660
|
const apiUrl = getApiUrlConfig();
|
|
4207
5661
|
try {
|
|
4208
|
-
const response = await fetch(`${apiUrl}/
|
|
5662
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
4209
5663
|
method: "POST",
|
|
4210
|
-
headers: { Authorization: authHeader }
|
|
5664
|
+
headers: { Authorization: authHeader },
|
|
5665
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
4211
5666
|
});
|
|
4212
5667
|
if (!response.ok) {
|
|
4213
5668
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -4218,13 +5673,69 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
4218
5673
|
}
|
|
4219
5674
|
return { ok: true };
|
|
4220
5675
|
} catch (error2) {
|
|
4221
|
-
return { ok: false, error:
|
|
5676
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
function describeBestEffortError(error2) {
|
|
5680
|
+
const name = error2?.name;
|
|
5681
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
5682
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5683
|
+
}
|
|
5684
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
5685
|
+
}
|
|
5686
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5687
|
+
try {
|
|
5688
|
+
const apiUrl = getApiUrlConfig();
|
|
5689
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5690
|
+
method: "POST",
|
|
5691
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5692
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5693
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5694
|
+
});
|
|
5695
|
+
if (!response.ok) {
|
|
5696
|
+
const serverMessage = await readErrorMessage(response);
|
|
5697
|
+
return {
|
|
5698
|
+
ok: false,
|
|
5699
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5700
|
+
};
|
|
5701
|
+
}
|
|
5702
|
+
return { ok: true };
|
|
5703
|
+
} catch (error2) {
|
|
5704
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
5705
|
+
}
|
|
5706
|
+
}
|
|
5707
|
+
function toReportedWindow(window) {
|
|
5708
|
+
if (!window) return null;
|
|
5709
|
+
return { utilization: window.utilization, resets_at: window.resetsAt };
|
|
5710
|
+
}
|
|
5711
|
+
async function reportClaudeUsage(agentId, authHeader, snapshot) {
|
|
5712
|
+
try {
|
|
5713
|
+
const apiUrl = getApiUrlConfig();
|
|
5714
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/claude-usage`, {
|
|
5715
|
+
method: "POST",
|
|
5716
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5717
|
+
body: JSON.stringify({
|
|
5718
|
+
five_hour: toReportedWindow(snapshot.fiveHour),
|
|
5719
|
+
seven_day: toReportedWindow(snapshot.sevenDay)
|
|
5720
|
+
}),
|
|
5721
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5722
|
+
});
|
|
5723
|
+
if (!response.ok) {
|
|
5724
|
+
const serverMessage = await readErrorMessage(response);
|
|
5725
|
+
return {
|
|
5726
|
+
ok: false,
|
|
5727
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5728
|
+
};
|
|
5729
|
+
}
|
|
5730
|
+
return { ok: true };
|
|
5731
|
+
} catch (error2) {
|
|
5732
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
4222
5733
|
}
|
|
4223
5734
|
}
|
|
4224
5735
|
async function getAgentInfo(agentId, authHeader) {
|
|
4225
5736
|
const apiUrl = getApiUrlConfig();
|
|
4226
5737
|
try {
|
|
4227
|
-
const response = await fetch(`${apiUrl}/
|
|
5738
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
4228
5739
|
headers: { Authorization: authHeader }
|
|
4229
5740
|
});
|
|
4230
5741
|
if (response.status === 401) {
|
|
@@ -4268,6 +5779,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4268
5779
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4269
5780
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4270
5781
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
5782
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
4271
5783
|
function resolveLogLevel(options) {
|
|
4272
5784
|
const accepted = Object.keys(LOG_LEVELS);
|
|
4273
5785
|
const validate = (value, source) => {
|
|
@@ -4291,6 +5803,63 @@ function resolveLogLevel(options) {
|
|
|
4291
5803
|
}
|
|
4292
5804
|
return "info";
|
|
4293
5805
|
}
|
|
5806
|
+
function resolveFileSyncDirectories(raw, homeDir) {
|
|
5807
|
+
const directories = [];
|
|
5808
|
+
for (const entry of raw ?? []) {
|
|
5809
|
+
const trimmed = entry.trim();
|
|
5810
|
+
if (trimmed === "") {
|
|
5811
|
+
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5812
|
+
}
|
|
5813
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
|
|
5814
|
+
if (!isAbsolute2(expanded)) {
|
|
5815
|
+
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5816
|
+
}
|
|
5817
|
+
const normalized = resolvePath(expanded);
|
|
5818
|
+
if (parse(normalized).root === normalized) {
|
|
5819
|
+
throw new Error(
|
|
5820
|
+
`--enable-file-sync-to will not allow-list the filesystem root ("${entry}"); name the specific directory the credentials belong in (for example ~/.claude)`
|
|
5821
|
+
);
|
|
5822
|
+
}
|
|
5823
|
+
if (!directories.includes(normalized)) {
|
|
5824
|
+
directories.push(normalized);
|
|
5825
|
+
}
|
|
5826
|
+
}
|
|
5827
|
+
if (directories.length > MAX_FILE_SYNC_DIRECTORIES) {
|
|
5828
|
+
throw new Error(
|
|
5829
|
+
`--enable-file-sync-to accepts at most ${MAX_FILE_SYNC_DIRECTORIES} directories; got ${directories.length}`
|
|
5830
|
+
);
|
|
5831
|
+
}
|
|
5832
|
+
return directories;
|
|
5833
|
+
}
|
|
5834
|
+
var DEFAULT_OPENCODE_START_TIMEOUT_SECONDS = 180;
|
|
5835
|
+
var MAX_OPENCODE_START_TIMEOUT_SECONDS = 3600;
|
|
5836
|
+
var OPENCODE_START_TIMEOUT_ENV = "EVIDENT_OPENCODE_START_TIMEOUT";
|
|
5837
|
+
function resolveOpenCodeStartTimeoutMs(options, env = process.env) {
|
|
5838
|
+
const defaultMs = DEFAULT_OPENCODE_START_TIMEOUT_SECONDS * 1e3;
|
|
5839
|
+
let raw;
|
|
5840
|
+
let source;
|
|
5841
|
+
if (options.opencodeStartTimeout !== void 0) {
|
|
5842
|
+
raw = options.opencodeStartTimeout;
|
|
5843
|
+
source = "--opencode-start-timeout";
|
|
5844
|
+
} else if (env[OPENCODE_START_TIMEOUT_ENV] !== void 0 && env[OPENCODE_START_TIMEOUT_ENV] !== "") {
|
|
5845
|
+
raw = env[OPENCODE_START_TIMEOUT_ENV];
|
|
5846
|
+
source = OPENCODE_START_TIMEOUT_ENV;
|
|
5847
|
+
} else {
|
|
5848
|
+
return { timeoutMs: defaultMs, warnings: [] };
|
|
5849
|
+
}
|
|
5850
|
+
const trimmed = raw.trim();
|
|
5851
|
+
const seconds = Number(trimmed);
|
|
5852
|
+
const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(seconds) && seconds > 0;
|
|
5853
|
+
if (!isPositiveInteger || seconds > MAX_OPENCODE_START_TIMEOUT_SECONDS) {
|
|
5854
|
+
return {
|
|
5855
|
+
timeoutMs: defaultMs,
|
|
5856
|
+
warnings: [
|
|
5857
|
+
`Ignoring invalid ${source} "${raw}": expected a positive integer number of seconds (at most ${MAX_OPENCODE_START_TIMEOUT_SECONDS}); using the default ${DEFAULT_OPENCODE_START_TIMEOUT_SECONDS}s`
|
|
5858
|
+
]
|
|
5859
|
+
};
|
|
5860
|
+
}
|
|
5861
|
+
return { timeoutMs: seconds * 1e3, warnings: [] };
|
|
5862
|
+
}
|
|
4294
5863
|
function meetsThreshold(state, level) {
|
|
4295
5864
|
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4296
5865
|
}
|
|
@@ -4312,6 +5881,10 @@ function log2(state, message, level = "info") {
|
|
|
4312
5881
|
function logActivity(state, entry) {
|
|
4313
5882
|
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
5883
|
if (!meetsThreshold(state, level)) return;
|
|
5884
|
+
forwardRunnerActivity(
|
|
5885
|
+
{ level, message: entry.message, error: entry.error },
|
|
5886
|
+
{ agentId: state.agentId, authHeader: state.authHeader }
|
|
5887
|
+
);
|
|
4315
5888
|
const fullEntry = {
|
|
4316
5889
|
...entry,
|
|
4317
5890
|
level,
|
|
@@ -4412,18 +5985,29 @@ async function handleAuthError(state, error2) {
|
|
|
4412
5985
|
async function driveChannels(state, driver) {
|
|
4413
5986
|
let idlePolls = 0;
|
|
4414
5987
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5988
|
+
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
4415
5989
|
while (state.running) {
|
|
4416
5990
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
4417
5991
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
4418
5992
|
if (state.interactive) displayStatus(state);
|
|
4419
5993
|
await state.connection.reconnectPromise;
|
|
4420
5994
|
}
|
|
5995
|
+
const carriedOverFileSync = driver.fileSyncActivity().inFlight;
|
|
5996
|
+
void driver.syncPendingFiles().catch(
|
|
5997
|
+
(error2) => logActivity(state, {
|
|
5998
|
+
type: "error",
|
|
5999
|
+
error: `Runner file sync failed: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
6000
|
+
})
|
|
6001
|
+
);
|
|
4421
6002
|
try {
|
|
4422
6003
|
const processed = await driver.drainPending();
|
|
4423
6004
|
state.messageCount += processed;
|
|
4424
6005
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
4425
6006
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
4426
|
-
|
|
6007
|
+
const appliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
6008
|
+
const fileActivity = carriedOverFileSync || appliedFiles !== lastSeenAppliedFiles;
|
|
6009
|
+
lastSeenAppliedFiles = appliedFiles;
|
|
6010
|
+
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
|
|
4427
6011
|
idlePolls = 0;
|
|
4428
6012
|
if (processed > 0 && state.interactive) displayStatus(state);
|
|
4429
6013
|
} else if (state.idleTimeout !== null) {
|
|
@@ -4452,7 +6036,7 @@ async function driveChannels(state, driver) {
|
|
|
4452
6036
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
4453
6037
|
if (state.interactive) displayStatus(state);
|
|
4454
6038
|
}
|
|
4455
|
-
await new Promise((
|
|
6039
|
+
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
4456
6040
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
4457
6041
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
4458
6042
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -4464,8 +6048,8 @@ async function driveChannels(state, driver) {
|
|
|
4464
6048
|
}
|
|
4465
6049
|
}
|
|
4466
6050
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
4467
|
-
async function runSweep(state, driver,
|
|
4468
|
-
const mode = `age=${
|
|
6051
|
+
async function runSweep(state, driver, config) {
|
|
6052
|
+
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
4469
6053
|
try {
|
|
4470
6054
|
const sessions = await listSessions(state.port);
|
|
4471
6055
|
if (sessions === null) {
|
|
@@ -4478,8 +6062,8 @@ async function runSweep(state, driver, config2) {
|
|
|
4478
6062
|
const toDelete = selectSessionsToDelete(
|
|
4479
6063
|
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
4480
6064
|
{
|
|
4481
|
-
maxAgeMs:
|
|
4482
|
-
maxCount:
|
|
6065
|
+
maxAgeMs: config.maxAgeMs,
|
|
6066
|
+
maxCount: config.maxCount,
|
|
4483
6067
|
nowMs: Date.now(),
|
|
4484
6068
|
protectedIds: driver.protectedSessionIds()
|
|
4485
6069
|
}
|
|
@@ -4515,7 +6099,7 @@ async function runSweep(state, driver, config2) {
|
|
|
4515
6099
|
}
|
|
4516
6100
|
}
|
|
4517
6101
|
function scheduleSessionCleanup(state, driver, options) {
|
|
4518
|
-
const
|
|
6102
|
+
const config = resolveSessionCleanupConfig(
|
|
4519
6103
|
{
|
|
4520
6104
|
maxAge: options.sessionCleanupMaxAge,
|
|
4521
6105
|
maxCount: options.sessionCleanupMaxCount,
|
|
@@ -4523,21 +6107,109 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4523
6107
|
},
|
|
4524
6108
|
process.env
|
|
4525
6109
|
);
|
|
4526
|
-
for (const warning2 of
|
|
6110
|
+
for (const warning2 of config.warnings) {
|
|
4527
6111
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4528
6112
|
}
|
|
4529
|
-
if (!
|
|
6113
|
+
if (!config.enabled) return;
|
|
4530
6114
|
logActivity(state, {
|
|
4531
6115
|
type: "info",
|
|
4532
|
-
message: `Session cleanup enabled (age=${
|
|
6116
|
+
message: `Session cleanup enabled (age=${config.maxAgeMs ?? "\u2014"}, count=${config.maxCount ?? "\u2014"}, interval=${config.intervalMs}ms)`
|
|
4533
6117
|
});
|
|
4534
|
-
const interval = setInterval(() => void runSweep(state, driver,
|
|
6118
|
+
const interval = setInterval(() => void runSweep(state, driver, config), config.intervalMs);
|
|
4535
6119
|
const firstSweep = setTimeout(
|
|
4536
|
-
() => void runSweep(state, driver,
|
|
6120
|
+
() => void runSweep(state, driver, config),
|
|
4537
6121
|
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
4538
6122
|
);
|
|
4539
6123
|
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
4540
6124
|
}
|
|
6125
|
+
function scheduleClaudeUsageReporting(state, options) {
|
|
6126
|
+
const { mode, warnings } = resolveClaudeUsageReportingMode(
|
|
6127
|
+
options.claudeUsageReporting,
|
|
6128
|
+
process.env
|
|
6129
|
+
);
|
|
6130
|
+
for (const warning2 of warnings) {
|
|
6131
|
+
logActivity(state, {
|
|
6132
|
+
type: "info",
|
|
6133
|
+
level: "warn",
|
|
6134
|
+
message: `Claude usage reporting: ${warning2}`
|
|
6135
|
+
});
|
|
6136
|
+
}
|
|
6137
|
+
if (mode === "off") {
|
|
6138
|
+
logActivity(state, {
|
|
6139
|
+
type: "info",
|
|
6140
|
+
level: "debug",
|
|
6141
|
+
message: "Claude usage reporting is off (--claude-usage-reporting off)"
|
|
6142
|
+
});
|
|
6143
|
+
return;
|
|
6144
|
+
}
|
|
6145
|
+
let consecutiveFailures = 0;
|
|
6146
|
+
const scheduleNextTick = () => {
|
|
6147
|
+
state.claudeUsageTimer = setTimeout(() => void tick(false), nextReportDelayMs());
|
|
6148
|
+
};
|
|
6149
|
+
const tick = async (isFirst) => {
|
|
6150
|
+
try {
|
|
6151
|
+
const usage = await getClaudeUsage();
|
|
6152
|
+
const result = await reportClaudeUsage(state.agentId, state.authHeader, usage);
|
|
6153
|
+
if (result.ok) {
|
|
6154
|
+
if (consecutiveFailures > 0) {
|
|
6155
|
+
logActivity(state, {
|
|
6156
|
+
type: "info",
|
|
6157
|
+
level: "info",
|
|
6158
|
+
message: "Claude usage reporting recovered"
|
|
6159
|
+
});
|
|
6160
|
+
}
|
|
6161
|
+
consecutiveFailures = 0;
|
|
6162
|
+
logActivity(state, {
|
|
6163
|
+
type: "info",
|
|
6164
|
+
level: "debug",
|
|
6165
|
+
message: "Reported Claude usage to Evident"
|
|
6166
|
+
});
|
|
6167
|
+
} else {
|
|
6168
|
+
consecutiveFailures++;
|
|
6169
|
+
logActivity(state, {
|
|
6170
|
+
type: "info",
|
|
6171
|
+
level: consecutiveFailures === 1 ? "warn" : "debug",
|
|
6172
|
+
message: `Failed to report Claude usage: ${result.error}`
|
|
6173
|
+
});
|
|
6174
|
+
}
|
|
6175
|
+
scheduleNextTick();
|
|
6176
|
+
} catch (error2) {
|
|
6177
|
+
if (error2 instanceof ClaudeUsageError && isLocalCredentialProblem(error2)) {
|
|
6178
|
+
if (mode === "on") {
|
|
6179
|
+
logActivity(state, {
|
|
6180
|
+
type: "info",
|
|
6181
|
+
level: "warn",
|
|
6182
|
+
message: "Claude usage reporting is forced on but no usable Claude Code login was found \u2014 run `claude` to sign in; reporting will keep retrying"
|
|
6183
|
+
});
|
|
6184
|
+
scheduleNextTick();
|
|
6185
|
+
} else if (isFirst) {
|
|
6186
|
+
logActivity(state, {
|
|
6187
|
+
type: "info",
|
|
6188
|
+
level: "debug",
|
|
6189
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6190
|
+
});
|
|
6191
|
+
} else {
|
|
6192
|
+
logActivity(state, {
|
|
6193
|
+
type: "info",
|
|
6194
|
+
level: "debug",
|
|
6195
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6196
|
+
});
|
|
6197
|
+
scheduleNextTick();
|
|
6198
|
+
}
|
|
6199
|
+
} else {
|
|
6200
|
+
consecutiveFailures++;
|
|
6201
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
6202
|
+
logActivity(state, {
|
|
6203
|
+
type: "info",
|
|
6204
|
+
level: consecutiveFailures === 1 ? "warn" : "debug",
|
|
6205
|
+
message: `Claude usage reporting failed: ${message}`
|
|
6206
|
+
});
|
|
6207
|
+
scheduleNextTick();
|
|
6208
|
+
}
|
|
6209
|
+
}
|
|
6210
|
+
};
|
|
6211
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
6212
|
+
}
|
|
4541
6213
|
async function notifyOffline(state) {
|
|
4542
6214
|
if (!state.agentId || !state.authHeader) return;
|
|
4543
6215
|
if (!state.connected) {
|
|
@@ -4555,13 +6227,28 @@ async function notifyOffline(state) {
|
|
|
4555
6227
|
if (state.interactive) displayStatus(state);
|
|
4556
6228
|
}
|
|
4557
6229
|
}
|
|
6230
|
+
async function timeShutdownPhase(state, durations, name, run2) {
|
|
6231
|
+
const startedAt = Date.now();
|
|
6232
|
+
try {
|
|
6233
|
+
return await run2();
|
|
6234
|
+
} finally {
|
|
6235
|
+
const elapsedMs = Date.now() - startedAt;
|
|
6236
|
+
durations[name] = elapsedMs;
|
|
6237
|
+
log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
|
|
6238
|
+
}
|
|
6239
|
+
}
|
|
4558
6240
|
async function cleanup(state, opts = {}) {
|
|
6241
|
+
const durations = {};
|
|
4559
6242
|
state.running = false;
|
|
4560
6243
|
for (const timer of state.sessionCleanupTimers) {
|
|
4561
6244
|
clearInterval(timer);
|
|
4562
6245
|
clearTimeout(timer);
|
|
4563
6246
|
}
|
|
4564
6247
|
state.sessionCleanupTimers = [];
|
|
6248
|
+
if (state.claudeUsageTimer) {
|
|
6249
|
+
clearTimeout(state.claudeUsageTimer);
|
|
6250
|
+
state.claudeUsageTimer = null;
|
|
6251
|
+
}
|
|
4565
6252
|
if (opts.graceful && state.channelDriver) {
|
|
4566
6253
|
state.channelDriver.stop();
|
|
4567
6254
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -4569,7 +6256,13 @@ async function cleanup(state, opts = {}) {
|
|
|
4569
6256
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
4570
6257
|
displayStatus(state);
|
|
4571
6258
|
}
|
|
4572
|
-
const
|
|
6259
|
+
const driver = state.channelDriver;
|
|
6260
|
+
const settled = await timeShutdownPhase(
|
|
6261
|
+
state,
|
|
6262
|
+
durations,
|
|
6263
|
+
"drain",
|
|
6264
|
+
() => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
6265
|
+
);
|
|
4573
6266
|
if (!settled) {
|
|
4574
6267
|
logActivity(state, {
|
|
4575
6268
|
type: "info",
|
|
@@ -4578,13 +6271,15 @@ async function cleanup(state, opts = {}) {
|
|
|
4578
6271
|
if (state.interactive) displayStatus(state);
|
|
4579
6272
|
}
|
|
4580
6273
|
}
|
|
4581
|
-
await notifyOffline(state);
|
|
6274
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
4582
6275
|
if (state.connection) {
|
|
4583
|
-
state.connection
|
|
6276
|
+
const connection = state.connection;
|
|
6277
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
4584
6278
|
state.connection = null;
|
|
4585
6279
|
}
|
|
4586
6280
|
if (state.opencodeProcess) {
|
|
4587
|
-
|
|
6281
|
+
const opencodeProcess = state.opencodeProcess;
|
|
6282
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
4588
6283
|
if (state.interactive) {
|
|
4589
6284
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
4590
6285
|
displayStatus(state);
|
|
@@ -4593,12 +6288,15 @@ async function cleanup(state, opts = {}) {
|
|
|
4593
6288
|
}
|
|
4594
6289
|
state.opencodeProcess = null;
|
|
4595
6290
|
}
|
|
6291
|
+
return durations;
|
|
4596
6292
|
}
|
|
4597
6293
|
async function run(options) {
|
|
4598
6294
|
const interactive = isInteractive(options.json);
|
|
4599
6295
|
let logLevel;
|
|
6296
|
+
let fileSyncDirectories;
|
|
4600
6297
|
try {
|
|
4601
6298
|
logLevel = resolveLogLevel(options);
|
|
6299
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir3());
|
|
4602
6300
|
} catch (error2) {
|
|
4603
6301
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4604
6302
|
if (options.json) {
|
|
@@ -4611,7 +6309,7 @@ async function run(options) {
|
|
|
4611
6309
|
return;
|
|
4612
6310
|
}
|
|
4613
6311
|
const state = {
|
|
4614
|
-
agentId: options.agent || "",
|
|
6312
|
+
agentId: options.runner || options.agent || "",
|
|
4615
6313
|
agentName: null,
|
|
4616
6314
|
port: options.port ?? 4096,
|
|
4617
6315
|
conversationFilter: options.conversation ?? null,
|
|
@@ -4631,8 +6329,28 @@ async function run(options) {
|
|
|
4631
6329
|
messageCount: 0,
|
|
4632
6330
|
lastProxiedActivityAt: null,
|
|
4633
6331
|
sessionCleanupTimers: [],
|
|
6332
|
+
claudeUsageTimer: null,
|
|
4634
6333
|
authHeader: ""
|
|
4635
6334
|
};
|
|
6335
|
+
setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
|
|
6336
|
+
if (fileSyncDirectories.length > 0) {
|
|
6337
|
+
log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
|
|
6338
|
+
} else {
|
|
6339
|
+
log2(state, "File sync is disabled (no --enable-file-sync-to given)", "debug");
|
|
6340
|
+
}
|
|
6341
|
+
if (!options.runner && options.agent) {
|
|
6342
|
+
telemetry.info(
|
|
6343
|
+
EventTypes.DEPRECATED_AGENT_FLAG_USED,
|
|
6344
|
+
"Deprecated --agent flag used instead of --runner",
|
|
6345
|
+
{ command: "run" },
|
|
6346
|
+
state.agentId
|
|
6347
|
+
);
|
|
6348
|
+
const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
|
|
6349
|
+
log2(state, agentFlagNotice, "warn");
|
|
6350
|
+
if (state.interactive && !state.json) {
|
|
6351
|
+
logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
|
|
6352
|
+
}
|
|
6353
|
+
}
|
|
4636
6354
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4637
6355
|
log2(
|
|
4638
6356
|
state,
|
|
@@ -4643,14 +6361,38 @@ async function run(options) {
|
|
|
4643
6361
|
const handleSignal = async () => {
|
|
4644
6362
|
if (state.shuttingDown) return;
|
|
4645
6363
|
state.shuttingDown = true;
|
|
6364
|
+
const shutdownStartedAt = Date.now();
|
|
4646
6365
|
if (state.interactive) {
|
|
4647
6366
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
4648
6367
|
displayStatus(state);
|
|
4649
6368
|
} else {
|
|
4650
6369
|
log2(state, "Shutting down...");
|
|
4651
6370
|
}
|
|
4652
|
-
await cleanup(state, { graceful: true });
|
|
4653
|
-
|
|
6371
|
+
const durations = await cleanup(state, { graceful: true });
|
|
6372
|
+
const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
|
|
6373
|
+
await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
|
|
6374
|
+
let timer;
|
|
6375
|
+
const flushed = shutdownTelemetry().then(
|
|
6376
|
+
() => true,
|
|
6377
|
+
(error2) => {
|
|
6378
|
+
log2(
|
|
6379
|
+
state,
|
|
6380
|
+
`Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
6381
|
+
"warn"
|
|
6382
|
+
);
|
|
6383
|
+
return true;
|
|
6384
|
+
}
|
|
6385
|
+
);
|
|
6386
|
+
const timedOut = new Promise((resolve3) => {
|
|
6387
|
+
timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
|
|
6388
|
+
});
|
|
6389
|
+
if (!await Promise.race([flushed, timedOut])) {
|
|
6390
|
+
log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
|
|
6391
|
+
}
|
|
6392
|
+
clearTimeout(timer);
|
|
6393
|
+
});
|
|
6394
|
+
const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
|
|
6395
|
+
log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
|
|
4654
6396
|
process.exit(0);
|
|
4655
6397
|
};
|
|
4656
6398
|
process.on("SIGINT", handleSignal);
|
|
@@ -4661,7 +6403,9 @@ async function run(options) {
|
|
|
4661
6403
|
if (!interactive) {
|
|
4662
6404
|
printError("Authentication required");
|
|
4663
6405
|
blank();
|
|
4664
|
-
console.log(
|
|
6406
|
+
console.log(
|
|
6407
|
+
chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
|
|
6408
|
+
);
|
|
4665
6409
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
4666
6410
|
blank();
|
|
4667
6411
|
process.exit(1);
|
|
@@ -4675,6 +6419,25 @@ async function run(options) {
|
|
|
4675
6419
|
);
|
|
4676
6420
|
}
|
|
4677
6421
|
state.authHeader = getAuthHeader(credentials2);
|
|
6422
|
+
if (credentials2.notice) {
|
|
6423
|
+
log2(state, credentials2.notice, "warn");
|
|
6424
|
+
if (state.interactive && !state.json) {
|
|
6425
|
+
logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
if (credentials2.keySource === "agent_key") {
|
|
6429
|
+
telemetry.info(
|
|
6430
|
+
EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
|
|
6431
|
+
"Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
|
|
6432
|
+
{ command: "run" },
|
|
6433
|
+
state.agentId
|
|
6434
|
+
);
|
|
6435
|
+
const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
|
|
6436
|
+
log2(state, agentKeyNotice, "warn");
|
|
6437
|
+
if (state.interactive && !state.json) {
|
|
6438
|
+
logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
|
|
6439
|
+
}
|
|
6440
|
+
}
|
|
4678
6441
|
if (!state.agentId) {
|
|
4679
6442
|
if (credentials2.authType === "agent_key") {
|
|
4680
6443
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
@@ -4692,9 +6455,15 @@ async function run(options) {
|
|
|
4692
6455
|
process.exit(1);
|
|
4693
6456
|
}
|
|
4694
6457
|
} else {
|
|
4695
|
-
printError(
|
|
6458
|
+
printError(
|
|
6459
|
+
"--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
|
|
6460
|
+
);
|
|
4696
6461
|
blank();
|
|
4697
|
-
console.log(
|
|
6462
|
+
console.log(
|
|
6463
|
+
chalk6.dim(
|
|
6464
|
+
"Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
|
|
6465
|
+
)
|
|
6466
|
+
);
|
|
4698
6467
|
blank();
|
|
4699
6468
|
process.exit(1);
|
|
4700
6469
|
}
|
|
@@ -4737,25 +6506,67 @@ async function run(options) {
|
|
|
4737
6506
|
}
|
|
4738
6507
|
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
4739
6508
|
state.agentName = validation.agent.name;
|
|
6509
|
+
const microvmId = process.env.MICROVM_ID?.trim();
|
|
6510
|
+
if (microvmId) {
|
|
6511
|
+
const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
|
|
6512
|
+
if (reported.ok) {
|
|
6513
|
+
log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
|
|
6514
|
+
} else {
|
|
6515
|
+
const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
|
|
6516
|
+
log2(state, message, "warn");
|
|
6517
|
+
if (state.interactive && !state.json) {
|
|
6518
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
6519
|
+
}
|
|
6520
|
+
}
|
|
6521
|
+
} else {
|
|
6522
|
+
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
6523
|
+
}
|
|
6524
|
+
const { timeoutMs: opencodeStartTimeoutMs, warnings: opencodeStartTimeoutWarnings } = resolveOpenCodeStartTimeoutMs(options, process.env);
|
|
6525
|
+
for (const warning2 of opencodeStartTimeoutWarnings) {
|
|
6526
|
+
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
6527
|
+
}
|
|
4740
6528
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
4741
6529
|
try {
|
|
4742
6530
|
const oc = await ensureOpenCodeRunning({
|
|
4743
6531
|
port: state.port,
|
|
4744
6532
|
interactive: state.interactive,
|
|
4745
6533
|
agentId: state.agentId,
|
|
4746
|
-
log: (message) => log2(state, message)
|
|
6534
|
+
log: (message) => log2(state, message),
|
|
6535
|
+
startTimeoutMs: opencodeStartTimeoutMs
|
|
4747
6536
|
});
|
|
4748
6537
|
state.port = oc.port;
|
|
4749
6538
|
state.opencodeProcess = oc.process;
|
|
4750
6539
|
state.opencodeVersion = oc.version;
|
|
4751
|
-
state.opencodeConnected = oc.
|
|
6540
|
+
state.opencodeConnected = oc.notReadyReason === null;
|
|
4752
6541
|
const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
4753
6542
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
6543
|
+
if (!state.interactive && oc.notReadyReason !== null) {
|
|
6544
|
+
const message = `OpenCode is not ready on port ${state.port}: ${oc.notReadyReason}. The runner will still come online, but messages will fail until opencode answers \u2014 raise the wait with --opencode-start-timeout <seconds> (env ${OPENCODE_START_TIMEOUT_ENV}).`;
|
|
6545
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
6546
|
+
} else {
|
|
6547
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
6548
|
+
if (versionWarning) {
|
|
6549
|
+
log2(state, versionWarning, "warn");
|
|
6550
|
+
if (state.interactive && !state.json) {
|
|
6551
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
const noProviderWarning = buildNoProviderWarning(
|
|
6555
|
+
await hasAnyConfiguredProvider(state.port)
|
|
6556
|
+
);
|
|
6557
|
+
if (noProviderWarning) {
|
|
6558
|
+
log2(state, noProviderWarning, "warn");
|
|
6559
|
+
if (state.interactive && !state.json) {
|
|
6560
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
6561
|
+
blank();
|
|
6562
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
6563
|
+
console.log(
|
|
6564
|
+
chalk6.dim(
|
|
6565
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
6566
|
+
)
|
|
6567
|
+
);
|
|
6568
|
+
blank();
|
|
6569
|
+
}
|
|
4759
6570
|
}
|
|
4760
6571
|
}
|
|
4761
6572
|
} catch (error2) {
|
|
@@ -4770,6 +6581,10 @@ async function run(options) {
|
|
|
4770
6581
|
getAuthHeader: () => state.authHeader,
|
|
4771
6582
|
conversationFilter: state.conversationFilter,
|
|
4772
6583
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
6584
|
+
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
6585
|
+
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
6586
|
+
fileSyncDirectories,
|
|
6587
|
+
homeDir: homedir3(),
|
|
4773
6588
|
log: (entry) => (
|
|
4774
6589
|
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4775
6590
|
// survive the sink filter (they no longer collapse to info). `type`
|
|
@@ -4796,6 +6611,18 @@ async function run(options) {
|
|
|
4796
6611
|
type: "info",
|
|
4797
6612
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
4798
6613
|
});
|
|
6614
|
+
if (options.tunnelReadyFile) {
|
|
6615
|
+
const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
|
|
6616
|
+
if (marker.ok) {
|
|
6617
|
+
log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
|
|
6618
|
+
} else {
|
|
6619
|
+
log2(
|
|
6620
|
+
state,
|
|
6621
|
+
`Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
|
|
6622
|
+
"error"
|
|
6623
|
+
);
|
|
6624
|
+
}
|
|
6625
|
+
}
|
|
4799
6626
|
emitAgentConnected(state.agentId, {
|
|
4800
6627
|
port: state.port,
|
|
4801
6628
|
cli_version: getCliVersion(),
|
|
@@ -4851,6 +6678,12 @@ async function run(options) {
|
|
|
4851
6678
|
onDrainPing: () => {
|
|
4852
6679
|
if (!state.running) return;
|
|
4853
6680
|
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
6681
|
+
void channelDriver.syncPendingFiles().catch(
|
|
6682
|
+
(error2) => logActivity(state, {
|
|
6683
|
+
type: "error",
|
|
6684
|
+
error: `Runner file sync failed on ping: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
6685
|
+
})
|
|
6686
|
+
);
|
|
4854
6687
|
channelDriver.drainPending().then((processed) => {
|
|
4855
6688
|
if (processed > 0) {
|
|
4856
6689
|
state.messageCount += processed;
|
|
@@ -4880,6 +6713,7 @@ async function run(options) {
|
|
|
4880
6713
|
throw error2;
|
|
4881
6714
|
}
|
|
4882
6715
|
scheduleSessionCleanup(state, channelDriver, options);
|
|
6716
|
+
scheduleClaudeUsageReporting(state, options);
|
|
4883
6717
|
if (!interactive || state.json) {
|
|
4884
6718
|
log2(state, "Driving channel messages...");
|
|
4885
6719
|
}
|
|
@@ -4909,7 +6743,7 @@ async function run(options) {
|
|
|
4909
6743
|
}
|
|
4910
6744
|
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
4911
6745
|
command: "run",
|
|
4912
|
-
agentId: options.agent
|
|
6746
|
+
agentId: options.runner || options.agent
|
|
4913
6747
|
});
|
|
4914
6748
|
await shutdownTelemetry();
|
|
4915
6749
|
process.exit(1);
|
|
@@ -4934,10 +6768,17 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4934
6768
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
4935
6769
|
program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
|
|
4936
6770
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4937
|
-
program.command("
|
|
6771
|
+
program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
|
|
6772
|
+
program.command("run").description("Connect to Evident and process messages").option("--runner [id]", "Runner ID to connect to (optional when EVIDENT_RUNNER_KEY is set)").option(
|
|
6773
|
+
"-a, --agent [id]",
|
|
6774
|
+
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
6775
|
+
).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4938
6776
|
"--log-level <level>",
|
|
4939
6777
|
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
4940
|
-
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option(
|
|
6778
|
+
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option(
|
|
6779
|
+
"--opencode-start-timeout <seconds>",
|
|
6780
|
+
"Seconds to wait for OpenCode to become healthy when the runner starts it (default: 180). Env: EVIDENT_OPENCODE_START_TIMEOUT"
|
|
6781
|
+
).option("--json", "Output in JSON format").option(
|
|
4941
6782
|
"--session-cleanup-max-age <duration>",
|
|
4942
6783
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4943
6784
|
).option(
|
|
@@ -4946,10 +6787,22 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4946
6787
|
).option(
|
|
4947
6788
|
"--session-cleanup-interval <duration>",
|
|
4948
6789
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
6790
|
+
).option(
|
|
6791
|
+
"--claude-usage-reporting <mode>",
|
|
6792
|
+
"Report Claude subscription usage to Evident: auto | on | off (default: auto). Env: EVIDENT_CLAUDE_USAGE_REPORTING"
|
|
6793
|
+
).option(
|
|
6794
|
+
"--enable-file-sync-to <dir>",
|
|
6795
|
+
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
6796
|
+
(value, previous) => previous.concat([value]),
|
|
6797
|
+
[]
|
|
6798
|
+
).option(
|
|
6799
|
+
"--tunnel-ready-file <path>",
|
|
6800
|
+
"Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
|
|
4949
6801
|
).action(
|
|
4950
6802
|
(options) => {
|
|
4951
6803
|
run({
|
|
4952
6804
|
agent: options.agent,
|
|
6805
|
+
runner: options.runner,
|
|
4953
6806
|
port: parseInt(options.port, 10),
|
|
4954
6807
|
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
4955
6808
|
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
@@ -4957,11 +6810,21 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4957
6810
|
verbose: options.verbose,
|
|
4958
6811
|
conversation: options.conversation,
|
|
4959
6812
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
6813
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
6814
|
+
// resolveOpenCodeStartTimeoutMs (flag > EVIDENT_OPENCODE_START_TIMEOUT > 180s default).
|
|
6815
|
+
opencodeStartTimeout: options.opencodeStartTimeout,
|
|
4960
6816
|
json: options.json,
|
|
4961
6817
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
4962
6818
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
4963
6819
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
4964
|
-
sessionCleanupInterval: options.sessionCleanupInterval
|
|
6820
|
+
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
6821
|
+
// Raw string — the resolver in run.ts single-sources parsing
|
|
6822
|
+
// (resolveClaudeUsageReportingMode).
|
|
6823
|
+
claudeUsageReporting: options.claudeUsageReporting,
|
|
6824
|
+
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
6825
|
+
// resolveFileSyncDirectories.
|
|
6826
|
+
enableFileSyncTo: options.enableFileSyncTo,
|
|
6827
|
+
tunnelReadyFile: options.tunnelReadyFile
|
|
4965
6828
|
});
|
|
4966
6829
|
}
|
|
4967
6830
|
);
|