@evident-ai/cli 3.1.1-dev.2b250ad → 3.1.1-dev.2b780b5
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 +60 -0
- package/dist/index.js +1689 -334
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,11 +38,6 @@ 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: "",
|
|
@@ -417,8 +412,10 @@ async function deviceFlowLogin(options) {
|
|
|
417
412
|
}
|
|
418
413
|
async function tokenLogin() {
|
|
419
414
|
console.log("Token login mode.");
|
|
420
|
-
console.log("
|
|
421
|
-
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
|
+
);
|
|
422
419
|
blank();
|
|
423
420
|
process.stdout.write("Paste token: ");
|
|
424
421
|
const token = await new Promise((resolve3) => {
|
|
@@ -442,13 +439,22 @@ async function tokenLogin() {
|
|
|
442
439
|
printError("No token provided.");
|
|
443
440
|
process.exit(1);
|
|
444
441
|
}
|
|
442
|
+
await validateAndStoreToken(token);
|
|
443
|
+
}
|
|
444
|
+
async function validateAndStoreToken(token) {
|
|
445
445
|
const spinner = ora("Validating token...").start();
|
|
446
446
|
try {
|
|
447
|
-
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
|
+
}
|
|
448
455
|
await storeToken({
|
|
449
456
|
token,
|
|
450
|
-
user: result.user
|
|
451
|
-
expiresAt: result.expires_at
|
|
457
|
+
user: { email: result.user.email }
|
|
452
458
|
});
|
|
453
459
|
spinner.stop();
|
|
454
460
|
printSuccess(`Logged in as ${chalk2.bold(result.user.email)}`);
|
|
@@ -508,7 +514,9 @@ async function whoami() {
|
|
|
508
514
|
blank();
|
|
509
515
|
console.log(keyValue("Endpoint", apiUrl));
|
|
510
516
|
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
511
|
-
|
|
517
|
+
if (credentials2.user.id) {
|
|
518
|
+
console.log(keyValue("User ID", credentials2.user.id));
|
|
519
|
+
}
|
|
512
520
|
if (credentials2.expiresAt) {
|
|
513
521
|
const expiresAt = new Date(credentials2.expiresAt);
|
|
514
522
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -524,11 +532,484 @@ async function whoami() {
|
|
|
524
532
|
blank();
|
|
525
533
|
}
|
|
526
534
|
|
|
535
|
+
// src/lib/auth.ts
|
|
536
|
+
async function getAuthCredentials() {
|
|
537
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
538
|
+
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
539
|
+
if (runnerKey) {
|
|
540
|
+
return {
|
|
541
|
+
token: runnerKey,
|
|
542
|
+
authType: "agent_key",
|
|
543
|
+
keySource: "runner_key",
|
|
544
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
if (agentKey) {
|
|
548
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
549
|
+
}
|
|
550
|
+
const userToken = process.env.EVIDENT_TOKEN;
|
|
551
|
+
if (userToken) {
|
|
552
|
+
return { token: userToken, authType: "bearer" };
|
|
553
|
+
}
|
|
554
|
+
const keychainCreds = await getToken();
|
|
555
|
+
if (keychainCreds) {
|
|
556
|
+
return {
|
|
557
|
+
token: keychainCreds.token,
|
|
558
|
+
authType: "bearer",
|
|
559
|
+
user: keychainCreds.user
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
function getAuthHeader(credentials2) {
|
|
565
|
+
if (credentials2.authType === "agent_key") {
|
|
566
|
+
return `SandboxKey ${credentials2.token}`;
|
|
567
|
+
}
|
|
568
|
+
return `Bearer ${credentials2.token}`;
|
|
569
|
+
}
|
|
570
|
+
function isInteractive(jsonOutput) {
|
|
571
|
+
if (jsonOutput) return false;
|
|
572
|
+
if (process.env.CI) return false;
|
|
573
|
+
if (process.env.GITHUB_ACTIONS) return false;
|
|
574
|
+
if (!process.stdin.isTTY) return false;
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/commands/agent-lookup.ts
|
|
579
|
+
async function readErrorMessage(response) {
|
|
580
|
+
const text = await response.text().catch(() => "");
|
|
581
|
+
if (!text) return response.statusText || void 0;
|
|
582
|
+
try {
|
|
583
|
+
const data = JSON.parse(text);
|
|
584
|
+
const message = data.message ?? data.error;
|
|
585
|
+
if (typeof message === "string" && message.trim()) {
|
|
586
|
+
return message;
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
return text.trim() || response.statusText || void 0;
|
|
591
|
+
}
|
|
592
|
+
function authFailureHint(apiUrl, serverMessage) {
|
|
593
|
+
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
594
|
+
return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
|
|
595
|
+
}
|
|
596
|
+
async function resolveAgentIdFromKey(authHeader) {
|
|
597
|
+
const apiUrl = getApiUrlConfig();
|
|
598
|
+
try {
|
|
599
|
+
const response = await fetch(`${apiUrl}/me`, {
|
|
600
|
+
headers: { Authorization: authHeader }
|
|
601
|
+
});
|
|
602
|
+
if (response.status === 401) {
|
|
603
|
+
const serverMessage = await readErrorMessage(response);
|
|
604
|
+
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
605
|
+
}
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
const serverMessage = await readErrorMessage(response);
|
|
608
|
+
return {
|
|
609
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const data = await response.json();
|
|
613
|
+
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
614
|
+
return { agent_id: data.agent_id };
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
618
|
+
};
|
|
619
|
+
} catch (error2) {
|
|
620
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
621
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
625
|
+
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
626
|
+
const apiUrl = getApiUrlConfig();
|
|
627
|
+
try {
|
|
628
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { Authorization: authHeader },
|
|
631
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
632
|
+
});
|
|
633
|
+
if (!response.ok) {
|
|
634
|
+
const serverMessage = await readErrorMessage(response);
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { ok: true };
|
|
641
|
+
} catch (error2) {
|
|
642
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function describeBestEffortError(error2) {
|
|
646
|
+
const name = error2?.name;
|
|
647
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
648
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
649
|
+
}
|
|
650
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
651
|
+
}
|
|
652
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
653
|
+
try {
|
|
654
|
+
const apiUrl = getApiUrlConfig();
|
|
655
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
658
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
659
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
660
|
+
});
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
const serverMessage = await readErrorMessage(response);
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return { ok: true };
|
|
669
|
+
} catch (error2) {
|
|
670
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function toReportedWindow(window) {
|
|
674
|
+
if (!window) return null;
|
|
675
|
+
return { utilization: window.utilization, resets_at: window.resetsAt };
|
|
676
|
+
}
|
|
677
|
+
async function reportClaudeUsage(agentId, authHeader, snapshot) {
|
|
678
|
+
try {
|
|
679
|
+
const apiUrl = getApiUrlConfig();
|
|
680
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/claude-usage`, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
five_hour: toReportedWindow(snapshot.fiveHour),
|
|
685
|
+
seven_day: toReportedWindow(snapshot.sevenDay)
|
|
686
|
+
}),
|
|
687
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
688
|
+
});
|
|
689
|
+
if (!response.ok) {
|
|
690
|
+
const serverMessage = await readErrorMessage(response);
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return { ok: true };
|
|
697
|
+
} catch (error2) {
|
|
698
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function getAgentInfo(agentId, authHeader) {
|
|
702
|
+
const apiUrl = getApiUrlConfig();
|
|
703
|
+
try {
|
|
704
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
705
|
+
headers: { Authorization: authHeader }
|
|
706
|
+
});
|
|
707
|
+
if (response.status === 401) {
|
|
708
|
+
const serverMessage = await readErrorMessage(response);
|
|
709
|
+
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
710
|
+
}
|
|
711
|
+
if (response.status === 403) {
|
|
712
|
+
const serverMessage = await readErrorMessage(response);
|
|
713
|
+
return {
|
|
714
|
+
valid: false,
|
|
715
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (response.status === 404) {
|
|
719
|
+
const serverMessage = await readErrorMessage(response);
|
|
720
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
721
|
+
}
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
const serverMessage = await readErrorMessage(response);
|
|
724
|
+
return {
|
|
725
|
+
valid: false,
|
|
726
|
+
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const agent = await response.json();
|
|
730
|
+
if (agent.agent_type !== "local") {
|
|
731
|
+
return {
|
|
732
|
+
valid: false,
|
|
733
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return { valid: true, agent };
|
|
737
|
+
} catch (error2) {
|
|
738
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
739
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/commands/status.ts
|
|
744
|
+
var STATUS_TIMEOUT_MS = 1e4;
|
|
745
|
+
function authLabelFor(credentials2) {
|
|
746
|
+
if (credentials2.authType === "agent_key") {
|
|
747
|
+
return credentials2.keySource === "agent_key" ? "runner key (EVIDENT_AGENT_KEY)" : "runner key (EVIDENT_RUNNER_KEY)";
|
|
748
|
+
}
|
|
749
|
+
return "user token";
|
|
750
|
+
}
|
|
751
|
+
function describeFetchError(error2) {
|
|
752
|
+
const name = error2?.name;
|
|
753
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
754
|
+
return `timed out after ${STATUS_TIMEOUT_MS}ms waiting for a response`;
|
|
755
|
+
}
|
|
756
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
757
|
+
}
|
|
758
|
+
async function checkStatus(jsonMode) {
|
|
759
|
+
const apiUrl = getApiUrlConfig();
|
|
760
|
+
const credentials2 = await getAuthCredentials();
|
|
761
|
+
if (!credentials2) {
|
|
762
|
+
return {
|
|
763
|
+
ok: false,
|
|
764
|
+
endpoint: apiUrl,
|
|
765
|
+
reason: "no_credentials",
|
|
766
|
+
error: "No credentials configured. Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY), or EVIDENT_TOKEN, or run `evident login`.",
|
|
767
|
+
exitCode: 1
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (credentials2.notice && !jsonMode) {
|
|
771
|
+
printWarning(credentials2.notice);
|
|
772
|
+
}
|
|
773
|
+
let response;
|
|
774
|
+
try {
|
|
775
|
+
response = await fetch(`${apiUrl}/me`, {
|
|
776
|
+
headers: { Authorization: getAuthHeader(credentials2) },
|
|
777
|
+
signal: AbortSignal.timeout(STATUS_TIMEOUT_MS)
|
|
778
|
+
});
|
|
779
|
+
} catch (error2) {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
endpoint: apiUrl,
|
|
783
|
+
authLabel: authLabelFor(credentials2),
|
|
784
|
+
reason: "unreachable",
|
|
785
|
+
error: `Could not reach ${apiUrl}: ${describeFetchError(error2)}. The credentials were NOT validated.`,
|
|
786
|
+
exitCode: 75
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (response.status === 401) {
|
|
790
|
+
const serverMessage = await readErrorMessage(response);
|
|
791
|
+
return {
|
|
792
|
+
ok: false,
|
|
793
|
+
endpoint: apiUrl,
|
|
794
|
+
authLabel: authLabelFor(credentials2),
|
|
795
|
+
reason: "unauthorized",
|
|
796
|
+
error: authFailureHint(apiUrl, serverMessage),
|
|
797
|
+
exitCode: 1
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (response.status === 404) {
|
|
801
|
+
return {
|
|
802
|
+
ok: false,
|
|
803
|
+
endpoint: apiUrl,
|
|
804
|
+
authLabel: authLabelFor(credentials2),
|
|
805
|
+
reason: "endpoint_not_found",
|
|
806
|
+
error: `${apiUrl}/me returned HTTP 404 \u2014 that endpoint has no /me route, so it is probably missing the /v1 prefix. The credentials were NOT validated.`,
|
|
807
|
+
exitCode: 75
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
if (response.status >= 500) {
|
|
811
|
+
const serverMessage = await readErrorMessage(response);
|
|
812
|
+
return {
|
|
813
|
+
ok: false,
|
|
814
|
+
endpoint: apiUrl,
|
|
815
|
+
authLabel: authLabelFor(credentials2),
|
|
816
|
+
reason: "unreachable",
|
|
817
|
+
error: `${apiUrl} returned HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}. The credentials were NOT validated.`,
|
|
818
|
+
exitCode: 75
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (!response.ok) {
|
|
822
|
+
const serverMessage = await readErrorMessage(response);
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
endpoint: apiUrl,
|
|
826
|
+
authLabel: authLabelFor(credentials2),
|
|
827
|
+
reason: "http_error",
|
|
828
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`,
|
|
829
|
+
exitCode: 1
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const data = await response.json();
|
|
833
|
+
return {
|
|
834
|
+
ok: true,
|
|
835
|
+
endpoint: apiUrl,
|
|
836
|
+
authType: data.auth_type,
|
|
837
|
+
authLabel: authLabelFor(credentials2),
|
|
838
|
+
runnerId: data.auth_type === "agent_key" ? data.agent_id : void 0,
|
|
839
|
+
reason: "ok",
|
|
840
|
+
exitCode: 0
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function printJson(result) {
|
|
844
|
+
const payload = {
|
|
845
|
+
ok: result.ok,
|
|
846
|
+
endpoint: result.endpoint
|
|
847
|
+
};
|
|
848
|
+
if (result.authType) payload.auth_type = result.authType;
|
|
849
|
+
if (result.runnerId) payload.runner_id = result.runnerId;
|
|
850
|
+
if (result.reason) payload.reason = result.reason;
|
|
851
|
+
if (result.error) payload.error = result.error;
|
|
852
|
+
console.log(JSON.stringify(payload));
|
|
853
|
+
}
|
|
854
|
+
function printHuman(result) {
|
|
855
|
+
blank();
|
|
856
|
+
console.log(keyValue("Endpoint", result.endpoint));
|
|
857
|
+
if (result.ok) {
|
|
858
|
+
console.log(keyValue("Auth", result.authLabel ?? "\u2014"));
|
|
859
|
+
if (result.runnerId) {
|
|
860
|
+
console.log(keyValue("Runner", result.runnerId));
|
|
861
|
+
}
|
|
862
|
+
console.log(keyValue("Status", "OK \u2014 credentials accepted"));
|
|
863
|
+
blank();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (result.authLabel) {
|
|
867
|
+
console.log(keyValue("Auth", result.authLabel));
|
|
868
|
+
}
|
|
869
|
+
blank();
|
|
870
|
+
printError(result.error ?? "Unknown error");
|
|
871
|
+
}
|
|
872
|
+
async function status(options = {}) {
|
|
873
|
+
const result = await checkStatus(Boolean(options.json));
|
|
874
|
+
if (options.json) {
|
|
875
|
+
printJson(result);
|
|
876
|
+
} else {
|
|
877
|
+
printHuman(result);
|
|
878
|
+
}
|
|
879
|
+
process.exit(result.exitCode);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/lib/claude-usage.ts
|
|
883
|
+
import { execFileSync } from "child_process";
|
|
884
|
+
import { readFileSync } from "fs";
|
|
885
|
+
import { homedir } from "os";
|
|
886
|
+
import { join } from "path";
|
|
887
|
+
var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
888
|
+
var KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
889
|
+
function parseClaudeCliCredentials(raw) {
|
|
890
|
+
let parsed;
|
|
891
|
+
try {
|
|
892
|
+
parsed = JSON.parse(raw);
|
|
893
|
+
} catch {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
const data = parsed.claudeAiOauth ?? parsed;
|
|
897
|
+
const creds = data;
|
|
898
|
+
if (typeof creds.accessToken !== "string" || typeof creds.expiresAt !== "number") {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
return { accessToken: creds.accessToken, expiresAt: creds.expiresAt };
|
|
902
|
+
}
|
|
903
|
+
function readClaudeCliCredentials() {
|
|
904
|
+
if (process.platform === "darwin") {
|
|
905
|
+
try {
|
|
906
|
+
const raw = execFileSync(
|
|
907
|
+
"/usr/bin/security",
|
|
908
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
|
|
909
|
+
{ encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
|
|
910
|
+
);
|
|
911
|
+
return parseClaudeCliCredentials(raw);
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
|
|
918
|
+
return parseClaudeCliCredentials(raw);
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
var ClaudeUsageError = class extends Error {
|
|
924
|
+
constructor(message, reason) {
|
|
925
|
+
super(message);
|
|
926
|
+
this.reason = reason;
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
function isLocalCredentialProblem(err) {
|
|
930
|
+
return err instanceof ClaudeUsageError && (err.reason === "no_credentials" || err.reason === "credentials_expired");
|
|
931
|
+
}
|
|
932
|
+
function normalizeResetsAt(value) {
|
|
933
|
+
const ms = Date.parse(value);
|
|
934
|
+
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
|
|
935
|
+
}
|
|
936
|
+
function toWindow(value) {
|
|
937
|
+
if (!value || typeof value !== "object") {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const window = value;
|
|
941
|
+
if (typeof window.utilization !== "number" || typeof window.resets_at !== "string") {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
const resetsAt = normalizeResetsAt(window.resets_at);
|
|
945
|
+
if (resetsAt === null) {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
return { utilization: window.utilization, resetsAt };
|
|
949
|
+
}
|
|
950
|
+
async function getClaudeUsage() {
|
|
951
|
+
const credentials2 = readClaudeCliCredentials();
|
|
952
|
+
if (!credentials2) {
|
|
953
|
+
throw new ClaudeUsageError(
|
|
954
|
+
"No local Claude Code login found. Run `claude` once to sign in with your Claude subscription.",
|
|
955
|
+
"no_credentials"
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (credentials2.expiresAt < Date.now()) {
|
|
959
|
+
throw new ClaudeUsageError(
|
|
960
|
+
"Claude Code credentials have expired. Run `claude` to refresh them.",
|
|
961
|
+
"credentials_expired"
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
const res = await fetch(CLAUDE_USAGE_URL, {
|
|
965
|
+
headers: {
|
|
966
|
+
Authorization: `Bearer ${credentials2.accessToken}`,
|
|
967
|
+
"Content-Type": "application/json",
|
|
968
|
+
"anthropic-version": "2023-06-01"
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
if (!res.ok) {
|
|
972
|
+
throw new ClaudeUsageError(`Claude usage request failed: HTTP ${res.status}`, "request_failed");
|
|
973
|
+
}
|
|
974
|
+
const body = await res.json();
|
|
975
|
+
return {
|
|
976
|
+
fiveHour: toWindow(body.five_hour),
|
|
977
|
+
sevenDay: toWindow(body.seven_day)
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/commands/claude-usage.ts
|
|
982
|
+
function formatWindow(label, window) {
|
|
983
|
+
if (!window) {
|
|
984
|
+
return keyValue(label, "not available for this plan");
|
|
985
|
+
}
|
|
986
|
+
const resetsAt = new Date(window.resetsAt);
|
|
987
|
+
return keyValue(label, `${window.utilization}% used, resets ${resetsAt.toLocaleString()}`);
|
|
988
|
+
}
|
|
989
|
+
async function claudeUsage() {
|
|
990
|
+
try {
|
|
991
|
+
const usage = await getClaudeUsage();
|
|
992
|
+
blank();
|
|
993
|
+
console.log(formatWindow("5-hour session", usage.fiveHour));
|
|
994
|
+
console.log(formatWindow("7-day", usage.sevenDay));
|
|
995
|
+
blank();
|
|
996
|
+
} catch (err) {
|
|
997
|
+
if (err instanceof ClaudeUsageError) {
|
|
998
|
+
printError(err.message);
|
|
999
|
+
process.exit(1);
|
|
1000
|
+
}
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
527
1005
|
// src/commands/run.ts
|
|
528
|
-
import { homedir as
|
|
529
|
-
import { isAbsolute as isAbsolute2, join as
|
|
1006
|
+
import { homedir as homedir3 } from "os";
|
|
1007
|
+
import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
|
|
530
1008
|
import chalk6 from "chalk";
|
|
531
1009
|
|
|
1010
|
+
// ../../packages/types/src/agents/index.ts
|
|
1011
|
+
var MICROVM_MAX_LIFETIME_MS = 8 * 60 * 6e4;
|
|
1012
|
+
|
|
532
1013
|
// ../../packages/types/src/telemetry/index.ts
|
|
533
1014
|
var TelemetryEventTypes = {
|
|
534
1015
|
// Agent activity events (shown in web UI activity log)
|
|
@@ -536,7 +1017,10 @@ var TelemetryEventTypes = {
|
|
|
536
1017
|
AGENT_DISCONNECTED: "agent.disconnected",
|
|
537
1018
|
AGENT_MESSAGE_PROCESSING: "agent.message_processing",
|
|
538
1019
|
AGENT_MESSAGE_DONE: "agent.message_done",
|
|
539
|
-
AGENT_MESSAGE_FAILED: "agent.message_failed"
|
|
1020
|
+
AGENT_MESSAGE_FAILED: "agent.message_failed",
|
|
1021
|
+
// A `warn`/`error` runner-side log line forwarded server-side for
|
|
1022
|
+
// observability (issue #916) — see `apps/cli/src/lib/runner-activity-telemetry.ts`.
|
|
1023
|
+
RUNNER_ACTIVITY: "runner.activity"
|
|
540
1024
|
};
|
|
541
1025
|
|
|
542
1026
|
// ../../packages/types/src/tunnel/index.ts
|
|
@@ -591,6 +1075,13 @@ var isShuttingDown = false;
|
|
|
591
1075
|
var FLUSH_INTERVAL_MS = 5e3;
|
|
592
1076
|
var MAX_BUFFER_SIZE = 50;
|
|
593
1077
|
var FLUSH_TIMEOUT_MS = 3e3;
|
|
1078
|
+
var authProvider = null;
|
|
1079
|
+
function setTelemetryAuthProvider(provider) {
|
|
1080
|
+
authProvider = provider;
|
|
1081
|
+
}
|
|
1082
|
+
var FLUSH_FAILURE_LOG_INTERVAL_MS = 6e4;
|
|
1083
|
+
var lastFlushFailureLoggedAt = 0;
|
|
1084
|
+
var suppressedFlushFailureCount = 0;
|
|
594
1085
|
function logEvent(eventType, options = {}) {
|
|
595
1086
|
const event = {
|
|
596
1087
|
event_type: eventType,
|
|
@@ -625,9 +1116,16 @@ async function flushEvents() {
|
|
|
625
1116
|
flushTimeout = null;
|
|
626
1117
|
}
|
|
627
1118
|
try {
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
1119
|
+
const providerContext = authProvider?.();
|
|
1120
|
+
let authHeader;
|
|
1121
|
+
if (providerContext?.authHeader) {
|
|
1122
|
+
authHeader = providerContext.authHeader;
|
|
1123
|
+
} else {
|
|
1124
|
+
const credentials2 = await getToken();
|
|
1125
|
+
if (!credentials2) {
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
authHeader = `Bearer ${credentials2.token}`;
|
|
631
1129
|
}
|
|
632
1130
|
const apiUrl = getApiUrlConfig();
|
|
633
1131
|
const controller = new AbortController();
|
|
@@ -642,7 +1140,7 @@ async function flushEvents() {
|
|
|
642
1140
|
method: "POST",
|
|
643
1141
|
headers: {
|
|
644
1142
|
"Content-Type": "application/json",
|
|
645
|
-
Authorization:
|
|
1143
|
+
Authorization: authHeader
|
|
646
1144
|
},
|
|
647
1145
|
body: JSON.stringify(request),
|
|
648
1146
|
signal: controller.signal
|
|
@@ -654,8 +1152,15 @@ async function flushEvents() {
|
|
|
654
1152
|
clearTimeout(timeout);
|
|
655
1153
|
}
|
|
656
1154
|
} catch (error2) {
|
|
657
|
-
|
|
658
|
-
|
|
1155
|
+
const now = Date.now();
|
|
1156
|
+
if (now - lastFlushFailureLoggedAt >= FLUSH_FAILURE_LOG_INTERVAL_MS) {
|
|
1157
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
1158
|
+
const suffix = suppressedFlushFailureCount > 0 ? ` (${suppressedFlushFailureCount} more suppressed in the last ${FLUSH_FAILURE_LOG_INTERVAL_MS / 1e3}s)` : "";
|
|
1159
|
+
console.error(`Telemetry flush error: ${message}${suffix}`);
|
|
1160
|
+
lastFlushFailureLoggedAt = now;
|
|
1161
|
+
suppressedFlushFailureCount = 0;
|
|
1162
|
+
} else {
|
|
1163
|
+
suppressedFlushFailureCount++;
|
|
659
1164
|
}
|
|
660
1165
|
}
|
|
661
1166
|
}
|
|
@@ -724,47 +1229,67 @@ var EventTypes = {
|
|
|
724
1229
|
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
725
1230
|
};
|
|
726
1231
|
|
|
727
|
-
// src/lib/
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1232
|
+
// src/lib/runner-activity-telemetry.ts
|
|
1233
|
+
var FORWARDED_LEVELS = /* @__PURE__ */ new Set(["warn", "error"]);
|
|
1234
|
+
var SEVERITY_BY_LEVEL = {
|
|
1235
|
+
warn: "warning",
|
|
1236
|
+
error: "error"
|
|
1237
|
+
};
|
|
1238
|
+
var MAX_MESSAGE_LENGTH = 500;
|
|
1239
|
+
var TRUNCATION_MARKER = "\u2026";
|
|
1240
|
+
function redact(message) {
|
|
1241
|
+
return message.replace(/esk_[A-Za-z0-9_-]+/g, "esk_***").replace(/ct_[A-Za-z0-9_-]+/g, "ct_***").replace(/https?:\/\/\S+/g, "<url>");
|
|
1242
|
+
}
|
|
1243
|
+
function truncate(message) {
|
|
1244
|
+
if (message.length <= MAX_MESSAGE_LENGTH) return message;
|
|
1245
|
+
return message.slice(0, MAX_MESSAGE_LENGTH - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
1246
|
+
}
|
|
1247
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1248
|
+
var RATE_LIMIT_MAX_EVENTS = 30;
|
|
1249
|
+
var windowStartedAt = 0;
|
|
1250
|
+
var windowCount = 0;
|
|
1251
|
+
var windowDroppedCount = 0;
|
|
1252
|
+
function admitUnderRateLimit(now) {
|
|
1253
|
+
if (now - windowStartedAt >= RATE_LIMIT_WINDOW_MS) {
|
|
1254
|
+
if (windowDroppedCount > 0) {
|
|
1255
|
+
console.error(
|
|
1256
|
+
`[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)`
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
windowStartedAt = now;
|
|
1260
|
+
windowCount = 0;
|
|
1261
|
+
windowDroppedCount = 0;
|
|
745
1262
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}
|
|
1263
|
+
if (windowCount >= RATE_LIMIT_MAX_EVENTS) {
|
|
1264
|
+
windowDroppedCount++;
|
|
1265
|
+
if (windowDroppedCount === 1) {
|
|
1266
|
+
console.error(
|
|
1267
|
+
`[runner-activity-telemetry] rate cap reached (${RATE_LIMIT_MAX_EVENTS} per ${RATE_LIMIT_WINDOW_MS / 1e3}s) \u2014 dropping further entries this window`
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
753
1271
|
}
|
|
754
|
-
|
|
1272
|
+
windowCount++;
|
|
1273
|
+
return true;
|
|
755
1274
|
}
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
1275
|
+
function forwardRunnerActivity(entry, context) {
|
|
1276
|
+
try {
|
|
1277
|
+
if (!FORWARDED_LEVELS.has(entry.level)) return;
|
|
1278
|
+
if (!context.agentId || !context.authHeader) return;
|
|
1279
|
+
if (!admitUnderRateLimit(Date.now())) return;
|
|
1280
|
+
const rawMessage = entry.error ?? entry.message ?? "";
|
|
1281
|
+
const message = truncate(redact(rawMessage));
|
|
1282
|
+
logEvent(TelemetryEventTypes.RUNNER_ACTIVITY, {
|
|
1283
|
+
severity: SEVERITY_BY_LEVEL[entry.level],
|
|
1284
|
+
message,
|
|
1285
|
+
metadata: { source: "cli.run" },
|
|
1286
|
+
agentId: context.agentId
|
|
1287
|
+
});
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
console.error(
|
|
1290
|
+
`[runner-activity-telemetry] failed to forward runner activity: ${err instanceof Error ? err.message : String(err)}`
|
|
1291
|
+
);
|
|
759
1292
|
}
|
|
760
|
-
return `Bearer ${credentials2.token}`;
|
|
761
|
-
}
|
|
762
|
-
function isInteractive(jsonOutput) {
|
|
763
|
-
if (jsonOutput) return false;
|
|
764
|
-
if (process.env.CI) return false;
|
|
765
|
-
if (process.env.GITHUB_ACTIONS) return false;
|
|
766
|
-
if (!process.stdin.isTTY) return false;
|
|
767
|
-
return true;
|
|
768
1293
|
}
|
|
769
1294
|
|
|
770
1295
|
// src/lib/opencode/health.ts
|
|
@@ -1574,6 +2099,21 @@ function messageError(messages, userMessageId) {
|
|
|
1574
2099
|
}
|
|
1575
2100
|
return "The agent run failed.";
|
|
1576
2101
|
}
|
|
2102
|
+
function isAbortedTerminalReply(messages, userMessageId) {
|
|
2103
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2104
|
+
const error2 = errorOf(reply);
|
|
2105
|
+
if (error2 == null) return false;
|
|
2106
|
+
if (typeof error2 === "string") return error2.trim() === "Aborted";
|
|
2107
|
+
if (typeof error2 === "object") {
|
|
2108
|
+
const e = error2;
|
|
2109
|
+
if (e.name === "MessageAbortedError") return true;
|
|
2110
|
+
if (e.name === "AbortError") return true;
|
|
2111
|
+
const dataMessage = e.data?.message;
|
|
2112
|
+
const rendered = typeof dataMessage === "string" ? dataMessage : typeof e.message === "string" ? e.message : null;
|
|
2113
|
+
return rendered != null && rendered.trim() === "Aborted";
|
|
2114
|
+
}
|
|
2115
|
+
return false;
|
|
2116
|
+
}
|
|
1577
2117
|
function messageFailure(messages, userMessageId) {
|
|
1578
2118
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1579
2119
|
const error2 = errorOf(reply);
|
|
@@ -2150,13 +2690,44 @@ function writeTunnelReadyMarker(path, agentId) {
|
|
|
2150
2690
|
}
|
|
2151
2691
|
}
|
|
2152
2692
|
|
|
2693
|
+
// src/lib/claude-usage-reporting.ts
|
|
2694
|
+
var VALID_MODES = ["auto", "on", "off"];
|
|
2695
|
+
function resolveClaudeUsageReportingMode(flagValue, env) {
|
|
2696
|
+
const raw = flagValue ?? env.EVIDENT_CLAUDE_USAGE_REPORTING;
|
|
2697
|
+
if (raw === void 0 || raw === "") {
|
|
2698
|
+
return { mode: "auto", warnings: [] };
|
|
2699
|
+
}
|
|
2700
|
+
const normalized = raw.trim().toLowerCase();
|
|
2701
|
+
if (VALID_MODES.includes(normalized)) {
|
|
2702
|
+
return { mode: normalized, warnings: [] };
|
|
2703
|
+
}
|
|
2704
|
+
const source = flagValue !== void 0 ? "--claude-usage-reporting" : "EVIDENT_CLAUDE_USAGE_REPORTING";
|
|
2705
|
+
return {
|
|
2706
|
+
mode: "auto",
|
|
2707
|
+
warnings: [
|
|
2708
|
+
`Ignoring invalid ${source} "${raw}": expected one of ${VALID_MODES.join(", ")}; using auto`
|
|
2709
|
+
]
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
var BASE_REPORT_DELAY_MS = 10 * 6e4;
|
|
2713
|
+
var REPORT_DELAY_JITTER_FRACTION = 0.2;
|
|
2714
|
+
function nextReportDelayMs(random = Math.random) {
|
|
2715
|
+
const jitterRangeMs = BASE_REPORT_DELAY_MS * REPORT_DELAY_JITTER_FRACTION;
|
|
2716
|
+
return BASE_REPORT_DELAY_MS - jitterRangeMs + random() * (2 * jitterRangeMs);
|
|
2717
|
+
}
|
|
2718
|
+
var FIRST_REPORT_DELAY_MS = 5e3 + Math.random() * 1e4;
|
|
2719
|
+
var CLAUDE_USAGE_FAILURE_REESCALATION_TICKS = 6;
|
|
2720
|
+
function claudeUsageFailureLogLevel(consecutiveFailures) {
|
|
2721
|
+
return consecutiveFailures === 1 || consecutiveFailures % CLAUDE_USAGE_FAILURE_REESCALATION_TICKS === 0 ? "warn" : "debug";
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2153
2724
|
// src/lib/channels/driver.ts
|
|
2154
|
-
import { homedir } from "os";
|
|
2725
|
+
import { homedir as homedir2 } from "os";
|
|
2155
2726
|
|
|
2156
2727
|
// src/lib/file-push.ts
|
|
2157
2728
|
import { randomUUID } from "crypto";
|
|
2158
2729
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2159
|
-
import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
|
|
2730
|
+
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
|
|
2160
2731
|
var FILE_MODE = 384;
|
|
2161
2732
|
var DIRECTORY_MODE = 448;
|
|
2162
2733
|
async function writePushedFile(request) {
|
|
@@ -2189,7 +2760,7 @@ async function writePushedFile(request) {
|
|
|
2189
2760
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2190
2761
|
dirname2(candidate)
|
|
2191
2762
|
);
|
|
2192
|
-
const realTarget =
|
|
2763
|
+
const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
|
|
2193
2764
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2194
2765
|
if (allowedDirectory === null) {
|
|
2195
2766
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
@@ -2225,7 +2796,7 @@ function expandAndValidate(requestedPath, homeDir) {
|
|
|
2225
2796
|
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2226
2797
|
return null;
|
|
2227
2798
|
}
|
|
2228
|
-
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ?
|
|
2799
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2229
2800
|
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2230
2801
|
return null;
|
|
2231
2802
|
}
|
|
@@ -2298,13 +2869,13 @@ function contains(realDirectory, realTarget) {
|
|
|
2298
2869
|
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2299
2870
|
let current = existingAncestor;
|
|
2300
2871
|
for (const segment of missingSegments) {
|
|
2301
|
-
current =
|
|
2872
|
+
current = join2(current, segment);
|
|
2302
2873
|
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2303
2874
|
await chmod(current, DIRECTORY_MODE);
|
|
2304
2875
|
}
|
|
2305
2876
|
}
|
|
2306
2877
|
async function writeAtomically(realTarget, content) {
|
|
2307
|
-
const temporaryPath =
|
|
2878
|
+
const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2308
2879
|
let handle;
|
|
2309
2880
|
try {
|
|
2310
2881
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -2471,8 +3042,8 @@ async function applyOne(options, file) {
|
|
|
2471
3042
|
await ack(options, file, "applied");
|
|
2472
3043
|
return true;
|
|
2473
3044
|
}
|
|
2474
|
-
function durableDownloadCode(
|
|
2475
|
-
return
|
|
3045
|
+
function durableDownloadCode(status2) {
|
|
3046
|
+
return status2 === 413 ? "file_too_large" : "write_failed";
|
|
2476
3047
|
}
|
|
2477
3048
|
async function downloadContent(options, file, label) {
|
|
2478
3049
|
try {
|
|
@@ -2505,8 +3076,8 @@ async function downloadContent(options, file, label) {
|
|
|
2505
3076
|
return { ok: false, terminal: false };
|
|
2506
3077
|
}
|
|
2507
3078
|
}
|
|
2508
|
-
async function ack(options, file,
|
|
2509
|
-
const outcome = `${
|
|
3079
|
+
async function ack(options, file, status2, reason) {
|
|
3080
|
+
const outcome = `${status2}${reason ? ` (${reason})` : ""}`;
|
|
2510
3081
|
try {
|
|
2511
3082
|
const res = await options.fetchImpl(
|
|
2512
3083
|
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
@@ -2516,7 +3087,7 @@ async function ack(options, file, status, reason) {
|
|
|
2516
3087
|
Authorization: options.getAuthHeader(),
|
|
2517
3088
|
"Content-Type": "application/json"
|
|
2518
3089
|
},
|
|
2519
|
-
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
3090
|
+
body: JSON.stringify(reason ? { status: status2, reason } : { status: status2 })
|
|
2520
3091
|
}
|
|
2521
3092
|
);
|
|
2522
3093
|
if (!res.ok) {
|
|
@@ -2580,6 +3151,7 @@ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
|
2580
3151
|
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
2581
3152
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2582
3153
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
3154
|
+
var MAX_IDENTICAL_REDRIVE_POLL_FAILURES = 5;
|
|
2583
3155
|
var ChannelAuthError = class extends Error {
|
|
2584
3156
|
constructor(message) {
|
|
2585
3157
|
super(message);
|
|
@@ -2588,10 +3160,10 @@ var ChannelAuthError = class extends Error {
|
|
|
2588
3160
|
};
|
|
2589
3161
|
var ChannelTerminalError = class extends Error {
|
|
2590
3162
|
status;
|
|
2591
|
-
constructor(message,
|
|
3163
|
+
constructor(message, status2) {
|
|
2592
3164
|
super(message);
|
|
2593
3165
|
this.name = "ChannelTerminalError";
|
|
2594
|
-
this.status =
|
|
3166
|
+
this.status = status2;
|
|
2595
3167
|
}
|
|
2596
3168
|
};
|
|
2597
3169
|
function backoffDelay(attempt, policy) {
|
|
@@ -2599,8 +3171,12 @@ function backoffDelay(attempt, policy) {
|
|
|
2599
3171
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
2600
3172
|
return Math.floor(Math.random() * capped);
|
|
2601
3173
|
}
|
|
2602
|
-
function isRetryableStatus(
|
|
2603
|
-
return
|
|
3174
|
+
function isRetryableStatus(status2) {
|
|
3175
|
+
return status2 === 429 || status2 >= 500 && status2 <= 599;
|
|
3176
|
+
}
|
|
3177
|
+
var VOLATILE_BODY_FIELD_PATTERN = /("(?:ref|requestId|request_id|traceId|trace_id)"\s*:\s*)"[^"]*"/gi;
|
|
3178
|
+
function normalizeRedrivePollFailureBody(body) {
|
|
3179
|
+
return body.replace(VOLATILE_BODY_FIELD_PATTERN, '$1"<redacted>"').replace(/\s+/g, " ").trim().slice(0, 200);
|
|
2604
3180
|
}
|
|
2605
3181
|
var ChannelDriver = class _ChannelDriver {
|
|
2606
3182
|
agentId;
|
|
@@ -2618,6 +3194,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2618
3194
|
now;
|
|
2619
3195
|
fileSyncDirectories;
|
|
2620
3196
|
homeDir;
|
|
3197
|
+
maxActiveSessions;
|
|
2621
3198
|
/** Cache of conversationId → opencode sessionId. */
|
|
2622
3199
|
sessions = /* @__PURE__ */ new Map();
|
|
2623
3200
|
/**
|
|
@@ -2707,14 +3284,92 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2707
3284
|
*/
|
|
2708
3285
|
doneUndeliverable = /* @__PURE__ */ new Set();
|
|
2709
3286
|
/**
|
|
2710
|
-
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
2711
|
-
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
2712
|
-
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
2713
|
-
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
2714
|
-
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
2715
|
-
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
3287
|
+
* "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
|
|
3288
|
+
* unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
|
|
3289
|
+
* every ~2s drain until the status map becomes readable — but the server-visible
|
|
3290
|
+
* signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
|
|
3291
|
+
* (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
|
|
3292
|
+
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
3293
|
+
*/
|
|
3294
|
+
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
3295
|
+
/**
|
|
3296
|
+
* "Already emitted `redrive_unresolved` for this row" (#965). Mirrors
|
|
3297
|
+
* `readoptPollUnresolvedSignalled`: `resolveRedrive`'s `unresolved` leaf recurs
|
|
3298
|
+
* every ~2s drain until opencode's status becomes readable, but the
|
|
3299
|
+
* server-visible signal is an OUTCOME, so it fires at most once per row. Cleared
|
|
3300
|
+
* on any non-`unresolved` outcome so the set cannot grow beyond the currently
|
|
3301
|
+
* unresolvable rows.
|
|
3302
|
+
*/
|
|
3303
|
+
redriveUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
3304
|
+
/**
|
|
3305
|
+
* First `now()` a `pending` row's re-drive was observed `unresolved` (#965). A
|
|
3306
|
+
* `pending` row is invisible to every cron arm (all require `status =
|
|
3307
|
+
* 'processing'`), so an indefinitely-`unresolved` row would be stranded with
|
|
3308
|
+
* nothing driving it. Once `now - since >= pausedMaxWaitMs`, `resolveRedrive`
|
|
3309
|
+
* takes `dispatch` instead of `unresolved` (reusing the existing knob — see
|
|
3310
|
+
* ADR-0047's own "unreachable ⇒ bounded" rule). Cleared on any other outcome.
|
|
3311
|
+
*/
|
|
3312
|
+
redriveUnresolvedSince = /* @__PURE__ */ new Map();
|
|
3313
|
+
/**
|
|
3314
|
+
* Consecutive-identical-poll-failure streak for the re-drive fence (#1348),
|
|
3315
|
+
* keyed by Evident **message id** (not session) so `clearRedriveUnresolved`
|
|
3316
|
+
* can drop it with the other two trackers and it cannot leak. `sessionId` is
|
|
3317
|
+
* carried inside the entry, not the key: a session change is a different
|
|
3318
|
+
* situation and resets the streak, which gives the `(sessionId, message.id)`
|
|
3319
|
+
* pairing #1348 asks for without a composite map key.
|
|
3320
|
+
*/
|
|
3321
|
+
redrivePollFailures = /* @__PURE__ */ new Map();
|
|
3322
|
+
/**
|
|
3323
|
+
* "Already emitted `redrive_outcome_unreported` for THIS (message, outcome)
|
|
3324
|
+
* streak" (Class B, #1340: the runner DECIDED reattach/settle/fail_permanent
|
|
3325
|
+
* but its own PATCH to record it failed — distinct from Class A's
|
|
3326
|
+
* `redrive_poll_failed`, where opencode itself can't be observed). Keyed by
|
|
3327
|
+
* message id, valued by the outcome currently failing to report, so a
|
|
3328
|
+
* change of outcome starts a fresh signal. Cleared by
|
|
3329
|
+
* `clearRedriveUnresolved` the instant either PATCH succeeds.
|
|
3330
|
+
*/
|
|
3331
|
+
redriveOutcomeUnreportedSignalled = /* @__PURE__ */ new Map();
|
|
3332
|
+
/**
|
|
3333
|
+
* First `now()` a Class B outcome PATCH (reattach/settle/fail_permanent) was
|
|
3334
|
+
* observed to fail for this message (#1366's failure-window trip arm,
|
|
3335
|
+
* `boundRedriveOutcome`). Duration, not a tick count — bounded by the
|
|
3336
|
+
* existing `pausedMaxWaitMs` window (reusing the knob, not a new constant).
|
|
3337
|
+
* Cleared by `clearRedriveUnresolved` the instant the original PATCH
|
|
3338
|
+
* succeeds.
|
|
3339
|
+
*/
|
|
3340
|
+
redriveOutcomeFailingSince = /* @__PURE__ */ new Map();
|
|
3341
|
+
/**
|
|
3342
|
+
* "Already posted `redrive_outcome_abandoned` with `reported: false` for this
|
|
3343
|
+
* row" (#1366) — the bound tripped but the terminal `markFailed` fallback ALSO
|
|
3344
|
+
* failed (the route-level fault of G2), so every following tick re-attempts
|
|
3345
|
+
* the same terminal PATCH. Guards that quiet retry from re-signalling on
|
|
3346
|
+
* every tick. Cleared by `clearRedriveUnresolved`.
|
|
3347
|
+
*/
|
|
3348
|
+
redriveOutcomeAbandonedSignalled = /* @__PURE__ */ new Set();
|
|
3349
|
+
/**
|
|
3350
|
+
* "Already emitted `dispatch_not_started` for THIS (message, branch) streak"
|
|
3351
|
+
* (#1340). Valued by the branch currently firing, so a row that moves between
|
|
3352
|
+
* exits re-signals — the move IS the finding. Cleared only on a CONFIRMED
|
|
3353
|
+
* dispatch, never on the fence's decision to dispatch: `clearRedriveUnresolved`
|
|
3354
|
+
* runs on that decision (`resolveRedriveUnresolved`), so clearing there would
|
|
3355
|
+
* re-signal on every one of the 15h of re-dispatch attempts #1110 made.
|
|
3356
|
+
*/
|
|
3357
|
+
dispatchNotStartedSignalled = /* @__PURE__ */ new Map();
|
|
3358
|
+
/**
|
|
3359
|
+
* Consecutive-UNCONFIRMED-dispatch streak for a `pending` row with NO stored
|
|
3360
|
+
* `opencode_message_id` yet — i.e. one that has never even reached the
|
|
3361
|
+
* re-drive fence above. `sendPromptAsync`'s POST may 2xx, but its own
|
|
3362
|
+
* read-back retries can never confirm the assigned id when the session's
|
|
3363
|
+
* message list is PERMANENTLY unreadable (e.g. a corrupted local opencode
|
|
3364
|
+
* SQLite DB, #1345/#1348's exact fault, just hit BEFORE the row is ever
|
|
3365
|
+
* dispatched instead of after). Unlike an already-dispatched row, THIS row has
|
|
3366
|
+
* no other safety net at all: the lifecycle cron only reclaims `status =
|
|
3367
|
+
* 'processing'` rows, and a row stuck here never reaches `processing`. Keyed
|
|
3368
|
+
* by message id, carrying `sessionId` so a session change (a fresh one bound
|
|
3369
|
+
* after abandonment) starts a new streak rather than inheriting the old
|
|
3370
|
+
* session's count — same shape as `redrivePollFailures` above.
|
|
2716
3371
|
*/
|
|
2717
|
-
|
|
3372
|
+
unconfirmedDispatchFailures = /* @__PURE__ */ new Map();
|
|
2718
3373
|
/**
|
|
2719
3374
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
2720
3375
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -2803,23 +3458,24 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2803
3458
|
* and stops opencode.
|
|
2804
3459
|
*/
|
|
2805
3460
|
stopped = false;
|
|
2806
|
-
constructor(
|
|
2807
|
-
this.agentId =
|
|
2808
|
-
this.port =
|
|
2809
|
-
this.apiUrl =
|
|
2810
|
-
this.getAuthHeader =
|
|
2811
|
-
this.conversationFilter =
|
|
2812
|
-
this.retry = { ...DEFAULT_RETRY_POLICY, ...
|
|
2813
|
-
this.log =
|
|
3461
|
+
constructor(config) {
|
|
3462
|
+
this.agentId = config.agentId;
|
|
3463
|
+
this.port = config.port;
|
|
3464
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, "");
|
|
3465
|
+
this.getAuthHeader = config.getAuthHeader;
|
|
3466
|
+
this.conversationFilter = config.conversationFilter ?? null;
|
|
3467
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config.retry };
|
|
3468
|
+
this.log = config.log ?? (() => {
|
|
2814
3469
|
});
|
|
2815
|
-
this.fetchImpl =
|
|
2816
|
-
this.sleep =
|
|
2817
|
-
this.pausedPollIntervalMs =
|
|
2818
|
-
this.pausedMaxWaitMs =
|
|
2819
|
-
this.stuckQueuedMs =
|
|
2820
|
-
this.now =
|
|
2821
|
-
this.fileSyncDirectories =
|
|
2822
|
-
this.homeDir =
|
|
3470
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
3471
|
+
this.sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3472
|
+
this.pausedPollIntervalMs = config.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
3473
|
+
this.pausedMaxWaitMs = config.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
3474
|
+
this.stuckQueuedMs = config.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
3475
|
+
this.now = config.now ?? (() => Date.now());
|
|
3476
|
+
this.fileSyncDirectories = config.fileSyncDirectories ?? [];
|
|
3477
|
+
this.homeDir = config.homeDir ?? homedir2();
|
|
3478
|
+
this.maxActiveSessions = config.maxActiveSessions;
|
|
2823
3479
|
}
|
|
2824
3480
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
2825
3481
|
get opencodeBase() {
|
|
@@ -2899,10 +3555,26 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2899
3555
|
message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
|
|
2900
3556
|
});
|
|
2901
3557
|
}
|
|
3558
|
+
let cappedSkips = 0;
|
|
2902
3559
|
for (const conv of conversations) {
|
|
2903
3560
|
if (this.stopped) break;
|
|
3561
|
+
if (this.maxActiveSessions !== void 0) {
|
|
3562
|
+
const activeSessionIds = this.activeSessionIdsForCap();
|
|
3563
|
+
const resolvedSessionId = this.sessions.get(conv.id) ?? conv.opencode_session_id;
|
|
3564
|
+
const alreadyActive = resolvedSessionId != null && activeSessionIds.has(resolvedSessionId);
|
|
3565
|
+
if (activeSessionIds.size >= this.maxActiveSessions && !alreadyActive) {
|
|
3566
|
+
cappedSkips++;
|
|
3567
|
+
continue;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
2904
3570
|
dispatched += await this.processConversation(conv);
|
|
2905
3571
|
}
|
|
3572
|
+
if (cappedSkips > 0) {
|
|
3573
|
+
this.log({
|
|
3574
|
+
level: "warn",
|
|
3575
|
+
message: `max-active-sessions cap (${this.maxActiveSessions}) reached \u2014 skipped ${cappedSkips} pending conversation(s) this tick`
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
2906
3578
|
await this.readoptProcessing();
|
|
2907
3579
|
} finally {
|
|
2908
3580
|
this.draining = false;
|
|
@@ -2921,6 +3593,22 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2921
3593
|
}
|
|
2922
3594
|
return false;
|
|
2923
3595
|
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Session ids active *for the `--max-active-sessions` cap*: in-flight work AND
|
|
3598
|
+
* a live watcher loop. Unlike `hasInFlightWatchers()` / `protectedSessionIds()`,
|
|
3599
|
+
* a ZOMBIE watcher (in-flight but `loop === null`, left by a non-auth failure
|
|
3600
|
+
* inside `runWatcherLoop`) does not count here — under a cap it would
|
|
3601
|
+
* permanently consume a slot, whereas cleanup/idle-exit should still treat it
|
|
3602
|
+
* as protected. One call per drain iteration serves both the cap check
|
|
3603
|
+
* (`.size`) and the already-active exemption (`.has`).
|
|
3604
|
+
*/
|
|
3605
|
+
activeSessionIdsForCap() {
|
|
3606
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3607
|
+
for (const [sessionId, watcher] of this.watchers) {
|
|
3608
|
+
if (watcher.inFlight.size > 0 && watcher.loop !== null) ids.add(sessionId);
|
|
3609
|
+
}
|
|
3610
|
+
return ids;
|
|
3611
|
+
}
|
|
2924
3612
|
/**
|
|
2925
3613
|
* File-pull work, for `run.ts`'s idle accounting (#559).
|
|
2926
3614
|
*
|
|
@@ -3039,7 +3727,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3039
3727
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
3040
3728
|
*/
|
|
3041
3729
|
async processConversation(conv) {
|
|
3042
|
-
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
3730
|
+
const { sessionId, refusedSessionId, created: sessionCreated } = await this.ensureSession(conv);
|
|
3043
3731
|
const messages = await this.getPendingMessages(conv.id);
|
|
3044
3732
|
let dispatched = 0;
|
|
3045
3733
|
let skippedAlreadyDispatched = 0;
|
|
@@ -3054,6 +3742,15 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3054
3742
|
skippedAlreadyDispatched += 1;
|
|
3055
3743
|
continue;
|
|
3056
3744
|
}
|
|
3745
|
+
if (message.opencode_message_id) {
|
|
3746
|
+
const outcome = await this.resolveRedrive(conv, sessionId, message, sessionCreated);
|
|
3747
|
+
if (outcome === "abandoned") {
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
if (outcome !== "dispatch") {
|
|
3751
|
+
break;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3057
3754
|
const options = {
|
|
3058
3755
|
agent: message.opencode_agent ?? void 0,
|
|
3059
3756
|
model: message.opencode_model ?? void 0
|
|
@@ -3083,6 +3780,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3083
3780
|
conversation_id: conv.id,
|
|
3084
3781
|
message_id: message.id
|
|
3085
3782
|
});
|
|
3783
|
+
this.signalDispatchNotStarted(conv, message, "session_deleted_race");
|
|
3086
3784
|
break;
|
|
3087
3785
|
}
|
|
3088
3786
|
if (exists === null) {
|
|
@@ -3092,6 +3790,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3092
3790
|
conversation_id: conv.id,
|
|
3093
3791
|
message_id: message.id
|
|
3094
3792
|
});
|
|
3793
|
+
this.signalDispatchNotStarted(conv, message, "session_existence_unknown");
|
|
3095
3794
|
break;
|
|
3096
3795
|
}
|
|
3097
3796
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
@@ -3110,6 +3809,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3110
3809
|
conversation_id: conv.id,
|
|
3111
3810
|
message_id: message.id
|
|
3112
3811
|
});
|
|
3812
|
+
this.signalDispatchNotStarted(conv, message, "failure_unreported");
|
|
3113
3813
|
});
|
|
3114
3814
|
this.log({
|
|
3115
3815
|
level: "error",
|
|
@@ -3120,14 +3820,40 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3120
3820
|
break;
|
|
3121
3821
|
}
|
|
3122
3822
|
if (opencodeMessageId === null) {
|
|
3823
|
+
const streak = this.recordUnconfirmedDispatch(message.id, sessionId);
|
|
3824
|
+
if (streak < MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
|
|
3825
|
+
this.log({
|
|
3826
|
+
level: "warn",
|
|
3827
|
+
message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next tick`,
|
|
3828
|
+
conversation_id: conv.id,
|
|
3829
|
+
message_id: message.id
|
|
3830
|
+
});
|
|
3831
|
+
this.signalDispatchNotStarted(conv, message, "readback_unconfirmed");
|
|
3832
|
+
continue;
|
|
3833
|
+
}
|
|
3834
|
+
this.unconfirmedDispatchFailures.delete(message.id);
|
|
3835
|
+
this.sessions.delete(conv.id);
|
|
3836
|
+
this.supersede(conv.id, sessionId);
|
|
3837
|
+
const errorMessage = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
|
|
3123
3838
|
this.log({
|
|
3124
|
-
level: "
|
|
3125
|
-
message:
|
|
3839
|
+
level: "error",
|
|
3840
|
+
message: errorMessage,
|
|
3126
3841
|
conversation_id: conv.id,
|
|
3127
3842
|
message_id: message.id
|
|
3128
3843
|
});
|
|
3129
|
-
|
|
3844
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
3845
|
+
this.log({
|
|
3846
|
+
level: "warn",
|
|
3847
|
+
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)}`,
|
|
3848
|
+
conversation_id: conv.id,
|
|
3849
|
+
message_id: message.id
|
|
3850
|
+
});
|
|
3851
|
+
this.signalDispatchNotStarted(conv, message, "abandon_unreported");
|
|
3852
|
+
});
|
|
3853
|
+
break;
|
|
3130
3854
|
}
|
|
3855
|
+
this.unconfirmedDispatchFailures.delete(message.id);
|
|
3856
|
+
this.dispatchNotStartedSignalled.delete(message.id);
|
|
3131
3857
|
this.dispatched.add(message.id);
|
|
3132
3858
|
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
3133
3859
|
dispatched += 1;
|
|
@@ -3143,6 +3869,451 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3143
3869
|
this.ensureWatcherRunning(sessionId);
|
|
3144
3870
|
return dispatched;
|
|
3145
3871
|
}
|
|
3872
|
+
/**
|
|
3873
|
+
* Poll a session's message list for the re-drive fence (#965), via the
|
|
3874
|
+
* INJECTED `fetchImpl` — NOT the imported `getSessionMessages` helper, which
|
|
3875
|
+
* hits the global `fetch` and would bypass the same override every other
|
|
3876
|
+
* opencode poll in this file respects. Mirrors `readoptProcessing`'s own
|
|
3877
|
+
* snapshot fetch (`:3081-3111`).
|
|
3878
|
+
*
|
|
3879
|
+
* Returns `{ ok: true, messages }` on a readable snapshot, or
|
|
3880
|
+
* `{ ok: false, signature }` on failure — `signature` is a string that
|
|
3881
|
+
* repeats across attempts for the SAME underlying fault (used by the
|
|
3882
|
+
* consecutive-identical-failure bound, #1348), or `null` for a thrown
|
|
3883
|
+
* exception, which is NOT countable toward that bound (a network blip / an
|
|
3884
|
+
* opencode restart also throws identically every tick, and must keep
|
|
3885
|
+
* retrying unbounded rather than ever being treated as permanent).
|
|
3886
|
+
*/
|
|
3887
|
+
async pollSessionMessagesForRedrive(conv, message, sessionId) {
|
|
3888
|
+
try {
|
|
3889
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
3890
|
+
if (!res.ok) {
|
|
3891
|
+
const rawBody = await res.text();
|
|
3892
|
+
const normalized = normalizeRedrivePollFailureBody(rawBody);
|
|
3893
|
+
this.log({
|
|
3894
|
+
level: "warn",
|
|
3895
|
+
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status}${normalized ? `: ${normalized}` : ""} \u2014 treating as unreadable this tick`,
|
|
3896
|
+
conversation_id: conv.id,
|
|
3897
|
+
message_id: message.id
|
|
3898
|
+
});
|
|
3899
|
+
return { ok: false, signature: `HTTP ${res.status}${normalized ? `: ${normalized}` : ""}` };
|
|
3900
|
+
}
|
|
3901
|
+
const body = await res.json();
|
|
3902
|
+
if (!Array.isArray(body)) {
|
|
3903
|
+
this.log({
|
|
3904
|
+
level: "warn",
|
|
3905
|
+
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned a non-array message body \u2014 treating as unreadable this tick`,
|
|
3906
|
+
conversation_id: conv.id,
|
|
3907
|
+
message_id: message.id
|
|
3908
|
+
});
|
|
3909
|
+
return { ok: false, signature: "non-array message body" };
|
|
3910
|
+
}
|
|
3911
|
+
return { ok: true, messages: body };
|
|
3912
|
+
} catch (err) {
|
|
3913
|
+
this.log({
|
|
3914
|
+
level: "warn",
|
|
3915
|
+
message: `Re-drive: failed to poll session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3916
|
+
conversation_id: conv.id,
|
|
3917
|
+
message_id: message.id
|
|
3918
|
+
});
|
|
3919
|
+
return { ok: false, signature: null };
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* The re-drive fence for a `pending` row that already carries a stored
|
|
3924
|
+
* `opencode_message_id` (#965) — i.e. it has already been handed to opencode at
|
|
3925
|
+
* least once (see the invariant at `QueuedMessage.opencode_message_id`'s doc).
|
|
3926
|
+
* The lifecycle cron can falsely reclaim a `processing` row back to `pending`
|
|
3927
|
+
* mid-turn (a 5-minute liveness-staleness check racing a still-running turn);
|
|
3928
|
+
* without this fence the drain loop would re-`prompt_async` the SAME turn a
|
|
3929
|
+
* second time against live GitHub state. Mirrors `readoptOne`'s job for the
|
|
3930
|
+
* `processing` re-adopt path, but simpler: no b1/b2 preamble cross-check is
|
|
3931
|
+
* needed here because `sessionCreated` already handles the cases (a #553
|
|
3932
|
+
* abandoned session, a #190 vanished one) that path exists for.
|
|
3933
|
+
*
|
|
3934
|
+
* Only `ChannelAuthError` propagates. A poll that fails identically
|
|
3935
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row reports the message
|
|
3936
|
+
* failed instead of retrying it (#1348) — SEPARATE from, not a replacement
|
|
3937
|
+
* for, `resolveRedriveUnresolved`'s own `pausedMaxWaitMs` bound below. Every
|
|
3938
|
+
* other failure resolves to `unresolved` and is retried whole on the next
|
|
3939
|
+
* ~2s drain tick.
|
|
3940
|
+
*/
|
|
3941
|
+
async resolveRedrive(conv, sessionId, message, sessionCreated) {
|
|
3942
|
+
const ocId = message.opencode_message_id ?? null;
|
|
3943
|
+
if (sessionCreated) {
|
|
3944
|
+
this.clearRedriveUnresolved(message.id);
|
|
3945
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3946
|
+
return "dispatch";
|
|
3947
|
+
}
|
|
3948
|
+
const polled = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
|
|
3949
|
+
if (!polled.ok) {
|
|
3950
|
+
const streak = this.recordRedrivePollFailure(message.id, sessionId, polled.signature);
|
|
3951
|
+
if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES && polled.signature !== null) {
|
|
3952
|
+
return this.failRedrivePollPermanent(conv, sessionId, message, polled.signature, streak);
|
|
3953
|
+
}
|
|
3954
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3955
|
+
}
|
|
3956
|
+
this.redrivePollFailures.delete(message.id);
|
|
3957
|
+
const messages = polled.messages;
|
|
3958
|
+
if (messages.length === 0) {
|
|
3959
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3960
|
+
}
|
|
3961
|
+
const state = messageRunState(messages, ocId ?? "");
|
|
3962
|
+
if (state === "failed" && isAbortedTerminalReply(messages, ocId ?? "")) {
|
|
3963
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
3964
|
+
if (ongoing === false) {
|
|
3965
|
+
this.log({
|
|
3966
|
+
level: "info",
|
|
3967
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
3968
|
+
conversation_id: conv.id,
|
|
3969
|
+
message_id: message.id
|
|
3970
|
+
});
|
|
3971
|
+
this.clearRedriveUnresolved(message.id);
|
|
3972
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3973
|
+
return "dispatch";
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
if (state === "done" || state === "failed") {
|
|
3977
|
+
return this.settleRedrive(conv, sessionId, message, ocId, messages, state);
|
|
3978
|
+
}
|
|
3979
|
+
if (state === "running" || state === "queued") {
|
|
3980
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
3981
|
+
if (ongoing === true) {
|
|
3982
|
+
return this.reattachRedrive(conv, sessionId, message, ocId);
|
|
3983
|
+
}
|
|
3984
|
+
if (ongoing === false) {
|
|
3985
|
+
this.clearRedriveUnresolved(message.id);
|
|
3986
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3987
|
+
return "dispatch";
|
|
3988
|
+
}
|
|
3989
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3990
|
+
}
|
|
3991
|
+
this.clearRedriveUnresolved(message.id);
|
|
3992
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3993
|
+
return "dispatch";
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3996
|
+
* The `reattached` outcome (Task 3.3): the prior turn is STILL ONGOING per
|
|
3997
|
+
* opencode's own status map — undo the false reclaim instead of starting a
|
|
3998
|
+
* second turn.
|
|
3999
|
+
*/
|
|
4000
|
+
async reattachRedrive(conv, sessionId, message, ocId) {
|
|
4001
|
+
let anchorMs;
|
|
4002
|
+
const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
|
|
4003
|
+
if (!Number.isNaN(parsed)) {
|
|
4004
|
+
anchorMs = parsed;
|
|
4005
|
+
} else {
|
|
4006
|
+
anchorMs = this.now();
|
|
4007
|
+
this.log({
|
|
4008
|
+
level: "error",
|
|
4009
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} has null/unparseable processing_started_at (${String(message.processing_started_at)}) \u2014 anchoring the watcher's absolute-age ceiling to now (defensive)`,
|
|
4010
|
+
conversation_id: conv.id,
|
|
4011
|
+
message_id: message.id
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
const title = await this.resolveSessionTitle(sessionId, conv.id);
|
|
4015
|
+
try {
|
|
4016
|
+
await this.markProcessing(conv.id, message.id, sessionId, ocId, title);
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4019
|
+
if (err instanceof ChannelTerminalError) {
|
|
4020
|
+
this.log({
|
|
4021
|
+
level: "error",
|
|
4022
|
+
message: `Re-drive: the server definitively refused to restore message ${message.id.slice(0, 8)} to processing (terminal HTTP ${err.status} \u2014 the row is gone or the update was rejected); NOT reporting a re-attach`,
|
|
4023
|
+
conversation_id: conv.id,
|
|
4024
|
+
message_id: message.id
|
|
4025
|
+
});
|
|
4026
|
+
} else {
|
|
4027
|
+
this.log({
|
|
4028
|
+
level: "warn",
|
|
4029
|
+
message: `Re-drive: failed to restore message ${message.id.slice(0, 8)} to processing (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4030
|
+
conversation_id: conv.id,
|
|
4031
|
+
message_id: message.id
|
|
4032
|
+
});
|
|
4033
|
+
}
|
|
4034
|
+
const bound = await this.boundRedriveOutcome(conv, message, "reattach");
|
|
4035
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
4036
|
+
}
|
|
4037
|
+
this.clearRedriveUnresolved(message.id);
|
|
4038
|
+
this.registerReadopted(conv, sessionId, message, ocId ?? "", anchorMs);
|
|
4039
|
+
this.dispatched.add(message.id);
|
|
4040
|
+
this.readopted.add(message.id);
|
|
4041
|
+
this.ensureWatcherRunning(sessionId);
|
|
4042
|
+
const watchedForMs = this.now() - anchorMs;
|
|
4043
|
+
void this.postSignal(conv.id, message.id, "redrive_reattached", {
|
|
4044
|
+
watched_for_ms: watchedForMs
|
|
4045
|
+
});
|
|
4046
|
+
this.log({
|
|
4047
|
+
level: "warn",
|
|
4048
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) was wrongly reclaimed to pending while its turn was still running (watched ${watchedForMs}ms) \u2014 restored to processing instead of re-dispatching`,
|
|
4049
|
+
conversation_id: conv.id,
|
|
4050
|
+
message_id: message.id
|
|
4051
|
+
});
|
|
4052
|
+
return "reattached";
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
4055
|
+
* The `settled` outcome (Task 3.2): the prior turn already finished (or
|
|
4056
|
+
* errored) while nobody was watching — deliver/report it instead of re-running.
|
|
4057
|
+
* Mirrors `readoptOne`'s `done`/`failed` branches' error discipline, simplified
|
|
4058
|
+
* (no `doneUndeliverable` park: a terminal PATCH failure here just retries next
|
|
4059
|
+
* drain, same as any other non-auth failure). The restart-abort carve-out that
|
|
4060
|
+
* keeps the two in step for `failed` lives in the caller (`resolveRedrive`, #1310),
|
|
4061
|
+
* so a row reaching this `failed` branch is a GENUINE failure.
|
|
4062
|
+
*/
|
|
4063
|
+
async settleRedrive(conv, sessionId, message, ocId, messages, state) {
|
|
4064
|
+
try {
|
|
4065
|
+
if (state === "done") {
|
|
4066
|
+
const title = await this.resolveSessionTitle(sessionId, conv.id);
|
|
4067
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
4068
|
+
this.log({
|
|
4069
|
+
level: "info",
|
|
4070
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} completed while its row was wrongly reclaimed to pending \u2014 marking done instead of re-dispatching`,
|
|
4071
|
+
conversation_id: conv.id,
|
|
4072
|
+
message_id: message.id
|
|
4073
|
+
});
|
|
4074
|
+
await this.markDone(conv.id, message.id, sessionId, ocId, title, usage);
|
|
4075
|
+
} else {
|
|
4076
|
+
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
4077
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
4078
|
+
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
4079
|
+
this.log({
|
|
4080
|
+
level: "error",
|
|
4081
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} errored while its row was wrongly reclaimed to pending \u2014 marking failed instead of re-dispatching: ${error2 ?? "(no error text)"}`,
|
|
4082
|
+
conversation_id: conv.id,
|
|
4083
|
+
message_id: message.id
|
|
4084
|
+
});
|
|
4085
|
+
await this.markFailed(conv.id, message.id, sessionId, error2, usage, failure);
|
|
4086
|
+
}
|
|
4087
|
+
} catch (err) {
|
|
4088
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4089
|
+
this.log({
|
|
4090
|
+
level: "warn",
|
|
4091
|
+
message: `Re-drive: failed to report message ${message.id.slice(0, 8)} ${state} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4092
|
+
conversation_id: conv.id,
|
|
4093
|
+
message_id: message.id
|
|
4094
|
+
});
|
|
4095
|
+
const bound = await this.boundRedriveOutcome(conv, message, "settle");
|
|
4096
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
4097
|
+
}
|
|
4098
|
+
this.clearRedriveUnresolved(message.id);
|
|
4099
|
+
void this.postSignal(conv.id, message.id, "redrive_settled");
|
|
4100
|
+
return "settled";
|
|
4101
|
+
}
|
|
4102
|
+
/**
|
|
4103
|
+
* The permanent-failure outcome (#1348): the fence's own poll of this session
|
|
4104
|
+
* failed with the SAME opencode-answered signature
|
|
4105
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row — a transient blip
|
|
4106
|
+
* would have varied or eventually cleared (see `pollSessionMessagesForRedrive`
|
|
4107
|
+
* and `recordRedrivePollFailure`), so this is a durable fault (e.g. #1345's
|
|
4108
|
+
* corrupted opencode session) rather than something worth retrying forever.
|
|
4109
|
+
* Mirrors `settleRedrive`'s error discipline: no `usage`/`failure` args to
|
|
4110
|
+
* `markFailed` (no opencode snapshot to extract them from — this poll never
|
|
4111
|
+
* got a readable one).
|
|
4112
|
+
*/
|
|
4113
|
+
async failRedrivePollPermanent(conv, sessionId, message, signature, streak) {
|
|
4114
|
+
this.log({
|
|
4115
|
+
level: "error",
|
|
4116
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) failed to poll with the identical signature "${signature}" ${streak} times in a row \u2014 reporting the message failed instead of retrying forever`,
|
|
4117
|
+
conversation_id: conv.id,
|
|
4118
|
+
message_id: message.id
|
|
4119
|
+
});
|
|
4120
|
+
try {
|
|
4121
|
+
await this.markFailed(
|
|
4122
|
+
conv.id,
|
|
4123
|
+
message.id,
|
|
4124
|
+
sessionId,
|
|
4125
|
+
`The runner could not read this conversation's state from OpenCode (${signature}). The same failure repeated ${streak} times in a row, so the message was not retried further.`
|
|
4126
|
+
);
|
|
4127
|
+
} catch (err) {
|
|
4128
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4129
|
+
this.log({
|
|
4130
|
+
level: "warn",
|
|
4131
|
+
message: `Re-drive: failed to report message ${message.id.slice(0, 8)} permanently failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4132
|
+
conversation_id: conv.id,
|
|
4133
|
+
message_id: message.id
|
|
4134
|
+
});
|
|
4135
|
+
const bound = await this.boundRedriveOutcome(conv, message, "fail_permanent");
|
|
4136
|
+
return bound === "abandoned" ? "abandoned" : "unresolved";
|
|
4137
|
+
}
|
|
4138
|
+
this.clearRedriveUnresolved(message.id);
|
|
4139
|
+
void this.postSignal(conv.id, message.id, "redrive_poll_failed");
|
|
4140
|
+
return "settled";
|
|
4141
|
+
}
|
|
4142
|
+
/**
|
|
4143
|
+
* The bounded `unresolved` outcome (Task 3.4): opencode's state could not be
|
|
4144
|
+
* observed (snapshot unreadable/empty, or `isSessionOngoing` returned `null`).
|
|
4145
|
+
* A `pending` row is swept by the server's own `PENDING_MAX_AGE_MS` (24h,
|
|
4146
|
+
* #1368) cron arm, but that is a day-scale backstop — this local bound acts
|
|
4147
|
+
* in minutes so the row (and the conversation it starves, per the ordering
|
|
4148
|
+
* invariant below) isn't left stranded for that long. Bound to the existing
|
|
4149
|
+
* `pausedMaxWaitMs` window (reusing the knob, not a new constant); takes
|
|
4150
|
+
* `dispatch` once elapsed.
|
|
4151
|
+
*/
|
|
4152
|
+
resolveRedriveUnresolved(conv, message) {
|
|
4153
|
+
const now = this.now();
|
|
4154
|
+
const since = this.redriveUnresolvedSince.get(message.id);
|
|
4155
|
+
if (since !== void 0 && now - since >= this.pausedMaxWaitMs) {
|
|
4156
|
+
this.clearRedriveUnresolved(message.id);
|
|
4157
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
4158
|
+
return "dispatch";
|
|
4159
|
+
}
|
|
4160
|
+
if (since === void 0) {
|
|
4161
|
+
this.redriveUnresolvedSince.set(message.id, now);
|
|
4162
|
+
}
|
|
4163
|
+
if (!this.redriveUnresolvedSignalled.has(message.id)) {
|
|
4164
|
+
this.redriveUnresolvedSignalled.add(message.id);
|
|
4165
|
+
void this.postSignal(conv.id, message.id, "redrive_unresolved");
|
|
4166
|
+
}
|
|
4167
|
+
return "unresolved";
|
|
4168
|
+
}
|
|
4169
|
+
/** Clear all `unresolved`/failure-streak trackers for a row (any non-`unresolved` outcome). */
|
|
4170
|
+
clearRedriveUnresolved(messageId) {
|
|
4171
|
+
this.redriveUnresolvedSince.delete(messageId);
|
|
4172
|
+
this.redriveUnresolvedSignalled.delete(messageId);
|
|
4173
|
+
this.redrivePollFailures.delete(messageId);
|
|
4174
|
+
this.redriveOutcomeUnreportedSignalled.delete(messageId);
|
|
4175
|
+
this.redriveOutcomeFailingSince.delete(messageId);
|
|
4176
|
+
this.redriveOutcomeAbandonedSignalled.delete(messageId);
|
|
4177
|
+
}
|
|
4178
|
+
/**
|
|
4179
|
+
* #1340: the dispatch loop reached a message and did NOT start a turn. Fires at
|
|
4180
|
+
* most once per (message, branch) streak — a wedged row is re-tried every tick,
|
|
4181
|
+
* and the per-tick count is already carried by the co-occurring
|
|
4182
|
+
* `redrive_unresolved`/`redrive_redispatched` signals.
|
|
4183
|
+
*/
|
|
4184
|
+
signalDispatchNotStarted(conv, message, branch) {
|
|
4185
|
+
if (this.dispatchNotStartedSignalled.get(message.id) === branch) return;
|
|
4186
|
+
this.dispatchNotStartedSignalled.set(message.id, branch);
|
|
4187
|
+
void this.postSignal(conv.id, message.id, "dispatch_not_started", { branch });
|
|
4188
|
+
}
|
|
4189
|
+
/**
|
|
4190
|
+
* Class B (#1340): the runner DECIDED an outcome (reattach/settle/fail_permanent)
|
|
4191
|
+
* but its own PATCH to record it failed. Fires at most once per (message,
|
|
4192
|
+
* outcome) streak, and only while `boundRedriveOutcome` has not yet tripped —
|
|
4193
|
+
* once it trips, `redrive_outcome_abandoned` takes over reporting for the row
|
|
4194
|
+
* (#1366).
|
|
4195
|
+
*/
|
|
4196
|
+
signalRedriveOutcomeUnreported(conv, message, outcome) {
|
|
4197
|
+
if (this.redriveOutcomeUnreportedSignalled.get(message.id) === outcome) return;
|
|
4198
|
+
this.redriveOutcomeUnreportedSignalled.set(message.id, outcome);
|
|
4199
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_unreported", {
|
|
4200
|
+
attempted_outcome: outcome
|
|
4201
|
+
});
|
|
4202
|
+
}
|
|
4203
|
+
/**
|
|
4204
|
+
* The runner-authored, honest error text for the terminal fallback a tripped
|
|
4205
|
+
* `boundRedriveOutcome` sends. Distinguishable per outcome and truthful about
|
|
4206
|
+
* what actually happened — the `settle`/done case must say the turn finished
|
|
4207
|
+
* but its result could not be recorded, never that the runner stopped
|
|
4208
|
+
* responding (that would be a lie for this shape, see #1366's "why this ships").
|
|
4209
|
+
*/
|
|
4210
|
+
static REDRIVE_ABANDON_ERROR = {
|
|
4211
|
+
reattach: "your runner could not record that this message had started, so it was given up on",
|
|
4212
|
+
settle: "your runner finished this message but could not record the result, so the reply could not be delivered",
|
|
4213
|
+
fail_permanent: "the runner could not read this conversation's state from OpenCode, and could not record that failure either, so the message was given up on"
|
|
4214
|
+
};
|
|
4215
|
+
/**
|
|
4216
|
+
* Bound for Class B (#1340, #1366): the runner DECIDED an outcome but its own
|
|
4217
|
+
* PATCH to record it failed. Two independent trip arms (either sufficient):
|
|
4218
|
+
* (1) this failure streak has lasted `pausedMaxWaitMs` — DURATION, not a tick
|
|
4219
|
+
* count, reusing the knob `resolveRedriveUnresolved` already established; (2)
|
|
4220
|
+
* the turn's `processing_started_at` age has crossed
|
|
4221
|
+
* `ABSOLUTE_MAX_PROCESSING_MS` — durable and restart-surviving, since arm (1)'s
|
|
4222
|
+
* in-memory streak resets on a scale-to-zero restart.
|
|
4223
|
+
*
|
|
4224
|
+
* INVARIANT — a tripped bound never suppresses the original outcome attempt;
|
|
4225
|
+
* it only adds a fallback after that attempt has failed again. This is only
|
|
4226
|
+
* ever reached from inside the catch of the ORIGINAL outcome PATCH, which is
|
|
4227
|
+
* attempted first on every tick whether or not this bound tripped before —
|
|
4228
|
+
* there is no give-up latch that would short-circuit it. That is what lets a
|
|
4229
|
+
* route-level fault that heals later still deliver the turn's real
|
|
4230
|
+
* `done`/`failed` payload: once the original PATCH succeeds again, this
|
|
4231
|
+
* helper is never entered and the row settles with its real result.
|
|
4232
|
+
*/
|
|
4233
|
+
async boundRedriveOutcome(conv, message, outcome) {
|
|
4234
|
+
const now = this.now();
|
|
4235
|
+
const since = this.redriveOutcomeFailingSince.get(message.id);
|
|
4236
|
+
if (since === void 0) this.redriveOutcomeFailingSince.set(message.id, now);
|
|
4237
|
+
const durationTripped = now - (since ?? now) >= this.pausedMaxWaitMs;
|
|
4238
|
+
const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
|
|
4239
|
+
const absoluteAgeTripped = !Number.isNaN(parsed) && now - parsed >= ABSOLUTE_MAX_PROCESSING_MS;
|
|
4240
|
+
if (!durationTripped && !absoluteAgeTripped) {
|
|
4241
|
+
this.signalRedriveOutcomeUnreported(conv, message, outcome);
|
|
4242
|
+
return "retry";
|
|
4243
|
+
}
|
|
4244
|
+
const arm = durationTripped ? "failure_window" : "absolute_age";
|
|
4245
|
+
try {
|
|
4246
|
+
await this.markFailed(
|
|
4247
|
+
conv.id,
|
|
4248
|
+
message.id,
|
|
4249
|
+
void 0,
|
|
4250
|
+
_ChannelDriver.REDRIVE_ABANDON_ERROR[outcome]
|
|
4251
|
+
);
|
|
4252
|
+
} catch (err) {
|
|
4253
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4254
|
+
this.log({
|
|
4255
|
+
level: "warn",
|
|
4256
|
+
message: `Re-drive bound: fallback markFailed for message ${message.id.slice(0, 8)} also failed (arm ${arm}, will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4257
|
+
conversation_id: conv.id,
|
|
4258
|
+
message_id: message.id
|
|
4259
|
+
});
|
|
4260
|
+
if (!this.redriveOutcomeAbandonedSignalled.has(message.id)) {
|
|
4261
|
+
this.redriveOutcomeAbandonedSignalled.add(message.id);
|
|
4262
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
|
|
4263
|
+
attempted_outcome: outcome,
|
|
4264
|
+
reported: false,
|
|
4265
|
+
arm
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
4268
|
+
return "retry";
|
|
4269
|
+
}
|
|
4270
|
+
this.clearRedriveUnresolved(message.id);
|
|
4271
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
|
|
4272
|
+
attempted_outcome: outcome,
|
|
4273
|
+
reported: true,
|
|
4274
|
+
arm
|
|
4275
|
+
});
|
|
4276
|
+
return "abandoned";
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Record one poll outcome toward the re-drive fence's consecutive-identical-
|
|
4280
|
+
* failure streak (#1348) and return the resulting count. `signature === null`
|
|
4281
|
+
* (a thrown exception, H1) always clears the streak and returns `0` — it is
|
|
4282
|
+
* never countable. Otherwise the streak continues only when BOTH the session
|
|
4283
|
+
* and the signature match the previous failure; anything else (a different
|
|
4284
|
+
* session, or the same session failing a DIFFERENT way) starts a fresh streak
|
|
4285
|
+
* at `1`.
|
|
4286
|
+
*/
|
|
4287
|
+
recordRedrivePollFailure(messageId, sessionId, signature) {
|
|
4288
|
+
if (signature === null) {
|
|
4289
|
+
this.redrivePollFailures.delete(messageId);
|
|
4290
|
+
return 0;
|
|
4291
|
+
}
|
|
4292
|
+
const existing = this.redrivePollFailures.get(messageId);
|
|
4293
|
+
if (existing && existing.sessionId === sessionId && existing.signature === signature) {
|
|
4294
|
+
existing.count += 1;
|
|
4295
|
+
return existing.count;
|
|
4296
|
+
}
|
|
4297
|
+
this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
|
|
4298
|
+
return 1;
|
|
4299
|
+
}
|
|
4300
|
+
/**
|
|
4301
|
+
* Record one UNCONFIRMED-dispatch outcome (a `pending` row with no stored
|
|
4302
|
+
* `opencode_message_id` whose `sendPromptAsync` returned `null`) toward the
|
|
4303
|
+
* bound in `processConversation`'s dispatch loop, and return the resulting
|
|
4304
|
+
* count. Mirrors `recordRedrivePollFailure`'s session-scoping: a session
|
|
4305
|
+
* change starts a fresh streak at `1` rather than inheriting the old one's
|
|
4306
|
+
* count, since a new session is a genuinely different attempt.
|
|
4307
|
+
*/
|
|
4308
|
+
recordUnconfirmedDispatch(messageId, sessionId) {
|
|
4309
|
+
const existing = this.unconfirmedDispatchFailures.get(messageId);
|
|
4310
|
+
if (existing && existing.sessionId === sessionId) {
|
|
4311
|
+
existing.count += 1;
|
|
4312
|
+
return existing.count;
|
|
4313
|
+
}
|
|
4314
|
+
this.unconfirmedDispatchFailures.set(messageId, { sessionId, count: 1 });
|
|
4315
|
+
return 1;
|
|
4316
|
+
}
|
|
3146
4317
|
/**
|
|
3147
4318
|
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
3148
4319
|
* (#553). Keyed by conversation and hard-capped, so it cannot grow with the
|
|
@@ -3167,6 +4338,13 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3167
4338
|
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
3168
4339
|
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
3169
4340
|
* happened and a fresh session was bound instead. The caller reports it.
|
|
4341
|
+
*
|
|
4342
|
+
* `created` says the returned session was made JUST NOW, so it provably holds
|
|
4343
|
+
* no prior turn. The re-drive fence needs that as CONTRARY evidence ("nothing
|
|
4344
|
+
* to reconcile against") — distinct from the ambiguous "I polled and saw an
|
|
4345
|
+
* empty transcript", which stays a deferral. Keep it separate from
|
|
4346
|
+
* `refusedSessionId`: only the latter means a #553 resurrection happened, and
|
|
4347
|
+
* only it may drive the `session_superseded` signal.
|
|
3170
4348
|
*/
|
|
3171
4349
|
async ensureSession(conv) {
|
|
3172
4350
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
@@ -3177,7 +4355,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3177
4355
|
conversation_id: conv.id
|
|
3178
4356
|
});
|
|
3179
4357
|
this.sessions.delete(conv.id);
|
|
3180
|
-
return {
|
|
4358
|
+
return {
|
|
4359
|
+
sessionId: await this.createAndBindSession(conv.id),
|
|
4360
|
+
refusedSessionId: bound,
|
|
4361
|
+
created: true
|
|
4362
|
+
};
|
|
3181
4363
|
}
|
|
3182
4364
|
if (bound) {
|
|
3183
4365
|
const exists = await sessionExists(this.port, bound);
|
|
@@ -3188,12 +4370,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3188
4370
|
conversation_id: conv.id
|
|
3189
4371
|
});
|
|
3190
4372
|
this.sessions.delete(conv.id);
|
|
3191
|
-
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
4373
|
+
return { sessionId: await this.createAndBindSession(conv.id), created: true };
|
|
3192
4374
|
}
|
|
3193
4375
|
this.sessions.set(conv.id, bound);
|
|
3194
|
-
return { sessionId: bound };
|
|
4376
|
+
return { sessionId: bound, created: false };
|
|
3195
4377
|
}
|
|
3196
|
-
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
4378
|
+
return { sessionId: await this.createAndBindSession(conv.id), created: true };
|
|
3197
4379
|
}
|
|
3198
4380
|
/**
|
|
3199
4381
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -3204,7 +4386,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3204
4386
|
const directory = await this.resolveOpenCodeDirectory();
|
|
3205
4387
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
3206
4388
|
this.sessions.set(conversationId, sessionId);
|
|
3207
|
-
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
4389
|
+
await this.persistSession(conversationId, sessionId).catch((err) => {
|
|
4390
|
+
this.log({
|
|
4391
|
+
level: "warn",
|
|
4392
|
+
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)}`,
|
|
4393
|
+
conversation_id: conversationId
|
|
4394
|
+
});
|
|
3208
4395
|
});
|
|
3209
4396
|
return sessionId;
|
|
3210
4397
|
}
|
|
@@ -3421,9 +4608,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3421
4608
|
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
3422
4609
|
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
3423
4610
|
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
3424
|
-
* handed to the cron.
|
|
3425
|
-
*
|
|
3426
|
-
*
|
|
4611
|
+
* handed to the cron. Real invariant (#965): the cron MAY reclaim a row this
|
|
4612
|
+
* runner still holds; a reclaimed row that already ran is never re-dispatched
|
|
4613
|
+
* while opencode reports its turn ongoing (readopt's own gate here, and the
|
|
4614
|
+
* `pending`-row re-drive fence, `resolveRedrive`). `dispatchedAt` stays `now`
|
|
3427
4615
|
* (only the appear-guard uses it).
|
|
3428
4616
|
*
|
|
3429
4617
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
@@ -3616,9 +4804,8 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3616
4804
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
3617
4805
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
3618
4806
|
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3619
|
-
let claimed;
|
|
3620
4807
|
try {
|
|
3621
|
-
|
|
4808
|
+
await this.markProcessing(
|
|
3622
4809
|
conv.id,
|
|
3623
4810
|
inFlight.evidentMessageId,
|
|
3624
4811
|
sessionId,
|
|
@@ -3627,23 +4814,24 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3627
4814
|
);
|
|
3628
4815
|
} catch (err) {
|
|
3629
4816
|
if (err instanceof ChannelAuthError) throw err;
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
4817
|
+
if (err instanceof ChannelTerminalError) {
|
|
4818
|
+
this.log({
|
|
4819
|
+
level: "error",
|
|
4820
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (terminal HTTP ${err.status}) \u2014 the server definitively refused the swap`,
|
|
4821
|
+
conversation_id: conv.id,
|
|
4822
|
+
message_id: inFlight.evidentMessageId
|
|
4823
|
+
});
|
|
4824
|
+
} else {
|
|
4825
|
+
this.log({
|
|
4826
|
+
level: "warn",
|
|
4827
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
4828
|
+
conversation_id: conv.id,
|
|
4829
|
+
message_id: inFlight.evidentMessageId
|
|
4830
|
+
});
|
|
4831
|
+
return;
|
|
4832
|
+
}
|
|
3637
4833
|
}
|
|
3638
4834
|
inFlight.started = true;
|
|
3639
|
-
if (!claimed) {
|
|
3640
|
-
this.log({
|
|
3641
|
-
level: "debug",
|
|
3642
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
3643
|
-
conversation_id: conv.id,
|
|
3644
|
-
message_id: inFlight.evidentMessageId
|
|
3645
|
-
});
|
|
3646
|
-
}
|
|
3647
4835
|
}
|
|
3648
4836
|
if (state === "done") {
|
|
3649
4837
|
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
@@ -3983,7 +5171,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3983
5171
|
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
3984
5172
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
3985
5173
|
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
3986
|
-
* errored turn is reported failed on restart, NOT re-dispatched
|
|
5174
|
+
* errored turn is reported failed on restart, NOT re-dispatched —
|
|
5175
|
+
* EXCEPT a restart-ABORTED turn under a not-ongoing session,
|
|
5176
|
+
* which is a restart orphan wearing a terminal error and is
|
|
5177
|
+
* re-dispatched instead (issue #1310, see the branch below);
|
|
3987
5178
|
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
3988
5179
|
* tracking the stored id so the reply correlates by it;
|
|
3989
5180
|
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
@@ -4047,7 +5238,16 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4047
5238
|
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
4048
5239
|
return;
|
|
4049
5240
|
}
|
|
4050
|
-
|
|
5241
|
+
const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
|
|
5242
|
+
if (restartAborted) {
|
|
5243
|
+
this.log({
|
|
5244
|
+
level: "info",
|
|
5245
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
5246
|
+
conversation_id: row.conversation_id,
|
|
5247
|
+
message_id: row.id
|
|
5248
|
+
});
|
|
5249
|
+
}
|
|
5250
|
+
if (state === "failed" && !restartAborted) {
|
|
4051
5251
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
4052
5252
|
const usage = messageUsage(messages, ocId ?? "");
|
|
4053
5253
|
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
@@ -4261,15 +5461,39 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4261
5461
|
}
|
|
4262
5462
|
if (ocId === null) {
|
|
4263
5463
|
this.awaitingReadopt.delete(row.id);
|
|
5464
|
+
const streak = this.recordUnconfirmedDispatch(row.id, sessionId);
|
|
5465
|
+
if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
|
|
5466
|
+
this.unconfirmedDispatchFailures.delete(row.id);
|
|
5467
|
+
this.sessions.delete(readoptConv.id);
|
|
5468
|
+
this.supersede(readoptConv.id, sessionId);
|
|
5469
|
+
const errorMessage = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
|
|
5470
|
+
this.log({
|
|
5471
|
+
level: "error",
|
|
5472
|
+
message: errorMessage,
|
|
5473
|
+
conversation_id: row.conversation_id,
|
|
5474
|
+
message_id: row.id
|
|
5475
|
+
});
|
|
5476
|
+
await this.markFailed(row.conversation_id, row.id, null, errorMessage).catch((markErr) => {
|
|
5477
|
+
this.log({
|
|
5478
|
+
level: "warn",
|
|
5479
|
+
message: `markFailed PATCH for message ${row.id.slice(0, 8)} (conversation ${row.conversation_id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
5480
|
+
conversation_id: row.conversation_id,
|
|
5481
|
+
message_id: row.id
|
|
5482
|
+
});
|
|
5483
|
+
});
|
|
5484
|
+
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
5485
|
+
return;
|
|
5486
|
+
}
|
|
4264
5487
|
this.log({
|
|
4265
5488
|
level: "warn",
|
|
4266
|
-
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
|
|
5489
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next drain`,
|
|
4267
5490
|
conversation_id: row.conversation_id,
|
|
4268
5491
|
message_id: row.id
|
|
4269
5492
|
});
|
|
4270
5493
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
4271
5494
|
return;
|
|
4272
5495
|
}
|
|
5496
|
+
this.unconfirmedDispatchFailures.delete(row.id);
|
|
4273
5497
|
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
4274
5498
|
this.dispatched.add(row.id);
|
|
4275
5499
|
this.readopted.add(row.id);
|
|
@@ -4325,7 +5549,8 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4325
5549
|
opencode_model: row.opencode_model,
|
|
4326
5550
|
source_message_id: row.source_message_id,
|
|
4327
5551
|
slack_user_id: row.slack_user_id,
|
|
4328
|
-
attachments: row.attachments ?? null
|
|
5552
|
+
attachments: row.attachments ?? null,
|
|
5553
|
+
opencode_message_id: row.opencode_message_id
|
|
4329
5554
|
};
|
|
4330
5555
|
}
|
|
4331
5556
|
/**
|
|
@@ -4917,11 +6142,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4917
6142
|
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
4918
6143
|
* stick.
|
|
4919
6144
|
*/
|
|
4920
|
-
sessionIdBody(sessionId, conversationId, messageId,
|
|
6145
|
+
sessionIdBody(sessionId, conversationId, messageId, status2) {
|
|
4921
6146
|
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
4922
6147
|
this.log({
|
|
4923
6148
|
level: "debug",
|
|
4924
|
-
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${
|
|
6149
|
+
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status2}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
|
|
4925
6150
|
conversation_id: conversationId,
|
|
4926
6151
|
message_id: messageId
|
|
4927
6152
|
});
|
|
@@ -4933,18 +6158,22 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4933
6158
|
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
4934
6159
|
* deep-linked "View in Evident" notice).
|
|
4935
6160
|
*
|
|
4936
|
-
*
|
|
4937
|
-
* -
|
|
4938
|
-
*
|
|
4939
|
-
*
|
|
4940
|
-
*
|
|
4941
|
-
*
|
|
4942
|
-
*
|
|
4943
|
-
*
|
|
4944
|
-
*
|
|
4945
|
-
*
|
|
4946
|
-
*
|
|
4947
|
-
*
|
|
6161
|
+
* Outcome contract (consumed by the watcher's swap-to-running guard):
|
|
6162
|
+
* - resolves (`void`) → the server transitioned the row to
|
|
6163
|
+
* processing (or idempotently confirmed
|
|
6164
|
+
* already-processing — that answer is
|
|
6165
|
+
* still a 200, never a refusal);
|
|
6166
|
+
* - throws `ChannelAuthError` → 401/403 (terminal auth failure);
|
|
6167
|
+
* - throws `ChannelTerminalError` → a definitive non-retryable, non-auth 4xx
|
|
6168
|
+
* (404 the row or its conversation is
|
|
6169
|
+
* gone, 400 the update was rejected).
|
|
6170
|
+
* Retrying cannot help;
|
|
6171
|
+
* - throws a plain `Error` → a TRANSIENT failure (retryable 5xx/429
|
|
6172
|
+
* status, or a network-level error from
|
|
6173
|
+
* `fetch`) — i.e. NO definitive server
|
|
6174
|
+
* response — so the caller leaves the
|
|
6175
|
+
* message un-started and retries the swap
|
|
6176
|
+
* on the next tick.
|
|
4948
6177
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
4949
6178
|
* retry vehicle for the swap-to-running.
|
|
4950
6179
|
*/
|
|
@@ -4963,11 +6192,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4963
6192
|
}
|
|
4964
6193
|
);
|
|
4965
6194
|
this.assertAuth(res, "marking message as processing");
|
|
4966
|
-
if (res.ok) return
|
|
6195
|
+
if (res.ok) return;
|
|
4967
6196
|
if (isRetryableStatus(res.status)) {
|
|
4968
6197
|
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
4969
6198
|
}
|
|
4970
|
-
|
|
6199
|
+
throw new ChannelTerminalError(`marking message as processing: HTTP ${res.status}`, res.status);
|
|
4971
6200
|
}
|
|
4972
6201
|
/**
|
|
4973
6202
|
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
|
|
@@ -5238,10 +6467,16 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5238
6467
|
import chalk5 from "chalk";
|
|
5239
6468
|
import ora2 from "ora";
|
|
5240
6469
|
import { select as select2 } from "@inquirer/prompts";
|
|
6470
|
+
var INTERACTIVE_START_TIMEOUT_MS = 3e4;
|
|
5241
6471
|
async function ensureOpenCodeRunning(ctx) {
|
|
5242
6472
|
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
5243
6473
|
if (healthCheck.healthy) {
|
|
5244
|
-
return {
|
|
6474
|
+
return {
|
|
6475
|
+
port: ctx.port,
|
|
6476
|
+
process: null,
|
|
6477
|
+
version: healthCheck.version ?? null,
|
|
6478
|
+
notReadyReason: null
|
|
6479
|
+
};
|
|
5245
6480
|
}
|
|
5246
6481
|
const runningInstances = await findHealthyOpenCodeInstances();
|
|
5247
6482
|
if (runningInstances.length > 0) {
|
|
@@ -5282,14 +6517,22 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
5282
6517
|
if (!ctx.interactive) {
|
|
5283
6518
|
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
5284
6519
|
const proc = await startOpenCode(ctx.port);
|
|
5285
|
-
const health = await waitForOpenCodeHealth(ctx.port,
|
|
6520
|
+
const health = await waitForOpenCodeHealth(ctx.port, ctx.startTimeoutMs);
|
|
5286
6521
|
if (!health.healthy) {
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
6522
|
+
return {
|
|
6523
|
+
port: ctx.port,
|
|
6524
|
+
process: proc,
|
|
6525
|
+
version: null,
|
|
6526
|
+
notReadyReason: `it did not become healthy within ${Math.round(ctx.startTimeoutMs / 1e3)}s`
|
|
6527
|
+
};
|
|
5290
6528
|
}
|
|
5291
6529
|
ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
|
|
5292
|
-
return {
|
|
6530
|
+
return {
|
|
6531
|
+
port: ctx.port,
|
|
6532
|
+
process: proc,
|
|
6533
|
+
version: health.version ?? null,
|
|
6534
|
+
notReadyReason: null
|
|
6535
|
+
};
|
|
5293
6536
|
}
|
|
5294
6537
|
let port = ctx.port;
|
|
5295
6538
|
if (isPortInUse(port)) {
|
|
@@ -5342,152 +6585,15 @@ Port ${port} is already in use.`));
|
|
|
5342
6585
|
if (action === "start") {
|
|
5343
6586
|
const spinner = ora2("Starting OpenCode...").start();
|
|
5344
6587
|
const proc = await startOpenCode(port);
|
|
5345
|
-
const health = await waitForOpenCodeHealth(port,
|
|
6588
|
+
const health = await waitForOpenCodeHealth(port, INTERACTIVE_START_TIMEOUT_MS);
|
|
5346
6589
|
if (!health.healthy) {
|
|
5347
6590
|
spinner.fail("Failed to start OpenCode");
|
|
5348
6591
|
throw new Error("OpenCode failed to start");
|
|
5349
6592
|
}
|
|
5350
6593
|
spinner.stop();
|
|
5351
|
-
return { port, process: proc, version: health.version ?? null };
|
|
5352
|
-
}
|
|
5353
|
-
return { port, process: null, version: null };
|
|
5354
|
-
}
|
|
5355
|
-
|
|
5356
|
-
// src/commands/agent-lookup.ts
|
|
5357
|
-
async function readErrorMessage(response) {
|
|
5358
|
-
const text = await response.text().catch(() => "");
|
|
5359
|
-
if (!text) return response.statusText || void 0;
|
|
5360
|
-
try {
|
|
5361
|
-
const data = JSON.parse(text);
|
|
5362
|
-
const message = data.message ?? data.error;
|
|
5363
|
-
if (typeof message === "string" && message.trim()) {
|
|
5364
|
-
return message;
|
|
5365
|
-
}
|
|
5366
|
-
} catch {
|
|
5367
|
-
}
|
|
5368
|
-
return text.trim() || response.statusText || void 0;
|
|
5369
|
-
}
|
|
5370
|
-
function authFailureHint(apiUrl, serverMessage) {
|
|
5371
|
-
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
5372
|
-
return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
|
|
5373
|
-
}
|
|
5374
|
-
async function resolveAgentIdFromKey(authHeader) {
|
|
5375
|
-
const apiUrl = getApiUrlConfig();
|
|
5376
|
-
try {
|
|
5377
|
-
const response = await fetch(`${apiUrl}/me`, {
|
|
5378
|
-
headers: { Authorization: authHeader }
|
|
5379
|
-
});
|
|
5380
|
-
if (response.status === 401) {
|
|
5381
|
-
const serverMessage = await readErrorMessage(response);
|
|
5382
|
-
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
5383
|
-
}
|
|
5384
|
-
if (!response.ok) {
|
|
5385
|
-
const serverMessage = await readErrorMessage(response);
|
|
5386
|
-
return {
|
|
5387
|
-
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5388
|
-
};
|
|
5389
|
-
}
|
|
5390
|
-
const data = await response.json();
|
|
5391
|
-
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
5392
|
-
return { agent_id: data.agent_id };
|
|
5393
|
-
}
|
|
5394
|
-
return {
|
|
5395
|
-
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
5396
|
-
};
|
|
5397
|
-
} catch (error2) {
|
|
5398
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5399
|
-
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5400
|
-
}
|
|
5401
|
-
}
|
|
5402
|
-
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
5403
|
-
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5404
|
-
const apiUrl = getApiUrlConfig();
|
|
5405
|
-
try {
|
|
5406
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5407
|
-
method: "POST",
|
|
5408
|
-
headers: { Authorization: authHeader },
|
|
5409
|
-
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5410
|
-
});
|
|
5411
|
-
if (!response.ok) {
|
|
5412
|
-
const serverMessage = await readErrorMessage(response);
|
|
5413
|
-
return {
|
|
5414
|
-
ok: false,
|
|
5415
|
-
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5416
|
-
};
|
|
5417
|
-
}
|
|
5418
|
-
return { ok: true };
|
|
5419
|
-
} catch (error2) {
|
|
5420
|
-
return { ok: false, error: describeBestEffortError(error2) };
|
|
5421
|
-
}
|
|
5422
|
-
}
|
|
5423
|
-
function describeBestEffortError(error2) {
|
|
5424
|
-
const name = error2?.name;
|
|
5425
|
-
if (name === "TimeoutError" || name === "AbortError") {
|
|
5426
|
-
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5427
|
-
}
|
|
5428
|
-
return error2 instanceof Error ? error2.message : String(error2);
|
|
5429
|
-
}
|
|
5430
|
-
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5431
|
-
try {
|
|
5432
|
-
const apiUrl = getApiUrlConfig();
|
|
5433
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5434
|
-
method: "POST",
|
|
5435
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5436
|
-
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5437
|
-
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5438
|
-
});
|
|
5439
|
-
if (!response.ok) {
|
|
5440
|
-
const serverMessage = await readErrorMessage(response);
|
|
5441
|
-
return {
|
|
5442
|
-
ok: false,
|
|
5443
|
-
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5444
|
-
};
|
|
5445
|
-
}
|
|
5446
|
-
return { ok: true };
|
|
5447
|
-
} catch (error2) {
|
|
5448
|
-
return { ok: false, error: describeBestEffortError(error2) };
|
|
5449
|
-
}
|
|
5450
|
-
}
|
|
5451
|
-
async function getAgentInfo(agentId, authHeader) {
|
|
5452
|
-
const apiUrl = getApiUrlConfig();
|
|
5453
|
-
try {
|
|
5454
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
5455
|
-
headers: { Authorization: authHeader }
|
|
5456
|
-
});
|
|
5457
|
-
if (response.status === 401) {
|
|
5458
|
-
const serverMessage = await readErrorMessage(response);
|
|
5459
|
-
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
5460
|
-
}
|
|
5461
|
-
if (response.status === 403) {
|
|
5462
|
-
const serverMessage = await readErrorMessage(response);
|
|
5463
|
-
return {
|
|
5464
|
-
valid: false,
|
|
5465
|
-
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
5466
|
-
};
|
|
5467
|
-
}
|
|
5468
|
-
if (response.status === 404) {
|
|
5469
|
-
const serverMessage = await readErrorMessage(response);
|
|
5470
|
-
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
5471
|
-
}
|
|
5472
|
-
if (!response.ok) {
|
|
5473
|
-
const serverMessage = await readErrorMessage(response);
|
|
5474
|
-
return {
|
|
5475
|
-
valid: false,
|
|
5476
|
-
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5477
|
-
};
|
|
5478
|
-
}
|
|
5479
|
-
const agent = await response.json();
|
|
5480
|
-
if (agent.agent_type !== "local") {
|
|
5481
|
-
return {
|
|
5482
|
-
valid: false,
|
|
5483
|
-
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
5484
|
-
};
|
|
5485
|
-
}
|
|
5486
|
-
return { valid: true, agent };
|
|
5487
|
-
} catch (error2) {
|
|
5488
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5489
|
-
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
6594
|
+
return { port, process: proc, version: health.version ?? null, notReadyReason: null };
|
|
5490
6595
|
}
|
|
6596
|
+
return { port, process: null, version: null, notReadyReason: "you chose to continue without it" };
|
|
5491
6597
|
}
|
|
5492
6598
|
|
|
5493
6599
|
// src/commands/run.ts
|
|
@@ -5526,7 +6632,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
5526
6632
|
if (trimmed === "") {
|
|
5527
6633
|
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5528
6634
|
}
|
|
5529
|
-
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ?
|
|
6635
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
|
|
5530
6636
|
if (!isAbsolute2(expanded)) {
|
|
5531
6637
|
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5532
6638
|
}
|
|
@@ -5547,6 +6653,61 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
5547
6653
|
}
|
|
5548
6654
|
return directories;
|
|
5549
6655
|
}
|
|
6656
|
+
var DEFAULT_OPENCODE_START_TIMEOUT_SECONDS = 180;
|
|
6657
|
+
var MAX_OPENCODE_START_TIMEOUT_SECONDS = 3600;
|
|
6658
|
+
var OPENCODE_START_TIMEOUT_ENV = "EVIDENT_OPENCODE_START_TIMEOUT";
|
|
6659
|
+
function resolveOpenCodeStartTimeoutMs(options, env = process.env) {
|
|
6660
|
+
const defaultMs = DEFAULT_OPENCODE_START_TIMEOUT_SECONDS * 1e3;
|
|
6661
|
+
let raw;
|
|
6662
|
+
let source;
|
|
6663
|
+
if (options.opencodeStartTimeout !== void 0) {
|
|
6664
|
+
raw = options.opencodeStartTimeout;
|
|
6665
|
+
source = "--opencode-start-timeout";
|
|
6666
|
+
} else if (env[OPENCODE_START_TIMEOUT_ENV] !== void 0 && env[OPENCODE_START_TIMEOUT_ENV] !== "") {
|
|
6667
|
+
raw = env[OPENCODE_START_TIMEOUT_ENV];
|
|
6668
|
+
source = OPENCODE_START_TIMEOUT_ENV;
|
|
6669
|
+
} else {
|
|
6670
|
+
return { timeoutMs: defaultMs, warnings: [] };
|
|
6671
|
+
}
|
|
6672
|
+
const trimmed = raw.trim();
|
|
6673
|
+
const seconds = Number(trimmed);
|
|
6674
|
+
const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(seconds) && seconds > 0;
|
|
6675
|
+
if (!isPositiveInteger || seconds > MAX_OPENCODE_START_TIMEOUT_SECONDS) {
|
|
6676
|
+
return {
|
|
6677
|
+
timeoutMs: defaultMs,
|
|
6678
|
+
warnings: [
|
|
6679
|
+
`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`
|
|
6680
|
+
]
|
|
6681
|
+
};
|
|
6682
|
+
}
|
|
6683
|
+
return { timeoutMs: seconds * 1e3, warnings: [] };
|
|
6684
|
+
}
|
|
6685
|
+
var MAX_ACTIVE_SESSIONS_ENV = "EVIDENT_MAX_ACTIVE_SESSIONS";
|
|
6686
|
+
function resolveMaxActiveSessions(options, env = process.env) {
|
|
6687
|
+
let raw;
|
|
6688
|
+
let source;
|
|
6689
|
+
if (options.maxActiveSessions !== void 0) {
|
|
6690
|
+
raw = options.maxActiveSessions;
|
|
6691
|
+
source = "--max-active-sessions";
|
|
6692
|
+
} else if (env[MAX_ACTIVE_SESSIONS_ENV] !== void 0 && env[MAX_ACTIVE_SESSIONS_ENV] !== "") {
|
|
6693
|
+
raw = env[MAX_ACTIVE_SESSIONS_ENV];
|
|
6694
|
+
source = MAX_ACTIVE_SESSIONS_ENV;
|
|
6695
|
+
} else {
|
|
6696
|
+
return { value: void 0, warnings: [] };
|
|
6697
|
+
}
|
|
6698
|
+
const trimmed = raw.trim();
|
|
6699
|
+
const count = Number(trimmed);
|
|
6700
|
+
const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(count) && count > 0;
|
|
6701
|
+
if (!isPositiveInteger) {
|
|
6702
|
+
return {
|
|
6703
|
+
value: void 0,
|
|
6704
|
+
warnings: [
|
|
6705
|
+
`Ignoring invalid ${source} "${raw}": expected a positive integer; using unlimited`
|
|
6706
|
+
]
|
|
6707
|
+
};
|
|
6708
|
+
}
|
|
6709
|
+
return { value: count, warnings: [] };
|
|
6710
|
+
}
|
|
5550
6711
|
function meetsThreshold(state, level) {
|
|
5551
6712
|
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
5552
6713
|
}
|
|
@@ -5568,6 +6729,10 @@ function log2(state, message, level = "info") {
|
|
|
5568
6729
|
function logActivity(state, entry) {
|
|
5569
6730
|
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
5570
6731
|
if (!meetsThreshold(state, level)) return;
|
|
6732
|
+
forwardRunnerActivity(
|
|
6733
|
+
{ level, message: entry.message, error: entry.error },
|
|
6734
|
+
{ agentId: state.agentId, authHeader: state.authHeader }
|
|
6735
|
+
);
|
|
5571
6736
|
const fullEntry = {
|
|
5572
6737
|
...entry,
|
|
5573
6738
|
level,
|
|
@@ -5667,9 +6832,15 @@ async function handleAuthError(state, error2) {
|
|
|
5667
6832
|
}
|
|
5668
6833
|
async function driveChannels(state, driver) {
|
|
5669
6834
|
let idlePolls = 0;
|
|
6835
|
+
let idleMs = 0;
|
|
6836
|
+
let consecutiveDrainFailures = 0;
|
|
6837
|
+
let unreachableMs = 0;
|
|
5670
6838
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5671
6839
|
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5672
6840
|
while (state.running) {
|
|
6841
|
+
const cycleStartedAtMs = performance.now();
|
|
6842
|
+
let idleThisCycle = false;
|
|
6843
|
+
let unreachableThisCycle = false;
|
|
5673
6844
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
5674
6845
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
5675
6846
|
if (state.interactive) displayStatus(state);
|
|
@@ -5684,17 +6855,23 @@ async function driveChannels(state, driver) {
|
|
|
5684
6855
|
);
|
|
5685
6856
|
try {
|
|
5686
6857
|
const processed = await driver.drainPending();
|
|
6858
|
+
consecutiveDrainFailures = 0;
|
|
6859
|
+
unreachableMs = 0;
|
|
5687
6860
|
state.messageCount += processed;
|
|
5688
6861
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
5689
6862
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5690
6863
|
const appliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5691
|
-
const
|
|
6864
|
+
const filesApplied = appliedFiles !== lastSeenAppliedFiles;
|
|
6865
|
+
const fileActivity = carriedOverFileSync || filesApplied;
|
|
5692
6866
|
lastSeenAppliedFiles = appliedFiles;
|
|
6867
|
+
if (filesApplied) state.claudeUsageRearm?.();
|
|
5693
6868
|
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
|
|
5694
6869
|
idlePolls = 0;
|
|
6870
|
+
idleMs = 0;
|
|
5695
6871
|
if (processed > 0 && state.interactive) displayStatus(state);
|
|
5696
6872
|
} else if (state.idleTimeout !== null) {
|
|
5697
6873
|
idlePolls++;
|
|
6874
|
+
idleThisCycle = true;
|
|
5698
6875
|
if (idlePolls === 1) {
|
|
5699
6876
|
logActivity(state, {
|
|
5700
6877
|
type: "info",
|
|
@@ -5718,21 +6895,44 @@ async function driveChannels(state, driver) {
|
|
|
5718
6895
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
5719
6896
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
5720
6897
|
if (state.interactive) displayStatus(state);
|
|
6898
|
+
if (driver.hasInFlightWatchers()) {
|
|
6899
|
+
consecutiveDrainFailures = 0;
|
|
6900
|
+
unreachableMs = 0;
|
|
6901
|
+
} else if (state.idleTimeout !== null) {
|
|
6902
|
+
consecutiveDrainFailures++;
|
|
6903
|
+
unreachableThisCycle = true;
|
|
6904
|
+
if (consecutiveDrainFailures === 1) {
|
|
6905
|
+
logActivity(state, {
|
|
6906
|
+
type: "info",
|
|
6907
|
+
message: `Cannot reach Evident, will exit if this persists past the idle timeout (timeout: ${state.idleTimeout}s)...`
|
|
6908
|
+
});
|
|
6909
|
+
if (state.interactive) displayStatus(state);
|
|
6910
|
+
}
|
|
6911
|
+
}
|
|
5721
6912
|
}
|
|
5722
6913
|
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
6914
|
+
const cycleMs = performance.now() - cycleStartedAtMs;
|
|
6915
|
+
if (idleThisCycle) idleMs += cycleMs;
|
|
6916
|
+
if (unreachableThisCycle) unreachableMs += cycleMs;
|
|
6917
|
+
if (state.idleTimeout !== null && consecutiveDrainFailures >= 2 && unreachableMs > state.idleTimeout * 1e3) {
|
|
6918
|
+
logActivity(state, {
|
|
6919
|
+
type: "info",
|
|
6920
|
+
level: "warn",
|
|
6921
|
+
message: `Exiting: could not reach Evident for ${consecutiveDrainFailures} consecutive polls (${Math.round(unreachableMs / 1e3)}s)`
|
|
6922
|
+
});
|
|
6923
|
+
if (state.interactive) displayStatus(state);
|
|
6924
|
+
break;
|
|
6925
|
+
}
|
|
6926
|
+
if (state.idleTimeout !== null && idlePolls >= 2 && idleMs > state.idleTimeout * 1e3) {
|
|
6927
|
+
logActivity(state, { type: "info", message: "Idle timeout reached" });
|
|
6928
|
+
if (state.interactive) displayStatus(state);
|
|
6929
|
+
break;
|
|
5730
6930
|
}
|
|
5731
6931
|
}
|
|
5732
6932
|
}
|
|
5733
6933
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
5734
|
-
async function runSweep(state, driver,
|
|
5735
|
-
const mode = `age=${
|
|
6934
|
+
async function runSweep(state, driver, config) {
|
|
6935
|
+
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
5736
6936
|
try {
|
|
5737
6937
|
const sessions = await listSessions(state.port);
|
|
5738
6938
|
if (sessions === null) {
|
|
@@ -5745,8 +6945,8 @@ async function runSweep(state, driver, config2) {
|
|
|
5745
6945
|
const toDelete = selectSessionsToDelete(
|
|
5746
6946
|
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
5747
6947
|
{
|
|
5748
|
-
maxAgeMs:
|
|
5749
|
-
maxCount:
|
|
6948
|
+
maxAgeMs: config.maxAgeMs,
|
|
6949
|
+
maxCount: config.maxCount,
|
|
5750
6950
|
nowMs: Date.now(),
|
|
5751
6951
|
protectedIds: driver.protectedSessionIds()
|
|
5752
6952
|
}
|
|
@@ -5782,7 +6982,7 @@ async function runSweep(state, driver, config2) {
|
|
|
5782
6982
|
}
|
|
5783
6983
|
}
|
|
5784
6984
|
function scheduleSessionCleanup(state, driver, options) {
|
|
5785
|
-
const
|
|
6985
|
+
const config = resolveSessionCleanupConfig(
|
|
5786
6986
|
{
|
|
5787
6987
|
maxAge: options.sessionCleanupMaxAge,
|
|
5788
6988
|
maxCount: options.sessionCleanupMaxCount,
|
|
@@ -5790,21 +6990,129 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
5790
6990
|
},
|
|
5791
6991
|
process.env
|
|
5792
6992
|
);
|
|
5793
|
-
for (const warning2 of
|
|
6993
|
+
for (const warning2 of config.warnings) {
|
|
5794
6994
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
5795
6995
|
}
|
|
5796
|
-
if (!
|
|
6996
|
+
if (!config.enabled) return;
|
|
5797
6997
|
logActivity(state, {
|
|
5798
6998
|
type: "info",
|
|
5799
|
-
message: `Session cleanup enabled (age=${
|
|
6999
|
+
message: `Session cleanup enabled (age=${config.maxAgeMs ?? "\u2014"}, count=${config.maxCount ?? "\u2014"}, interval=${config.intervalMs}ms)`
|
|
5800
7000
|
});
|
|
5801
|
-
const interval = setInterval(() => void runSweep(state, driver,
|
|
7001
|
+
const interval = setInterval(() => void runSweep(state, driver, config), config.intervalMs);
|
|
5802
7002
|
const firstSweep = setTimeout(
|
|
5803
|
-
() => void runSweep(state, driver,
|
|
7003
|
+
() => void runSweep(state, driver, config),
|
|
5804
7004
|
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
5805
7005
|
);
|
|
5806
7006
|
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
5807
7007
|
}
|
|
7008
|
+
function claudeUsageFailureStreakSuffix(consecutiveFailures) {
|
|
7009
|
+
return consecutiveFailures > 1 ? ` (${consecutiveFailures} consecutive failures)` : "";
|
|
7010
|
+
}
|
|
7011
|
+
function scheduleClaudeUsageReporting(state, options) {
|
|
7012
|
+
const { mode, warnings } = resolveClaudeUsageReportingMode(
|
|
7013
|
+
options.claudeUsageReporting,
|
|
7014
|
+
process.env
|
|
7015
|
+
);
|
|
7016
|
+
for (const warning2 of warnings) {
|
|
7017
|
+
logActivity(state, {
|
|
7018
|
+
type: "info",
|
|
7019
|
+
level: "warn",
|
|
7020
|
+
message: `Claude usage reporting: ${warning2}`
|
|
7021
|
+
});
|
|
7022
|
+
}
|
|
7023
|
+
if (mode === "off") {
|
|
7024
|
+
logActivity(state, {
|
|
7025
|
+
type: "info",
|
|
7026
|
+
level: "debug",
|
|
7027
|
+
message: "Claude usage reporting is off (--claude-usage-reporting off)"
|
|
7028
|
+
});
|
|
7029
|
+
return null;
|
|
7030
|
+
}
|
|
7031
|
+
let consecutiveFailures = 0;
|
|
7032
|
+
let armed = false;
|
|
7033
|
+
let rearmRequested = false;
|
|
7034
|
+
const scheduleNextTick = () => {
|
|
7035
|
+
armed = true;
|
|
7036
|
+
rearmRequested = false;
|
|
7037
|
+
state.claudeUsageTimer = setTimeout(() => void tick(false), nextReportDelayMs());
|
|
7038
|
+
};
|
|
7039
|
+
const rearm = () => {
|
|
7040
|
+
if (armed) {
|
|
7041
|
+
rearmRequested = true;
|
|
7042
|
+
return;
|
|
7043
|
+
}
|
|
7044
|
+
rearmRequested = false;
|
|
7045
|
+
armed = true;
|
|
7046
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
7047
|
+
};
|
|
7048
|
+
const tick = async (isProbe) => {
|
|
7049
|
+
try {
|
|
7050
|
+
const usage = await getClaudeUsage();
|
|
7051
|
+
const result = await reportClaudeUsage(state.agentId, state.authHeader, usage);
|
|
7052
|
+
if (result.ok) {
|
|
7053
|
+
if (consecutiveFailures > 0) {
|
|
7054
|
+
logActivity(state, {
|
|
7055
|
+
type: "info",
|
|
7056
|
+
level: "info",
|
|
7057
|
+
message: "Claude usage reporting recovered"
|
|
7058
|
+
});
|
|
7059
|
+
}
|
|
7060
|
+
consecutiveFailures = 0;
|
|
7061
|
+
logActivity(state, {
|
|
7062
|
+
type: "info",
|
|
7063
|
+
level: "debug",
|
|
7064
|
+
message: "Reported Claude usage to Evident"
|
|
7065
|
+
});
|
|
7066
|
+
} else {
|
|
7067
|
+
consecutiveFailures++;
|
|
7068
|
+
logActivity(state, {
|
|
7069
|
+
type: "info",
|
|
7070
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
7071
|
+
message: `Failed to report Claude usage: ${result.error}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
7072
|
+
});
|
|
7073
|
+
}
|
|
7074
|
+
scheduleNextTick();
|
|
7075
|
+
} catch (error2) {
|
|
7076
|
+
if (error2 instanceof ClaudeUsageError && isLocalCredentialProblem(error2)) {
|
|
7077
|
+
if (mode === "on") {
|
|
7078
|
+
logActivity(state, {
|
|
7079
|
+
type: "info",
|
|
7080
|
+
level: "warn",
|
|
7081
|
+
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"
|
|
7082
|
+
});
|
|
7083
|
+
scheduleNextTick();
|
|
7084
|
+
} else if (isProbe) {
|
|
7085
|
+
logActivity(state, {
|
|
7086
|
+
type: "info",
|
|
7087
|
+
level: "debug",
|
|
7088
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
7089
|
+
});
|
|
7090
|
+
armed = false;
|
|
7091
|
+
if (rearmRequested) rearm();
|
|
7092
|
+
} else {
|
|
7093
|
+
logActivity(state, {
|
|
7094
|
+
type: "info",
|
|
7095
|
+
level: "debug",
|
|
7096
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
7097
|
+
});
|
|
7098
|
+
scheduleNextTick();
|
|
7099
|
+
}
|
|
7100
|
+
} else {
|
|
7101
|
+
consecutiveFailures++;
|
|
7102
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
7103
|
+
logActivity(state, {
|
|
7104
|
+
type: "info",
|
|
7105
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
7106
|
+
message: `Claude usage reporting failed: ${message}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
7107
|
+
});
|
|
7108
|
+
scheduleNextTick();
|
|
7109
|
+
}
|
|
7110
|
+
}
|
|
7111
|
+
};
|
|
7112
|
+
armed = true;
|
|
7113
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
7114
|
+
return rearm;
|
|
7115
|
+
}
|
|
5808
7116
|
async function notifyOffline(state) {
|
|
5809
7117
|
if (!state.agentId || !state.authHeader) return;
|
|
5810
7118
|
if (!state.connected) {
|
|
@@ -5840,6 +7148,11 @@ async function cleanup(state, opts = {}) {
|
|
|
5840
7148
|
clearTimeout(timer);
|
|
5841
7149
|
}
|
|
5842
7150
|
state.sessionCleanupTimers = [];
|
|
7151
|
+
if (state.claudeUsageTimer) {
|
|
7152
|
+
clearTimeout(state.claudeUsageTimer);
|
|
7153
|
+
state.claudeUsageTimer = null;
|
|
7154
|
+
}
|
|
7155
|
+
state.claudeUsageRearm = null;
|
|
5843
7156
|
if (opts.graceful && state.channelDriver) {
|
|
5844
7157
|
state.channelDriver.stop();
|
|
5845
7158
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -5887,7 +7200,7 @@ async function run(options) {
|
|
|
5887
7200
|
let fileSyncDirectories;
|
|
5888
7201
|
try {
|
|
5889
7202
|
logLevel = resolveLogLevel(options);
|
|
5890
|
-
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo,
|
|
7203
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir3());
|
|
5891
7204
|
} catch (error2) {
|
|
5892
7205
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
5893
7206
|
if (options.json) {
|
|
@@ -5920,8 +7233,11 @@ async function run(options) {
|
|
|
5920
7233
|
messageCount: 0,
|
|
5921
7234
|
lastProxiedActivityAt: null,
|
|
5922
7235
|
sessionCleanupTimers: [],
|
|
7236
|
+
claudeUsageTimer: null,
|
|
7237
|
+
claudeUsageRearm: null,
|
|
5923
7238
|
authHeader: ""
|
|
5924
7239
|
};
|
|
7240
|
+
setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
|
|
5925
7241
|
if (fileSyncDirectories.length > 0) {
|
|
5926
7242
|
log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
|
|
5927
7243
|
} else {
|
|
@@ -5998,6 +7314,7 @@ async function run(options) {
|
|
|
5998
7314
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
5999
7315
|
blank();
|
|
6000
7316
|
process.exit(1);
|
|
7317
|
+
return;
|
|
6001
7318
|
}
|
|
6002
7319
|
blank();
|
|
6003
7320
|
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
@@ -6042,6 +7359,7 @@ async function run(options) {
|
|
|
6042
7359
|
} else {
|
|
6043
7360
|
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
6044
7361
|
process.exit(1);
|
|
7362
|
+
return;
|
|
6045
7363
|
}
|
|
6046
7364
|
} else {
|
|
6047
7365
|
printError(
|
|
@@ -6055,6 +7373,7 @@ async function run(options) {
|
|
|
6055
7373
|
);
|
|
6056
7374
|
blank();
|
|
6057
7375
|
process.exit(1);
|
|
7376
|
+
return;
|
|
6058
7377
|
}
|
|
6059
7378
|
}
|
|
6060
7379
|
telemetry.info(
|
|
@@ -6110,40 +7429,56 @@ async function run(options) {
|
|
|
6110
7429
|
} else {
|
|
6111
7430
|
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
6112
7431
|
}
|
|
7432
|
+
const { timeoutMs: opencodeStartTimeoutMs, warnings: opencodeStartTimeoutWarnings } = resolveOpenCodeStartTimeoutMs(options, process.env);
|
|
7433
|
+
for (const warning2 of opencodeStartTimeoutWarnings) {
|
|
7434
|
+
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
7435
|
+
}
|
|
7436
|
+
const { value: maxActiveSessions, warnings: maxActiveSessionsWarnings } = resolveMaxActiveSessions(options, process.env);
|
|
7437
|
+
for (const warning2 of maxActiveSessionsWarnings) {
|
|
7438
|
+
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
7439
|
+
}
|
|
6113
7440
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
6114
7441
|
try {
|
|
6115
7442
|
const oc = await ensureOpenCodeRunning({
|
|
6116
7443
|
port: state.port,
|
|
6117
7444
|
interactive: state.interactive,
|
|
6118
7445
|
agentId: state.agentId,
|
|
6119
|
-
log: (message) => log2(state, message)
|
|
7446
|
+
log: (message) => log2(state, message),
|
|
7447
|
+
startTimeoutMs: opencodeStartTimeoutMs
|
|
6120
7448
|
});
|
|
6121
7449
|
state.port = oc.port;
|
|
6122
7450
|
state.opencodeProcess = oc.process;
|
|
6123
7451
|
state.opencodeVersion = oc.version;
|
|
6124
|
-
state.opencodeConnected = oc.
|
|
7452
|
+
state.opencodeConnected = oc.notReadyReason === null;
|
|
6125
7453
|
const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
6126
7454
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
7455
|
+
if (!state.interactive && oc.notReadyReason !== null) {
|
|
7456
|
+
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}).`;
|
|
7457
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
7458
|
+
} else {
|
|
7459
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
7460
|
+
if (versionWarning) {
|
|
7461
|
+
log2(state, versionWarning, "warn");
|
|
7462
|
+
if (state.interactive && !state.json) {
|
|
7463
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
7464
|
+
}
|
|
6132
7465
|
}
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
7466
|
+
const noProviderWarning = buildNoProviderWarning(
|
|
7467
|
+
await hasAnyConfiguredProvider(state.port)
|
|
7468
|
+
);
|
|
7469
|
+
if (noProviderWarning) {
|
|
7470
|
+
log2(state, noProviderWarning, "warn");
|
|
7471
|
+
if (state.interactive && !state.json) {
|
|
7472
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
7473
|
+
blank();
|
|
7474
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
7475
|
+
console.log(
|
|
7476
|
+
chalk6.dim(
|
|
7477
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
7478
|
+
)
|
|
7479
|
+
);
|
|
7480
|
+
blank();
|
|
7481
|
+
}
|
|
6147
7482
|
}
|
|
6148
7483
|
}
|
|
6149
7484
|
} catch (error2) {
|
|
@@ -6161,7 +7496,8 @@ async function run(options) {
|
|
|
6161
7496
|
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
6162
7497
|
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
6163
7498
|
fileSyncDirectories,
|
|
6164
|
-
homeDir:
|
|
7499
|
+
homeDir: homedir3(),
|
|
7500
|
+
maxActiveSessions,
|
|
6165
7501
|
log: (entry) => (
|
|
6166
7502
|
// Thread the driver's real level straight through so `debug`/`warn`
|
|
6167
7503
|
// survive the sink filter (they no longer collapse to info). `type`
|
|
@@ -6290,6 +7626,7 @@ async function run(options) {
|
|
|
6290
7626
|
throw error2;
|
|
6291
7627
|
}
|
|
6292
7628
|
scheduleSessionCleanup(state, channelDriver, options);
|
|
7629
|
+
state.claudeUsageRearm = scheduleClaudeUsageReporting(state, options);
|
|
6293
7630
|
if (!interactive || state.json) {
|
|
6294
7631
|
log2(state, "Driving channel messages...");
|
|
6295
7632
|
}
|
|
@@ -6344,21 +7681,32 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
6344
7681
|
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);
|
|
6345
7682
|
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 }));
|
|
6346
7683
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
7684
|
+
program.command("status").description("Check whether the configured credentials can reach Evident").option("--json", "Output in JSON format").action((options) => status({ json: options.json }));
|
|
7685
|
+
program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
|
|
6347
7686
|
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(
|
|
6348
7687
|
"-a, --agent [id]",
|
|
6349
7688
|
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
6350
7689
|
).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
6351
7690
|
"--log-level <level>",
|
|
6352
7691
|
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
6353
|
-
).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(
|
|
7692
|
+
).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(
|
|
7693
|
+
"--opencode-start-timeout <seconds>",
|
|
7694
|
+
"Seconds to wait for OpenCode to become healthy when the runner starts it (default: 180). Env: EVIDENT_OPENCODE_START_TIMEOUT"
|
|
7695
|
+
).option("--json", "Output in JSON format").option(
|
|
6354
7696
|
"--session-cleanup-max-age <duration>",
|
|
6355
7697
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
6356
7698
|
).option(
|
|
6357
7699
|
"--session-cleanup-max-count <n>",
|
|
6358
7700
|
"Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
|
|
7701
|
+
).option(
|
|
7702
|
+
"--max-active-sessions <n>",
|
|
7703
|
+
"Cap how many sessions this runner works on at once (default: unlimited). Env: EVIDENT_MAX_ACTIVE_SESSIONS"
|
|
6359
7704
|
).option(
|
|
6360
7705
|
"--session-cleanup-interval <duration>",
|
|
6361
7706
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
7707
|
+
).option(
|
|
7708
|
+
"--claude-usage-reporting <mode>",
|
|
7709
|
+
"Report Claude subscription usage to Evident: auto | on | off (default: auto). Env: EVIDENT_CLAUDE_USAGE_REPORTING"
|
|
6362
7710
|
).option(
|
|
6363
7711
|
"--enable-file-sync-to <dir>",
|
|
6364
7712
|
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
@@ -6366,7 +7714,7 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
6366
7714
|
[]
|
|
6367
7715
|
).option(
|
|
6368
7716
|
"--tunnel-ready-file <path>",
|
|
6369
|
-
"Path to write once the tunnel is connected (
|
|
7717
|
+
"Path to write once the tunnel is connected (opt-in; unused unless an operator/image sets it)"
|
|
6370
7718
|
).action(
|
|
6371
7719
|
(options) => {
|
|
6372
7720
|
run({
|
|
@@ -6379,11 +7727,18 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
6379
7727
|
verbose: options.verbose,
|
|
6380
7728
|
conversation: options.conversation,
|
|
6381
7729
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
7730
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
7731
|
+
// resolveOpenCodeStartTimeoutMs (flag > EVIDENT_OPENCODE_START_TIMEOUT > 180s default).
|
|
7732
|
+
opencodeStartTimeout: options.opencodeStartTimeout,
|
|
6382
7733
|
json: options.json,
|
|
6383
7734
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
6384
7735
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
6385
7736
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
7737
|
+
maxActiveSessions: options.maxActiveSessions,
|
|
6386
7738
|
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
7739
|
+
// Raw string — the resolver in run.ts single-sources parsing
|
|
7740
|
+
// (resolveClaudeUsageReportingMode).
|
|
7741
|
+
claudeUsageReporting: options.claudeUsageReporting,
|
|
6387
7742
|
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
6388
7743
|
// resolveFileSyncDirectories.
|
|
6389
7744
|
enableFileSyncTo: options.enableFileSyncTo,
|