@peakflo/agent-harness 0.2.9-rc.1 → 0.2.9-rc.3
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 +51 -5
- package/dist/adapter-resolver.d.ts +61 -0
- package/dist/adapter-resolver.js +243 -0
- package/dist/adapters/claude-code-adapter.js +5 -1
- package/dist/cli.js +26 -22
- package/dist/harness-discovery.d.ts +39 -8
- package/dist/harness-discovery.js +117 -32
- package/dist/harness.d.ts +54 -0
- package/dist/harness.js +173 -15
- package/dist/node-floor.d.ts +46 -0
- package/dist/node-floor.js +76 -0
- package/dist/runtime/session-runner.d.ts +27 -1
- package/dist/runtime/session-runner.js +66 -3
- package/dist/store/conversation-store.js +2 -0
- package/dist/store/sqlite-store.d.ts +17 -0
- package/dist/store/sqlite-store.js +26 -0
- package/dist/types.d.ts +14 -0
- package/dist/vendor-backends.d.ts +77 -0
- package/dist/vendor-backends.js +167 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* is local and is handed to each child process.
|
|
31
31
|
*/
|
|
32
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
-
exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
|
|
33
|
+
exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_CONCURRENCY = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
|
|
34
34
|
exports.discoverySearchPath = discoverySearchPath;
|
|
35
35
|
exports.discoveryChildEnv = discoveryChildEnv;
|
|
36
36
|
exports.sanitizeHarnessModels = sanitizeHarnessModels;
|
|
@@ -45,10 +45,33 @@ const node_child_process_1 = require("node:child_process");
|
|
|
45
45
|
const node_fs_1 = require("node:fs");
|
|
46
46
|
const node_os_1 = require("node:os");
|
|
47
47
|
const node_path_1 = require("node:path");
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
/**
|
|
49
|
+
* One command: `--version`, a status check, or a model listing.
|
|
50
|
+
*
|
|
51
|
+
* MEASURED, NOT GUESSED. On the Mac where the first release candidate was
|
|
52
|
+
* tried, with two runners and other work going on, `cursor-agent --version`
|
|
53
|
+
* took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
|
|
54
|
+
* probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
|
|
55
|
+
* others. The first limit was 8 s, so both lost on a busy machine and were
|
|
56
|
+
* shown as broken. The limit is now well above the slowest start seen — and
|
|
57
|
+
* the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
|
|
58
|
+
* which is what made each of them slow.
|
|
59
|
+
*/
|
|
60
|
+
exports.DISCOVERY_COMMAND_TIMEOUT_MS = 20_000;
|
|
61
|
+
/**
|
|
62
|
+
* The budget for finding out a harness's STATE: its version and whether it is
|
|
63
|
+
* signed in. The model listing is NOT inside this budget — it has one command
|
|
64
|
+
* timeout of its own and can never change the state (see `probeHarness`).
|
|
65
|
+
*/
|
|
66
|
+
exports.DISCOVERY_HARNESS_TIMEOUT_MS = 45_000;
|
|
67
|
+
/**
|
|
68
|
+
* How many harnesses are probed at the same time. Each harness runs its
|
|
69
|
+
* commands one after another, so this is also the most CLI processes
|
|
70
|
+
* discovery ever has running. Two, not five: these CLIs are heavy to start
|
|
71
|
+
* (most are bundled Node programs), and five cold starts at once roughly
|
|
72
|
+
* doubled the start time of each on the test machine.
|
|
73
|
+
*/
|
|
74
|
+
exports.DISCOVERY_CONCURRENCY = 2;
|
|
52
75
|
const MAX_MODELS_PER_HARNESS = 200;
|
|
53
76
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
54
77
|
/**
|
|
@@ -57,11 +80,16 @@ const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
|
57
80
|
*/
|
|
58
81
|
exports.REASONS = {
|
|
59
82
|
notInstalled: 'Not installed on this machine.',
|
|
60
|
-
|
|
83
|
+
/*
|
|
84
|
+
* The three "timed out" sentences describe a CHECK that did not finish, not
|
|
85
|
+
* the harness. Each goes out with `transient: true`, and the runner checks
|
|
86
|
+
* again shortly.
|
|
87
|
+
*/
|
|
88
|
+
versionTimedOut: 'Installed. The version check timed out and will be tried again.',
|
|
61
89
|
versionFailed: 'The version check failed.',
|
|
62
|
-
discoveryTimedOut: '
|
|
90
|
+
discoveryTimedOut: 'Installed. The check timed out and will be tried again.',
|
|
63
91
|
discoveryFailed: 'Discovery failed.',
|
|
64
|
-
signInTimedOut: 'The sign-in check timed out.',
|
|
92
|
+
signInTimedOut: 'Installed. The sign-in check timed out and will be tried again.',
|
|
65
93
|
signInUnknown: 'Sign-in could not be confirmed.',
|
|
66
94
|
codexSignIn: 'Not signed in. Run `codex login` on this machine.',
|
|
67
95
|
claudeSignIn: 'Not signed in. Run `claude auth login` on this machine.',
|
|
@@ -325,6 +353,11 @@ function parseVersion(output) {
|
|
|
325
353
|
const match = cleanLine(output).match(/(\d+\.\d+[\w.+-]*)/);
|
|
326
354
|
return match ? match[1].slice(0, 64) : null;
|
|
327
355
|
}
|
|
356
|
+
const SIGN_IN_TIMED_OUT = {
|
|
357
|
+
usable: false,
|
|
358
|
+
reason: exports.REASONS.signInTimedOut,
|
|
359
|
+
transient: true
|
|
360
|
+
};
|
|
328
361
|
function hasEnv(env, names) {
|
|
329
362
|
return names.some((name) => Boolean(env[name]));
|
|
330
363
|
}
|
|
@@ -405,7 +438,7 @@ const CODEX = {
|
|
|
405
438
|
// Exit code only. The reply names the sign-in method, which is not needed.
|
|
406
439
|
const status = await run(['login', 'status']);
|
|
407
440
|
if (status.timedOut)
|
|
408
|
-
return
|
|
441
|
+
return SIGN_IN_TIMED_OUT;
|
|
409
442
|
// The exit code decides; the text is only tested for a plain "not
|
|
410
443
|
// logged in", in case a version exits 0 while saying so. Nothing is kept.
|
|
411
444
|
return status.code === 0 &&
|
|
@@ -446,7 +479,7 @@ const CLAUDE = {
|
|
|
446
479
|
}
|
|
447
480
|
const status = await run(['auth', 'status']);
|
|
448
481
|
if (status.timedOut)
|
|
449
|
-
return
|
|
482
|
+
return SIGN_IN_TIMED_OUT;
|
|
450
483
|
/*
|
|
451
484
|
* The reply is JSON that also holds the account email and organisation.
|
|
452
485
|
* ONE boolean is read from it and the rest is dropped here.
|
|
@@ -528,7 +561,7 @@ const CURSOR = {
|
|
|
528
561
|
return { usable: true };
|
|
529
562
|
const status = await run(['status']);
|
|
530
563
|
if (status.timedOut)
|
|
531
|
-
return
|
|
564
|
+
return SIGN_IN_TIMED_OUT;
|
|
532
565
|
// The reply names the account. It is tested here and never stored.
|
|
533
566
|
const text = `${status.stdout}\n${status.stderr}`;
|
|
534
567
|
// The negative test comes first and wins.
|
|
@@ -601,10 +634,7 @@ exports.KNOWN_HARNESSES = [
|
|
|
601
634
|
OPENCODE
|
|
602
635
|
].map(({ name, label }) => ({ name, label }));
|
|
603
636
|
const SPECS = [CODEX, CLAUDE, CURSOR, PI, OPENCODE];
|
|
604
|
-
|
|
605
|
-
/* The pass */
|
|
606
|
-
/* -------------------------------------------------------------------------- */
|
|
607
|
-
async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
|
|
637
|
+
async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired, progress) {
|
|
608
638
|
let path = null;
|
|
609
639
|
for (const command of spec.commands) {
|
|
610
640
|
const found = deps.resolveCommand(command);
|
|
@@ -623,9 +653,9 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
|
|
|
623
653
|
path: resolved,
|
|
624
654
|
deps,
|
|
625
655
|
/*
|
|
626
|
-
* NOTHING IS STARTED AFTER THE
|
|
627
|
-
* been sent without this harness by then, so a sign-in check
|
|
628
|
-
*
|
|
656
|
+
* NOTHING IS STARTED AFTER THE STATE DEADLINE. The report has already
|
|
657
|
+
* been sent without this harness by then, so a sign-in check started now
|
|
658
|
+
* would be a process nobody is waiting for.
|
|
629
659
|
*/
|
|
630
660
|
run: (args) => isExpired()
|
|
631
661
|
? Promise.resolve({
|
|
@@ -638,7 +668,10 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
|
|
|
638
668
|
};
|
|
639
669
|
const version = await context.run(['--version']);
|
|
640
670
|
if (version.timedOut) {
|
|
671
|
+
// The binary EXISTS and did not answer in time. That is a slow machine,
|
|
672
|
+
// not a broken harness, so it is reported as "will be tried again".
|
|
641
673
|
entry.reason = exports.REASONS.versionTimedOut;
|
|
674
|
+
entry.transient = true;
|
|
642
675
|
return;
|
|
643
676
|
}
|
|
644
677
|
if (version.code !== 0) {
|
|
@@ -652,22 +685,57 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
|
|
|
652
685
|
entry.usable = auth.usable;
|
|
653
686
|
if (!auth.usable && auth.reason)
|
|
654
687
|
entry.reason = auth.reason;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
688
|
+
if (!auth.usable && auth.transient)
|
|
689
|
+
entry.transient = true;
|
|
690
|
+
/*
|
|
691
|
+
* THE STATE IS SETTLED HERE, and the state deadline stops counting.
|
|
692
|
+
*
|
|
693
|
+
* The model listing below is extra information. It gets one command timeout
|
|
694
|
+
* of its own and its result can only ADD models: a listing that is slow or
|
|
695
|
+
* fails leaves `usable` exactly as the sign-in check found it. Before this,
|
|
696
|
+
* the listing shared one 20 s budget with the two checks above, and a slow
|
|
697
|
+
* `cursor-agent models` or `opencode models` turned a signed-in harness
|
|
698
|
+
* into "timed out".
|
|
699
|
+
*/
|
|
700
|
+
progress.stateKnown = true;
|
|
701
|
+
progress.onStateKnown?.();
|
|
702
|
+
try {
|
|
703
|
+
const listed = await spec.listModels(context, auth.usable);
|
|
704
|
+
if (listed && listed.models.length > 0) {
|
|
705
|
+
entry.models = listed.models;
|
|
706
|
+
entry.modelSource = listed.source;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// No models. The state above stands.
|
|
659
711
|
}
|
|
660
712
|
}
|
|
713
|
+
/** Run `task` for every item, at most `limit` at a time, keeping the order. */
|
|
714
|
+
async function mapWithLimit(items, limit, task) {
|
|
715
|
+
const results = new Array(items.length);
|
|
716
|
+
let next = 0;
|
|
717
|
+
const worker = async () => {
|
|
718
|
+
while (next < items.length) {
|
|
719
|
+
const index = next++;
|
|
720
|
+
results[index] = await task(items[index]);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
724
|
+
return results;
|
|
725
|
+
}
|
|
661
726
|
/**
|
|
662
727
|
* Look for every known harness. ALWAYS resolves, with one entry per known
|
|
663
728
|
* harness in a fixed order — a harness that is missing is reported as
|
|
664
729
|
* `installed: false`, so the reader can tell "not installed" from "this runner
|
|
665
|
-
* does not report harnesses".
|
|
730
|
+
* does not report harnesses". With `only`, just those harnesses are probed and
|
|
731
|
+
* returned.
|
|
666
732
|
*/
|
|
667
733
|
async function discoverHarnesses(options = {}) {
|
|
668
734
|
const commandTimeoutMs = options.commandTimeoutMs ?? exports.DISCOVERY_COMMAND_TIMEOUT_MS;
|
|
669
735
|
const harnessTimeoutMs = options.harnessTimeoutMs ?? exports.DISCOVERY_HARNESS_TIMEOUT_MS;
|
|
670
736
|
const runnable = new Set(options.runnableBackends ?? []);
|
|
737
|
+
const only = options.only ? new Set(options.only) : null;
|
|
738
|
+
const specs = only ? SPECS.filter((spec) => only.has(spec.name)) : SPECS;
|
|
671
739
|
let deps = null;
|
|
672
740
|
let detectedAt = new Date().toISOString();
|
|
673
741
|
try {
|
|
@@ -678,10 +746,11 @@ async function discoverHarnesses(options = {}) {
|
|
|
678
746
|
// `os.homedir()` throws on some locked-down hosts. Report every harness
|
|
679
747
|
// as not found rather than fail the runner.
|
|
680
748
|
}
|
|
681
|
-
|
|
749
|
+
const resolvedDeps = deps;
|
|
750
|
+
return mapWithLimit(specs, options.concurrency ?? exports.DISCOVERY_CONCURRENCY, async (spec) => {
|
|
682
751
|
/*
|
|
683
752
|
* The entry is filled in as the probe goes, so a deadline or a throw
|
|
684
|
-
* part-way still reports what was already learnt
|
|
753
|
+
* part-way still reports what was already learnt.
|
|
685
754
|
*/
|
|
686
755
|
const entry = {
|
|
687
756
|
name: spec.name,
|
|
@@ -692,36 +761,52 @@ async function discoverHarnesses(options = {}) {
|
|
|
692
761
|
runnable: false,
|
|
693
762
|
detectedAt
|
|
694
763
|
};
|
|
695
|
-
if (!
|
|
764
|
+
if (!resolvedDeps) {
|
|
696
765
|
entry.reason = exports.REASONS.discoveryFailed;
|
|
697
766
|
return entry;
|
|
698
767
|
}
|
|
699
768
|
let timer = null;
|
|
700
769
|
let expired = false;
|
|
701
|
-
const
|
|
770
|
+
const progress = { stateKnown: false };
|
|
702
771
|
const deadline = new Promise((resolve) => {
|
|
703
772
|
timer = setTimeout(() => {
|
|
704
773
|
expired = true;
|
|
705
774
|
resolve('expired');
|
|
706
775
|
}, harnessTimeoutMs);
|
|
707
776
|
timer.unref();
|
|
777
|
+
// The deadline is for the STATE only. Once that is known the model
|
|
778
|
+
// listing runs on its own command timeout.
|
|
779
|
+
progress.onStateKnown = () => {
|
|
780
|
+
if (timer)
|
|
781
|
+
clearTimeout(timer);
|
|
782
|
+
timer = null;
|
|
783
|
+
};
|
|
708
784
|
});
|
|
785
|
+
const work = probeHarness(spec, entry, resolvedDeps, commandTimeoutMs, () => expired, progress).then(() => 'done', () => 'failed');
|
|
709
786
|
const outcome = await Promise.race([work, deadline]);
|
|
710
787
|
if (timer)
|
|
711
788
|
clearTimeout(timer);
|
|
712
789
|
// A copy, so a probe still running after its deadline cannot change
|
|
713
790
|
// what was reported.
|
|
714
791
|
const report = { ...entry };
|
|
715
|
-
if (outcome
|
|
792
|
+
if (outcome === 'expired') {
|
|
793
|
+
// Found, and the checks did not finish in time: "will be tried again".
|
|
794
|
+
report.usable = false;
|
|
795
|
+
report.reason = exports.REASONS.discoveryTimedOut;
|
|
796
|
+
if (report.installed)
|
|
797
|
+
report.transient = true;
|
|
798
|
+
delete report.models;
|
|
799
|
+
delete report.modelSource;
|
|
800
|
+
}
|
|
801
|
+
else if (outcome === 'failed' && !progress.stateKnown) {
|
|
716
802
|
report.usable = false;
|
|
717
|
-
report.reason =
|
|
718
|
-
|
|
719
|
-
: exports.REASONS.discoveryFailed;
|
|
803
|
+
report.reason = exports.REASONS.discoveryFailed;
|
|
804
|
+
delete report.transient;
|
|
720
805
|
delete report.models;
|
|
721
806
|
delete report.modelSource;
|
|
722
807
|
}
|
|
723
808
|
// Runnable needs both: the runner drives it AND the binary is there.
|
|
724
809
|
report.runnable = report.installed && runnable.has(spec.name);
|
|
725
810
|
return report;
|
|
726
|
-
})
|
|
811
|
+
});
|
|
727
812
|
}
|
package/dist/harness.d.ts
CHANGED
|
@@ -14,6 +14,22 @@ import { HarnessOptions, HealthReport, RunnerHarness } from './types';
|
|
|
14
14
|
* every heartbeat sends the cached answer.
|
|
15
15
|
*/
|
|
16
16
|
export declare const HARNESS_DISCOVERY_REFRESH_MS: number;
|
|
17
|
+
/**
|
|
18
|
+
* How long after a pass the checks that TIMED OUT are tried again: three
|
|
19
|
+
* times, each wait twice the one before. See `scheduleHarnessRetry`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const HARNESS_RETRY_DELAYS_MS: readonly number[];
|
|
22
|
+
/**
|
|
23
|
+
* Put the result of a pass together with what was known before.
|
|
24
|
+
*
|
|
25
|
+
* KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
|
|
26
|
+
* the previous pass DID settle that harness (signed in, signed out, version
|
|
27
|
+
* failed) that answer stays. Only a harness with no settled answer yet is
|
|
28
|
+
* shown as "check timed out, will be tried again".
|
|
29
|
+
*
|
|
30
|
+
* A retry pass returns only the harnesses it probed; the others are kept.
|
|
31
|
+
*/
|
|
32
|
+
export declare function mergeHarnessReports(previous: readonly RunnerHarness[] | null, fresh: readonly RunnerHarness[]): RunnerHarness[];
|
|
17
33
|
/** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
|
|
18
34
|
export declare const HARNESS_DISCOVERY_FEATURE = "harnessDiscovery";
|
|
19
35
|
/**
|
|
@@ -107,6 +123,11 @@ export interface HarnessDeps {
|
|
|
107
123
|
approvalRenewIntervalMs?: number;
|
|
108
124
|
/** Injected in tests. */
|
|
109
125
|
clientFactory?: (url: string) => ControlPlaneClient;
|
|
126
|
+
/**
|
|
127
|
+
* Why a vendor backend cannot be run here, or null. Injected in tests;
|
|
128
|
+
* defaults to `vendorBackendBlocker` (sandbox, or the Claude SDK missing).
|
|
129
|
+
*/
|
|
130
|
+
vendorBackendBlocker?: (backend: string, runtime: string) => string | null;
|
|
110
131
|
/** Injected in tests; defaults to the real `discoverHarnesses`. */
|
|
111
132
|
discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
|
|
112
133
|
}
|
|
@@ -182,6 +203,10 @@ export declare class Harness {
|
|
|
182
203
|
private harnessReport;
|
|
183
204
|
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
184
205
|
private harnessDiscoveryAt;
|
|
206
|
+
/** Epoch ms of the next retry of timed-out checks, or null when none is due. */
|
|
207
|
+
private harnessRetryAt;
|
|
208
|
+
/** Retries scheduled in a row. Back to 0 once nothing is waiting. */
|
|
209
|
+
private harnessRetryAttempts;
|
|
185
210
|
private harnessDiscoveryInFlight;
|
|
186
211
|
/**
|
|
187
212
|
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
@@ -319,6 +344,16 @@ export declare class Harness {
|
|
|
319
344
|
* returns the running one.
|
|
320
345
|
*/
|
|
321
346
|
private refreshHarnessDiscovery;
|
|
347
|
+
/**
|
|
348
|
+
* After a pass: check again SOON when a check timed out, instead of leaving
|
|
349
|
+
* "timed out" on screen for a whole interval.
|
|
350
|
+
*
|
|
351
|
+
* Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
|
|
352
|
+
* five-minute pass takes over. A machine that is slow for good must not be
|
|
353
|
+
* probed for ever at a short interval. The counter starts again once
|
|
354
|
+
* nothing is waiting.
|
|
355
|
+
*/
|
|
356
|
+
private scheduleHarnessRetry;
|
|
322
357
|
/**
|
|
323
358
|
* Attach the cached harness report to a capability report.
|
|
324
359
|
*
|
|
@@ -329,6 +364,25 @@ export declare class Harness {
|
|
|
329
364
|
* the same reason — one source, so the two lists cannot disagree.
|
|
330
365
|
*/
|
|
331
366
|
private applyHarnessReport;
|
|
367
|
+
/**
|
|
368
|
+
* Add Codex, Claude Code and Cursor to `backends` — the list work is routed
|
|
369
|
+
* on — for exactly as long as each is INSTALLED AND SIGNED IN.
|
|
370
|
+
*
|
|
371
|
+
* This is the gate that turns "detected" into "runnable", so it is strict:
|
|
372
|
+
*
|
|
373
|
+
* - only from a discovery report, which a sandbox never has;
|
|
374
|
+
* - only a harness whose check SETTLED as usable. A check that timed out
|
|
375
|
+
* with no earlier answer is not enough to take work on;
|
|
376
|
+
* - only when this runner was given adapters at all, and has what the
|
|
377
|
+
* adapter needs (Claude Code needs the Claude Agent SDK).
|
|
378
|
+
*
|
|
379
|
+
* Recomputed for every capability report from the cached discovery pass, so
|
|
380
|
+
* a harness that is signed out, or uninstalled, leaves `backends` with the
|
|
381
|
+
* report that follows the pass which noticed. Nothing is remembered here.
|
|
382
|
+
*
|
|
383
|
+
* Returns why each harness that is usable was still NOT added, for the UI.
|
|
384
|
+
*/
|
|
385
|
+
private addVendorBackends;
|
|
332
386
|
private harnessDiscoveryEnabled;
|
|
333
387
|
/**
|
|
334
388
|
* Whether to start a discovery pass on this heartbeat.
|
package/dist/harness.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
|
|
3
|
+
exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_RETRY_DELAYS_MS = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
|
|
4
|
+
exports.mergeHarnessReports = mergeHarnessReports;
|
|
4
5
|
exports.harnessContentKey = harnessContentKey;
|
|
5
6
|
const node_crypto_1 = require("node:crypto");
|
|
6
7
|
const node_path_1 = require("node:path");
|
|
@@ -21,6 +22,7 @@ const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
|
|
|
21
22
|
const session_runner_1 = require("./runtime/session-runner");
|
|
22
23
|
const conversation_store_1 = require("./store/conversation-store");
|
|
23
24
|
const sqlite_store_1 = require("./store/sqlite-store");
|
|
25
|
+
const vendor_backends_1 = require("./vendor-backends");
|
|
24
26
|
const version_1 = require("./version");
|
|
25
27
|
/** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
|
|
26
28
|
const LEASE_ERROR_BACKOFF_MS = 5_000;
|
|
@@ -41,6 +43,45 @@ const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
|
41
43
|
* every heartbeat sends the cached answer.
|
|
42
44
|
*/
|
|
43
45
|
exports.HARNESS_DISCOVERY_REFRESH_MS = 5 * 60 * 1000;
|
|
46
|
+
/**
|
|
47
|
+
* How long after a pass the checks that TIMED OUT are tried again: three
|
|
48
|
+
* times, each wait twice the one before. See `scheduleHarnessRetry`.
|
|
49
|
+
*/
|
|
50
|
+
exports.HARNESS_RETRY_DELAYS_MS = [
|
|
51
|
+
30_000, 60_000, 120_000
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Put the result of a pass together with what was known before.
|
|
55
|
+
*
|
|
56
|
+
* KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
|
|
57
|
+
* the previous pass DID settle that harness (signed in, signed out, version
|
|
58
|
+
* failed) that answer stays. Only a harness with no settled answer yet is
|
|
59
|
+
* shown as "check timed out, will be tried again".
|
|
60
|
+
*
|
|
61
|
+
* A retry pass returns only the harnesses it probed; the others are kept.
|
|
62
|
+
*/
|
|
63
|
+
function mergeHarnessReports(previous, fresh) {
|
|
64
|
+
if (!previous)
|
|
65
|
+
return [...fresh];
|
|
66
|
+
const before = new Map(previous.map((harness) => [harness.name, harness]));
|
|
67
|
+
const after = new Map(fresh.map((harness) => [harness.name, harness]));
|
|
68
|
+
const pick = (name) => {
|
|
69
|
+
const now = after.get(name);
|
|
70
|
+
const then = before.get(name);
|
|
71
|
+
if (!now)
|
|
72
|
+
return then;
|
|
73
|
+
return now.transient && then && then.installed && !then.transient
|
|
74
|
+
? then
|
|
75
|
+
: now;
|
|
76
|
+
};
|
|
77
|
+
const names = [
|
|
78
|
+
...previous.map((harness) => harness.name),
|
|
79
|
+
...fresh.map((harness) => harness.name).filter((name) => !before.has(name))
|
|
80
|
+
];
|
|
81
|
+
return names
|
|
82
|
+
.map(pick)
|
|
83
|
+
.filter((harness) => harness !== undefined);
|
|
84
|
+
}
|
|
44
85
|
/** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
|
|
45
86
|
exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
|
|
46
87
|
/**
|
|
@@ -169,6 +210,10 @@ class Harness {
|
|
|
169
210
|
harnessReport = null;
|
|
170
211
|
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
171
212
|
harnessDiscoveryAt = 0;
|
|
213
|
+
/** Epoch ms of the next retry of timed-out checks, or null when none is due. */
|
|
214
|
+
harnessRetryAt = null;
|
|
215
|
+
/** Retries scheduled in a row. Back to 0 once nothing is waiting. */
|
|
216
|
+
harnessRetryAttempts = 0;
|
|
172
217
|
harnessDiscoveryInFlight = null;
|
|
173
218
|
/**
|
|
174
219
|
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
@@ -325,13 +370,6 @@ class Harness {
|
|
|
325
370
|
maxConcurrentSessions: this.options.maxConcurrentSessions,
|
|
326
371
|
labels: this.options.labels
|
|
327
372
|
});
|
|
328
|
-
/*
|
|
329
|
-
* STARTED, NEVER AWAITED. Registration must not wait for a third-party
|
|
330
|
-
* CLI: a pass can take as long as its slowest harness (20 s), and a runner
|
|
331
|
-
* that is slow to register looks like a runner that did not come back. The
|
|
332
|
-
* report is attached to the first heartbeat after the pass has finished.
|
|
333
|
-
*/
|
|
334
|
-
void this.refreshHarnessDiscovery();
|
|
335
373
|
// Install immediately before model probing can start a backend. The lease
|
|
336
374
|
// is process-wide and reference-counted, so concurrent Harness instances
|
|
337
375
|
// share one generic launcher instead of racing to rewrite global PATH.
|
|
@@ -347,6 +385,19 @@ class Harness {
|
|
|
347
385
|
*/
|
|
348
386
|
await this.refreshModelCatalog(true);
|
|
349
387
|
this.applyModelCatalog(this.capabilities);
|
|
388
|
+
/*
|
|
389
|
+
* STARTED, NEVER AWAITED. Registration must not wait for a third-party
|
|
390
|
+
* CLI, and a runner that is slow to register looks like a runner that did
|
|
391
|
+
* not come back. The report is attached to the first heartbeat after the
|
|
392
|
+
* pass has finished.
|
|
393
|
+
*
|
|
394
|
+
* STARTED HERE, AFTER THE MODEL PROBE, and not beside it. The first
|
|
395
|
+
* release candidate started both at once: the Pi discovery process and
|
|
396
|
+
* five harness CLIs all cold-starting together, on a machine that was
|
|
397
|
+
* also starting this very process through npx. Two harnesses lost their
|
|
398
|
+
* version check to that and were shown as broken for five minutes.
|
|
399
|
+
*/
|
|
400
|
+
void this.refreshHarnessDiscovery();
|
|
350
401
|
// Attaches the report only if the pass has already finished.
|
|
351
402
|
this.applyHarnessReport(this.capabilities);
|
|
352
403
|
log('Harness starting', {
|
|
@@ -713,7 +764,10 @@ class Harness {
|
|
|
713
764
|
* `force` re-probes regardless of age (used at start-up). Never rejects.
|
|
714
765
|
*/
|
|
715
766
|
async refreshModelCatalog(force = false) {
|
|
716
|
-
|
|
767
|
+
// A vendor harness's models come from discovery, not from this probe:
|
|
768
|
+
// asking its adapter would start `codex app-server` or `cursor-agent acp`
|
|
769
|
+
// just to list models that are already known.
|
|
770
|
+
const backends = (this.capabilities?.backends ?? []).filter((backend) => !(0, vendor_backends_1.isVendorBackend)(backend.name));
|
|
717
771
|
const build = this.deps.getAdapter;
|
|
718
772
|
if (!build || backends.length === 0) {
|
|
719
773
|
this.modelCatalog = null;
|
|
@@ -795,13 +849,31 @@ class Harness {
|
|
|
795
849
|
if (this.harnessDiscoveryInFlight) {
|
|
796
850
|
return this.harnessDiscoveryInFlight;
|
|
797
851
|
}
|
|
798
|
-
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
/*
|
|
854
|
+
* A RETRY PASS probes only the harnesses whose last check timed out. A
|
|
855
|
+
* full pass, when one is due anyway, covers them too.
|
|
856
|
+
*/
|
|
857
|
+
const fullPassDue = !this.harnessReport ||
|
|
858
|
+
now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
|
|
859
|
+
const retryNames = (this.harnessReport ?? [])
|
|
860
|
+
.filter((harness) => harness.transient)
|
|
861
|
+
.map((harness) => harness.name);
|
|
862
|
+
const only = !fullPassDue && this.harnessRetryAt !== null && retryNames.length > 0
|
|
863
|
+
? retryNames
|
|
864
|
+
: undefined;
|
|
865
|
+
if (!only) {
|
|
866
|
+
this.harnessDiscoveryAt = now;
|
|
867
|
+
}
|
|
868
|
+
this.harnessRetryAt = null;
|
|
799
869
|
const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
|
|
800
870
|
const pass = (async () => {
|
|
801
871
|
try {
|
|
802
|
-
const
|
|
803
|
-
runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name)
|
|
872
|
+
const fresh = await discover({
|
|
873
|
+
runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
|
|
874
|
+
...(only ? { only } : {})
|
|
804
875
|
});
|
|
876
|
+
const report = mergeHarnessReports(this.harnessReport, fresh);
|
|
805
877
|
// Same content as before: keep the OLD report, with its
|
|
806
878
|
// `detectedAt`. A pass that found nothing new must not look like a
|
|
807
879
|
// change, or the whole list would be sent again every five minutes.
|
|
@@ -809,13 +881,20 @@ class Harness {
|
|
|
809
881
|
harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
|
|
810
882
|
this.harnessReport = report;
|
|
811
883
|
}
|
|
884
|
+
this.scheduleHarnessRetry(report);
|
|
812
885
|
log('Harnesses discovered', {
|
|
813
886
|
installed: report
|
|
814
887
|
.filter((harness) => harness.installed)
|
|
815
888
|
.map((harness) => harness.name),
|
|
816
889
|
usable: report
|
|
817
890
|
.filter((harness) => harness.usable)
|
|
818
|
-
.map((harness) => harness.name)
|
|
891
|
+
.map((harness) => harness.name),
|
|
892
|
+
// Named so a slow machine can be told from a broken harness in
|
|
893
|
+
// the log: these are checked again, the others are settled.
|
|
894
|
+
retrying: report
|
|
895
|
+
.filter((harness) => harness.transient)
|
|
896
|
+
.map((harness) => harness.name),
|
|
897
|
+
...(only ? { pass: 'retry' } : {})
|
|
819
898
|
});
|
|
820
899
|
}
|
|
821
900
|
catch {
|
|
@@ -830,6 +909,29 @@ class Harness {
|
|
|
830
909
|
this.harnessDiscoveryInFlight = pass;
|
|
831
910
|
return pass;
|
|
832
911
|
}
|
|
912
|
+
/**
|
|
913
|
+
* After a pass: check again SOON when a check timed out, instead of leaving
|
|
914
|
+
* "timed out" on screen for a whole interval.
|
|
915
|
+
*
|
|
916
|
+
* Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
|
|
917
|
+
* five-minute pass takes over. A machine that is slow for good must not be
|
|
918
|
+
* probed for ever at a short interval. The counter starts again once
|
|
919
|
+
* nothing is waiting.
|
|
920
|
+
*/
|
|
921
|
+
scheduleHarnessRetry(report) {
|
|
922
|
+
if (!report.some((harness) => harness.transient)) {
|
|
923
|
+
this.harnessRetryAttempts = 0;
|
|
924
|
+
this.harnessRetryAt = null;
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const delay = exports.HARNESS_RETRY_DELAYS_MS[this.harnessRetryAttempts];
|
|
928
|
+
if (delay === undefined) {
|
|
929
|
+
this.harnessRetryAt = null;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.harnessRetryAttempts += 1;
|
|
933
|
+
this.harnessRetryAt = Date.now() + delay;
|
|
934
|
+
}
|
|
833
935
|
/**
|
|
834
936
|
* Attach the cached harness report to a capability report.
|
|
835
937
|
*
|
|
@@ -843,10 +945,16 @@ class Harness {
|
|
|
843
945
|
if (!this.harnessReport) {
|
|
844
946
|
return;
|
|
845
947
|
}
|
|
948
|
+
const blocked = this.addVendorBackends(capabilities);
|
|
846
949
|
const harnesses = this.harnessReport.map((harness) => {
|
|
847
950
|
const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
|
|
848
951
|
if (!backend) {
|
|
849
|
-
|
|
952
|
+
const reason = blocked.get(harness.name);
|
|
953
|
+
return {
|
|
954
|
+
...harness,
|
|
955
|
+
runnable: false,
|
|
956
|
+
...(reason ? { notRunnableReason: reason } : {})
|
|
957
|
+
};
|
|
850
958
|
}
|
|
851
959
|
/*
|
|
852
960
|
* A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
|
|
@@ -865,6 +973,7 @@ class Harness {
|
|
|
865
973
|
version: backend.version ?? harness.version ?? null
|
|
866
974
|
};
|
|
867
975
|
delete entry.reason;
|
|
976
|
+
delete entry.transient;
|
|
868
977
|
if (!entry.models && backend.models && backend.models.length > 0) {
|
|
869
978
|
entry.models = backend.models.map(({ id, name }) => ({ id, name }));
|
|
870
979
|
entry.modelSource = 'backend';
|
|
@@ -891,6 +1000,51 @@ class Harness {
|
|
|
891
1000
|
exports.HARNESS_DISCOVERY_FEATURE
|
|
892
1001
|
];
|
|
893
1002
|
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Add Codex, Claude Code and Cursor to `backends` — the list work is routed
|
|
1005
|
+
* on — for exactly as long as each is INSTALLED AND SIGNED IN.
|
|
1006
|
+
*
|
|
1007
|
+
* This is the gate that turns "detected" into "runnable", so it is strict:
|
|
1008
|
+
*
|
|
1009
|
+
* - only from a discovery report, which a sandbox never has;
|
|
1010
|
+
* - only a harness whose check SETTLED as usable. A check that timed out
|
|
1011
|
+
* with no earlier answer is not enough to take work on;
|
|
1012
|
+
* - only when this runner was given adapters at all, and has what the
|
|
1013
|
+
* adapter needs (Claude Code needs the Claude Agent SDK).
|
|
1014
|
+
*
|
|
1015
|
+
* Recomputed for every capability report from the cached discovery pass, so
|
|
1016
|
+
* a harness that is signed out, or uninstalled, leaves `backends` with the
|
|
1017
|
+
* report that follows the pass which noticed. Nothing is remembered here.
|
|
1018
|
+
*
|
|
1019
|
+
* Returns why each harness that is usable was still NOT added, for the UI.
|
|
1020
|
+
*/
|
|
1021
|
+
addVendorBackends(capabilities) {
|
|
1022
|
+
const blocked = new Map();
|
|
1023
|
+
if (!this.harnessReport || !this.deps.getAdapter)
|
|
1024
|
+
return blocked;
|
|
1025
|
+
const blocker = this.deps.vendorBackendBlocker ?? vendor_backends_1.vendorBackendBlocker;
|
|
1026
|
+
for (const harness of this.harnessReport) {
|
|
1027
|
+
if (!(0, vendor_backends_1.isVendorBackend)(harness.name))
|
|
1028
|
+
continue;
|
|
1029
|
+
if (!harness.installed || !harness.usable)
|
|
1030
|
+
continue;
|
|
1031
|
+
if (capabilities.backends.some((b) => b.name === harness.name))
|
|
1032
|
+
continue;
|
|
1033
|
+
const reason = blocker(harness.name, this.options.runtime);
|
|
1034
|
+
if (reason) {
|
|
1035
|
+
blocked.set(harness.name, reason);
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
capabilities.backends.push({
|
|
1039
|
+
name: harness.name,
|
|
1040
|
+
version: harness.version ?? null,
|
|
1041
|
+
...(harness.models && harness.models.length > 0
|
|
1042
|
+
? { models: harness.models.map(({ id, name }) => ({ id, name })) }
|
|
1043
|
+
: {})
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
return blocked;
|
|
1047
|
+
}
|
|
894
1048
|
harnessDiscoveryEnabled() {
|
|
895
1049
|
return (this.options.runtime !== 'sandbox' &&
|
|
896
1050
|
this.options.harnessDiscovery !== false);
|
|
@@ -907,7 +1061,11 @@ class Harness {
|
|
|
907
1061
|
if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
|
|
908
1062
|
return false;
|
|
909
1063
|
}
|
|
910
|
-
|
|
1064
|
+
const now = Date.now();
|
|
1065
|
+
if (this.harnessRetryAt !== null && now >= this.harnessRetryAt) {
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
return now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
|
|
911
1069
|
}
|
|
912
1070
|
/**
|
|
913
1071
|
* Whether the model catalogue needs a (rare) re-probe.
|