@peakflo/agent-harness 0.2.9-rc.1 → 0.2.9-rc.2
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 +33 -2
- package/dist/cli.js +24 -0
- package/dist/harness-discovery.d.ts +39 -8
- package/dist/harness-discovery.js +117 -32
- package/dist/harness.d.ts +30 -0
- package/dist/harness.js +116 -13
- package/dist/node-floor.d.ts +46 -0
- package/dist/node-floor.js +76 -0
- 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 +8 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@ Run a Peakflo coding agent on your machine.
|
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
|
-
Use Node.js 22.19 or newer and install Git.
|
|
7
|
+
Use Node.js 22.19 or newer and install Git. The runner checks the Node.js
|
|
8
|
+
version when it starts and stops with one line if it is older (see
|
|
9
|
+
"Troubleshooting"). Install the Pi coding agent once:
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
12
|
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|
@@ -50,7 +52,15 @@ this runner".
|
|
|
50
52
|
|
|
51
53
|
**When it looks.** Once after start, without delaying registration, and then
|
|
52
54
|
every five minutes while no turn is running. A harness you install or sign in
|
|
53
|
-
to later appears without a restart.
|
|
55
|
+
to later appears without a restart. At most two of these programs run at the
|
|
56
|
+
same time.
|
|
57
|
+
|
|
58
|
+
**A slow machine.** Some of these programs take several seconds to start. When
|
|
59
|
+
one does not answer in time, Peakflo shows it as "Installed — check timed out,
|
|
60
|
+
will retry", not as unavailable. The runner checks it again after 30 seconds,
|
|
61
|
+
then 60, then 120, and after that with the normal five-minute pass. A state
|
|
62
|
+
that was already known (signed in, or not signed in) is kept when a later
|
|
63
|
+
check times out.
|
|
54
64
|
|
|
55
65
|
**What it runs.** To do this the runner starts those programs, as your user,
|
|
56
66
|
with `--version` and a status or model-list command. It looks in the absolute
|
|
@@ -71,6 +81,27 @@ about what a third-party program prints.
|
|
|
71
81
|
`PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
|
|
72
82
|
reports no list. Running turns with Pi is not affected.
|
|
73
83
|
|
|
84
|
+
## Troubleshooting
|
|
85
|
+
|
|
86
|
+
**`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
|
|
87
|
+
`@peakflo` scope to another registry (for example GitHub Packages). Name the
|
|
88
|
+
public registry for this one command:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
npx --@peakflo:registry=https://registry.npmjs.org @peakflo/agent-harness@latest start
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**"pf-agent-harness needs Node.js 22.19 or newer".** Switch to a newer
|
|
95
|
+
Node.js, for example `nvm install 22 && nvm use 22`, and start again. npm only
|
|
96
|
+
prints an `EBADENGINE` warning for an older Node.js and installs anyway; the
|
|
97
|
+
runner is what stops. The requirement comes from Pi, which the runner starts
|
|
98
|
+
with the same Node.js. To start on an older Node.js at your own risk, set
|
|
99
|
+
`PF_ALLOW_UNSUPPORTED_NODE=1`.
|
|
100
|
+
|
|
101
|
+
**"Durable store unavailable, running in memory".** The runner could not
|
|
102
|
+
create or open its database under `--workspace-root`. It creates that
|
|
103
|
+
directory itself when it is missing, so check that the path is writable.
|
|
104
|
+
|
|
74
105
|
## Releasing
|
|
75
106
|
|
|
76
107
|
`.github/scripts/agent-harness-release.mjs` publishes this package
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ const adapters_1 = require("./adapters");
|
|
|
38
38
|
const contract_1 = require("./contract");
|
|
39
39
|
const credential_store_1 = require("./credential-store");
|
|
40
40
|
const harness_1 = require("./harness");
|
|
41
|
+
const node_floor_1 = require("./node-floor");
|
|
41
42
|
const options_1 = require("./options");
|
|
42
43
|
const version_1 = require("./version");
|
|
43
44
|
const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
|
|
@@ -81,6 +82,9 @@ Options:
|
|
|
81
82
|
default for desktop/self-hosted; never runs
|
|
82
83
|
in a sandbox.
|
|
83
84
|
|
|
85
|
+
Needs Node.js 22.19 or newer; start stops with one line on an older one.
|
|
86
|
+
PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
|
|
87
|
+
|
|
84
88
|
In a managed sandbox pf-bootstrap supplies these through the environment, so
|
|
85
89
|
the enrolment token never appears in /proc/<pid>/cmdline.
|
|
86
90
|
`;
|
|
@@ -144,6 +148,26 @@ async function main() {
|
|
|
144
148
|
process.exitCode = 2;
|
|
145
149
|
return;
|
|
146
150
|
}
|
|
151
|
+
/*
|
|
152
|
+
* THE NODE.JS FLOOR — checked first, before the browser sign-in, so nobody
|
|
153
|
+
* signs in and picks an organisation only to be told to change Node. See
|
|
154
|
+
* `node-floor.ts` for where the floor comes from and why a sandbox only
|
|
155
|
+
* warns.
|
|
156
|
+
*/
|
|
157
|
+
const nodeFloor = (0, node_floor_1.checkNodeFloor)({
|
|
158
|
+
current: process.versions.node,
|
|
159
|
+
floor: (0, version_1.getNodeFloor)(),
|
|
160
|
+
runtime: readOptionValue(argv.slice(1), 'runtime') ??
|
|
161
|
+
process.env.PF_RUNNER_RUNTIME,
|
|
162
|
+
env: process.env
|
|
163
|
+
});
|
|
164
|
+
if (nodeFloor.action === 'refuse') {
|
|
165
|
+
process.stderr.write(`${nodeFloor.message}\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (nodeFloor.action === 'warn') {
|
|
169
|
+
process.stderr.write(`Warning: ${nodeFloor.message}\n`);
|
|
170
|
+
}
|
|
147
171
|
/*
|
|
148
172
|
* NO TOKEN? OFFER TO SIGN IN, BUT ONLY WHERE THAT CAN POSSIBLY WORK.
|
|
149
173
|
*
|
|
@@ -29,21 +29,44 @@
|
|
|
29
29
|
* is local and is handed to each child process.
|
|
30
30
|
*/
|
|
31
31
|
import type { RunnerHarness, RunnerHarnessModel } from './types';
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/**
|
|
33
|
+
* One command: `--version`, a status check, or a model listing.
|
|
34
|
+
*
|
|
35
|
+
* MEASURED, NOT GUESSED. On the Mac where the first release candidate was
|
|
36
|
+
* tried, with two runners and other work going on, `cursor-agent --version`
|
|
37
|
+
* took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
|
|
38
|
+
* probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
|
|
39
|
+
* others. The first limit was 8 s, so both lost on a busy machine and were
|
|
40
|
+
* shown as broken. The limit is now well above the slowest start seen — and
|
|
41
|
+
* the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
|
|
42
|
+
* which is what made each of them slow.
|
|
43
|
+
*/
|
|
44
|
+
export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 20000;
|
|
45
|
+
/**
|
|
46
|
+
* The budget for finding out a harness's STATE: its version and whether it is
|
|
47
|
+
* signed in. The model listing is NOT inside this budget — it has one command
|
|
48
|
+
* timeout of its own and can never change the state (see `probeHarness`).
|
|
49
|
+
*/
|
|
50
|
+
export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 45000;
|
|
51
|
+
/**
|
|
52
|
+
* How many harnesses are probed at the same time. Each harness runs its
|
|
53
|
+
* commands one after another, so this is also the most CLI processes
|
|
54
|
+
* discovery ever has running. Two, not five: these CLIs are heavy to start
|
|
55
|
+
* (most are bundled Node programs), and five cold starts at once roughly
|
|
56
|
+
* doubled the start time of each on the test machine.
|
|
57
|
+
*/
|
|
58
|
+
export declare const DISCOVERY_CONCURRENCY = 2;
|
|
36
59
|
/**
|
|
37
60
|
* Every sentence a `reason` can be. A closed set on purpose: text printed by a
|
|
38
61
|
* CLI can hold an account email or a key, and a constant cannot.
|
|
39
62
|
*/
|
|
40
63
|
export declare const REASONS: {
|
|
41
64
|
readonly notInstalled: "Not installed on this machine.";
|
|
42
|
-
readonly versionTimedOut: "The version check timed out.";
|
|
65
|
+
readonly versionTimedOut: "Installed. The version check timed out and will be tried again.";
|
|
43
66
|
readonly versionFailed: "The version check failed.";
|
|
44
|
-
readonly discoveryTimedOut: "
|
|
67
|
+
readonly discoveryTimedOut: "Installed. The check timed out and will be tried again.";
|
|
45
68
|
readonly discoveryFailed: "Discovery failed.";
|
|
46
|
-
readonly signInTimedOut: "The sign-in check timed out.";
|
|
69
|
+
readonly signInTimedOut: "Installed. The sign-in check timed out and will be tried again.";
|
|
47
70
|
readonly signInUnknown: "Sign-in could not be confirmed.";
|
|
48
71
|
readonly codexSignIn: "Not signed in. Run `codex login` on this machine.";
|
|
49
72
|
readonly claudeSignIn: "Not signed in. Run `claude auth login` on this machine.";
|
|
@@ -81,6 +104,13 @@ export interface DiscoverHarnessesOptions {
|
|
|
81
104
|
runnableBackends?: readonly string[];
|
|
82
105
|
commandTimeoutMs?: number;
|
|
83
106
|
harnessTimeoutMs?: number;
|
|
107
|
+
/** Harnesses probed at once. Defaults to `DISCOVERY_CONCURRENCY`. */
|
|
108
|
+
concurrency?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Probe ONLY these harnesses and return only their entries. Used to check
|
|
111
|
+
* again the ones whose last check timed out, without starting the others.
|
|
112
|
+
*/
|
|
113
|
+
only?: readonly string[];
|
|
84
114
|
deps?: Partial<DiscoveryDeps>;
|
|
85
115
|
}
|
|
86
116
|
/**
|
|
@@ -137,6 +167,7 @@ export declare const KNOWN_HARNESSES: ReadonlyArray<{
|
|
|
137
167
|
* Look for every known harness. ALWAYS resolves, with one entry per known
|
|
138
168
|
* harness in a fixed order — a harness that is missing is reported as
|
|
139
169
|
* `installed: false`, so the reader can tell "not installed" from "this runner
|
|
140
|
-
* does not report harnesses".
|
|
170
|
+
* does not report harnesses". With `only`, just those harnesses are probed and
|
|
171
|
+
* returned.
|
|
141
172
|
*/
|
|
142
173
|
export declare function discoverHarnesses(options?: DiscoverHarnessesOptions): Promise<RunnerHarness[]>;
|
|
@@ -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
|
/**
|
|
@@ -182,6 +198,10 @@ export declare class Harness {
|
|
|
182
198
|
private harnessReport;
|
|
183
199
|
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
184
200
|
private harnessDiscoveryAt;
|
|
201
|
+
/** Epoch ms of the next retry of timed-out checks, or null when none is due. */
|
|
202
|
+
private harnessRetryAt;
|
|
203
|
+
/** Retries scheduled in a row. Back to 0 once nothing is waiting. */
|
|
204
|
+
private harnessRetryAttempts;
|
|
185
205
|
private harnessDiscoveryInFlight;
|
|
186
206
|
/**
|
|
187
207
|
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
@@ -319,6 +339,16 @@ export declare class Harness {
|
|
|
319
339
|
* returns the running one.
|
|
320
340
|
*/
|
|
321
341
|
private refreshHarnessDiscovery;
|
|
342
|
+
/**
|
|
343
|
+
* After a pass: check again SOON when a check timed out, instead of leaving
|
|
344
|
+
* "timed out" on screen for a whole interval.
|
|
345
|
+
*
|
|
346
|
+
* Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
|
|
347
|
+
* five-minute pass takes over. A machine that is slow for good must not be
|
|
348
|
+
* probed for ever at a short interval. The counter starts again once
|
|
349
|
+
* nothing is waiting.
|
|
350
|
+
*/
|
|
351
|
+
private scheduleHarnessRetry;
|
|
322
352
|
/**
|
|
323
353
|
* Attach the cached harness report to a capability report.
|
|
324
354
|
*
|
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");
|
|
@@ -41,6 +42,45 @@ const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
|
41
42
|
* every heartbeat sends the cached answer.
|
|
42
43
|
*/
|
|
43
44
|
exports.HARNESS_DISCOVERY_REFRESH_MS = 5 * 60 * 1000;
|
|
45
|
+
/**
|
|
46
|
+
* How long after a pass the checks that TIMED OUT are tried again: three
|
|
47
|
+
* times, each wait twice the one before. See `scheduleHarnessRetry`.
|
|
48
|
+
*/
|
|
49
|
+
exports.HARNESS_RETRY_DELAYS_MS = [
|
|
50
|
+
30_000, 60_000, 120_000
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Put the result of a pass together with what was known before.
|
|
54
|
+
*
|
|
55
|
+
* KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
|
|
56
|
+
* the previous pass DID settle that harness (signed in, signed out, version
|
|
57
|
+
* failed) that answer stays. Only a harness with no settled answer yet is
|
|
58
|
+
* shown as "check timed out, will be tried again".
|
|
59
|
+
*
|
|
60
|
+
* A retry pass returns only the harnesses it probed; the others are kept.
|
|
61
|
+
*/
|
|
62
|
+
function mergeHarnessReports(previous, fresh) {
|
|
63
|
+
if (!previous)
|
|
64
|
+
return [...fresh];
|
|
65
|
+
const before = new Map(previous.map((harness) => [harness.name, harness]));
|
|
66
|
+
const after = new Map(fresh.map((harness) => [harness.name, harness]));
|
|
67
|
+
const pick = (name) => {
|
|
68
|
+
const now = after.get(name);
|
|
69
|
+
const then = before.get(name);
|
|
70
|
+
if (!now)
|
|
71
|
+
return then;
|
|
72
|
+
return now.transient && then && then.installed && !then.transient
|
|
73
|
+
? then
|
|
74
|
+
: now;
|
|
75
|
+
};
|
|
76
|
+
const names = [
|
|
77
|
+
...previous.map((harness) => harness.name),
|
|
78
|
+
...fresh.map((harness) => harness.name).filter((name) => !before.has(name))
|
|
79
|
+
];
|
|
80
|
+
return names
|
|
81
|
+
.map(pick)
|
|
82
|
+
.filter((harness) => harness !== undefined);
|
|
83
|
+
}
|
|
44
84
|
/** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
|
|
45
85
|
exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
|
|
46
86
|
/**
|
|
@@ -169,6 +209,10 @@ class Harness {
|
|
|
169
209
|
harnessReport = null;
|
|
170
210
|
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
171
211
|
harnessDiscoveryAt = 0;
|
|
212
|
+
/** Epoch ms of the next retry of timed-out checks, or null when none is due. */
|
|
213
|
+
harnessRetryAt = null;
|
|
214
|
+
/** Retries scheduled in a row. Back to 0 once nothing is waiting. */
|
|
215
|
+
harnessRetryAttempts = 0;
|
|
172
216
|
harnessDiscoveryInFlight = null;
|
|
173
217
|
/**
|
|
174
218
|
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
@@ -325,13 +369,6 @@ class Harness {
|
|
|
325
369
|
maxConcurrentSessions: this.options.maxConcurrentSessions,
|
|
326
370
|
labels: this.options.labels
|
|
327
371
|
});
|
|
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
372
|
// Install immediately before model probing can start a backend. The lease
|
|
336
373
|
// is process-wide and reference-counted, so concurrent Harness instances
|
|
337
374
|
// share one generic launcher instead of racing to rewrite global PATH.
|
|
@@ -347,6 +384,19 @@ class Harness {
|
|
|
347
384
|
*/
|
|
348
385
|
await this.refreshModelCatalog(true);
|
|
349
386
|
this.applyModelCatalog(this.capabilities);
|
|
387
|
+
/*
|
|
388
|
+
* STARTED, NEVER AWAITED. Registration must not wait for a third-party
|
|
389
|
+
* CLI, and a runner that is slow to register looks like a runner that did
|
|
390
|
+
* not come back. The report is attached to the first heartbeat after the
|
|
391
|
+
* pass has finished.
|
|
392
|
+
*
|
|
393
|
+
* STARTED HERE, AFTER THE MODEL PROBE, and not beside it. The first
|
|
394
|
+
* release candidate started both at once: the Pi discovery process and
|
|
395
|
+
* five harness CLIs all cold-starting together, on a machine that was
|
|
396
|
+
* also starting this very process through npx. Two harnesses lost their
|
|
397
|
+
* version check to that and were shown as broken for five minutes.
|
|
398
|
+
*/
|
|
399
|
+
void this.refreshHarnessDiscovery();
|
|
350
400
|
// Attaches the report only if the pass has already finished.
|
|
351
401
|
this.applyHarnessReport(this.capabilities);
|
|
352
402
|
log('Harness starting', {
|
|
@@ -795,13 +845,31 @@ class Harness {
|
|
|
795
845
|
if (this.harnessDiscoveryInFlight) {
|
|
796
846
|
return this.harnessDiscoveryInFlight;
|
|
797
847
|
}
|
|
798
|
-
|
|
848
|
+
const now = Date.now();
|
|
849
|
+
/*
|
|
850
|
+
* A RETRY PASS probes only the harnesses whose last check timed out. A
|
|
851
|
+
* full pass, when one is due anyway, covers them too.
|
|
852
|
+
*/
|
|
853
|
+
const fullPassDue = !this.harnessReport ||
|
|
854
|
+
now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
|
|
855
|
+
const retryNames = (this.harnessReport ?? [])
|
|
856
|
+
.filter((harness) => harness.transient)
|
|
857
|
+
.map((harness) => harness.name);
|
|
858
|
+
const only = !fullPassDue && this.harnessRetryAt !== null && retryNames.length > 0
|
|
859
|
+
? retryNames
|
|
860
|
+
: undefined;
|
|
861
|
+
if (!only) {
|
|
862
|
+
this.harnessDiscoveryAt = now;
|
|
863
|
+
}
|
|
864
|
+
this.harnessRetryAt = null;
|
|
799
865
|
const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
|
|
800
866
|
const pass = (async () => {
|
|
801
867
|
try {
|
|
802
|
-
const
|
|
803
|
-
runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name)
|
|
868
|
+
const fresh = await discover({
|
|
869
|
+
runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
|
|
870
|
+
...(only ? { only } : {})
|
|
804
871
|
});
|
|
872
|
+
const report = mergeHarnessReports(this.harnessReport, fresh);
|
|
805
873
|
// Same content as before: keep the OLD report, with its
|
|
806
874
|
// `detectedAt`. A pass that found nothing new must not look like a
|
|
807
875
|
// change, or the whole list would be sent again every five minutes.
|
|
@@ -809,13 +877,20 @@ class Harness {
|
|
|
809
877
|
harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
|
|
810
878
|
this.harnessReport = report;
|
|
811
879
|
}
|
|
880
|
+
this.scheduleHarnessRetry(report);
|
|
812
881
|
log('Harnesses discovered', {
|
|
813
882
|
installed: report
|
|
814
883
|
.filter((harness) => harness.installed)
|
|
815
884
|
.map((harness) => harness.name),
|
|
816
885
|
usable: report
|
|
817
886
|
.filter((harness) => harness.usable)
|
|
818
|
-
.map((harness) => harness.name)
|
|
887
|
+
.map((harness) => harness.name),
|
|
888
|
+
// Named so a slow machine can be told from a broken harness in
|
|
889
|
+
// the log: these are checked again, the others are settled.
|
|
890
|
+
retrying: report
|
|
891
|
+
.filter((harness) => harness.transient)
|
|
892
|
+
.map((harness) => harness.name),
|
|
893
|
+
...(only ? { pass: 'retry' } : {})
|
|
819
894
|
});
|
|
820
895
|
}
|
|
821
896
|
catch {
|
|
@@ -830,6 +905,29 @@ class Harness {
|
|
|
830
905
|
this.harnessDiscoveryInFlight = pass;
|
|
831
906
|
return pass;
|
|
832
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* After a pass: check again SOON when a check timed out, instead of leaving
|
|
910
|
+
* "timed out" on screen for a whole interval.
|
|
911
|
+
*
|
|
912
|
+
* Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
|
|
913
|
+
* five-minute pass takes over. A machine that is slow for good must not be
|
|
914
|
+
* probed for ever at a short interval. The counter starts again once
|
|
915
|
+
* nothing is waiting.
|
|
916
|
+
*/
|
|
917
|
+
scheduleHarnessRetry(report) {
|
|
918
|
+
if (!report.some((harness) => harness.transient)) {
|
|
919
|
+
this.harnessRetryAttempts = 0;
|
|
920
|
+
this.harnessRetryAt = null;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const delay = exports.HARNESS_RETRY_DELAYS_MS[this.harnessRetryAttempts];
|
|
924
|
+
if (delay === undefined) {
|
|
925
|
+
this.harnessRetryAt = null;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
this.harnessRetryAttempts += 1;
|
|
929
|
+
this.harnessRetryAt = Date.now() + delay;
|
|
930
|
+
}
|
|
833
931
|
/**
|
|
834
932
|
* Attach the cached harness report to a capability report.
|
|
835
933
|
*
|
|
@@ -865,6 +963,7 @@ class Harness {
|
|
|
865
963
|
version: backend.version ?? harness.version ?? null
|
|
866
964
|
};
|
|
867
965
|
delete entry.reason;
|
|
966
|
+
delete entry.transient;
|
|
868
967
|
if (!entry.models && backend.models && backend.models.length > 0) {
|
|
869
968
|
entry.models = backend.models.map(({ id, name }) => ({ id, name }));
|
|
870
969
|
entry.modelSource = 'backend';
|
|
@@ -907,7 +1006,11 @@ class Harness {
|
|
|
907
1006
|
if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
|
|
908
1007
|
return false;
|
|
909
1008
|
}
|
|
910
|
-
|
|
1009
|
+
const now = Date.now();
|
|
1010
|
+
if (this.harnessRetryAt !== null && now >= this.harnessRetryAt) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
return now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
|
|
911
1014
|
}
|
|
912
1015
|
/**
|
|
913
1016
|
* Whether the model catalogue needs a (rare) re-probe.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NODE.JS FLOOR, CHECKED AT START.
|
|
3
|
+
*
|
|
4
|
+
* `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
|
|
5
|
+
* an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
|
|
6
|
+
* the runner as if nothing was wrong. So the floor was a line in a manifest
|
|
7
|
+
* that nothing enforced.
|
|
8
|
+
*
|
|
9
|
+
* WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
|
|
10
|
+
* `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
|
|
11
|
+
* this runner runs turns with: `@earendil-works/pi-coding-agent` declares
|
|
12
|
+
* `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
|
|
13
|
+
* normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
|
|
14
|
+
* 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
|
|
15
|
+
* works there. An upstream requirement that is not proven false is kept.
|
|
16
|
+
*
|
|
17
|
+
* WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
|
|
18
|
+
* start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
|
|
19
|
+
* starts it anyway, with a one-line warning, for someone who knows their
|
|
20
|
+
* setup works. A SANDBOX only warns: its Node is whatever the image was built
|
|
21
|
+
* with, nobody is at a terminal to read the line, and a refusal there would
|
|
22
|
+
* take a cloud runner out of service for a requirement of a tool the image
|
|
23
|
+
* already carries.
|
|
24
|
+
*
|
|
25
|
+
* PURE: no process state is read here, so every branch can be tested.
|
|
26
|
+
*/
|
|
27
|
+
/** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
|
|
28
|
+
export declare function parseNodeVersion(text: string): number[] | null;
|
|
29
|
+
export declare function isNodeVersionAtLeast(current: string, floor: string): boolean;
|
|
30
|
+
export type NodeFloorVerdict = {
|
|
31
|
+
action: 'start';
|
|
32
|
+
} | {
|
|
33
|
+
action: 'warn';
|
|
34
|
+
message: string;
|
|
35
|
+
} | {
|
|
36
|
+
action: 'refuse';
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function checkNodeFloor(input: {
|
|
40
|
+
/** `process.versions.node`, e.g. `22.14.0`. */
|
|
41
|
+
current: string;
|
|
42
|
+
/** `engines.node` of this package, e.g. `>=22.19`. Null when unreadable. */
|
|
43
|
+
floor: string | null;
|
|
44
|
+
runtime: string | undefined;
|
|
45
|
+
env: NodeJS.ProcessEnv;
|
|
46
|
+
}): NodeFloorVerdict;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE NODE.JS FLOOR, CHECKED AT START.
|
|
4
|
+
*
|
|
5
|
+
* `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
|
|
6
|
+
* an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
|
|
7
|
+
* the runner as if nothing was wrong. So the floor was a line in a manifest
|
|
8
|
+
* that nothing enforced.
|
|
9
|
+
*
|
|
10
|
+
* WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
|
|
11
|
+
* `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
|
|
12
|
+
* this runner runs turns with: `@earendil-works/pi-coding-agent` declares
|
|
13
|
+
* `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
|
|
14
|
+
* normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
|
|
15
|
+
* 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
|
|
16
|
+
* works there. An upstream requirement that is not proven false is kept.
|
|
17
|
+
*
|
|
18
|
+
* WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
|
|
19
|
+
* start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
|
|
20
|
+
* starts it anyway, with a one-line warning, for someone who knows their
|
|
21
|
+
* setup works. A SANDBOX only warns: its Node is whatever the image was built
|
|
22
|
+
* with, nobody is at a terminal to read the line, and a refusal there would
|
|
23
|
+
* take a cloud runner out of service for a requirement of a tool the image
|
|
24
|
+
* already carries.
|
|
25
|
+
*
|
|
26
|
+
* PURE: no process state is read here, so every branch can be tested.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.parseNodeVersion = parseNodeVersion;
|
|
30
|
+
exports.isNodeVersionAtLeast = isNodeVersionAtLeast;
|
|
31
|
+
exports.checkNodeFloor = checkNodeFloor;
|
|
32
|
+
/** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
|
|
33
|
+
function parseNodeVersion(text) {
|
|
34
|
+
const match = text.trim().match(/^(?:>=\s*)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
return [match[1], match[2] ?? '0', match[3] ?? '0'].map(Number);
|
|
38
|
+
}
|
|
39
|
+
function isNodeVersionAtLeast(current, floor) {
|
|
40
|
+
const have = parseNodeVersion(current);
|
|
41
|
+
const need = parseNodeVersion(floor);
|
|
42
|
+
// A floor or a version that cannot be read is not a reason to refuse.
|
|
43
|
+
if (!have || !need)
|
|
44
|
+
return true;
|
|
45
|
+
for (let index = 0; index < 3; index++) {
|
|
46
|
+
if (have[index] > need[index])
|
|
47
|
+
return true;
|
|
48
|
+
if (have[index] < need[index])
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function checkNodeFloor(input) {
|
|
54
|
+
if (!input.floor || isNodeVersionAtLeast(input.current, input.floor)) {
|
|
55
|
+
return { action: 'start' };
|
|
56
|
+
}
|
|
57
|
+
const need = (parseNodeVersion(input.floor) ?? []).slice(0, 2).join('.');
|
|
58
|
+
const problem = `pf-agent-harness needs Node.js ${need} or newer; this is Node.js ${input.current}.`;
|
|
59
|
+
const allowed = /^(1|true|yes|on)$/i.test((input.env.PF_ALLOW_UNSUPPORTED_NODE ?? '').trim());
|
|
60
|
+
if (allowed) {
|
|
61
|
+
return {
|
|
62
|
+
action: 'warn',
|
|
63
|
+
message: `${problem} Starting anyway because PF_ALLOW_UNSUPPORTED_NODE is set.`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (input.runtime === 'sandbox') {
|
|
67
|
+
return {
|
|
68
|
+
action: 'warn',
|
|
69
|
+
message: `${problem} Starting anyway in a sandbox.`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
action: 'refuse',
|
|
74
|
+
message: `${problem} Switch to a newer Node.js (for example \`nvm install 22 && nvm use 22\`) and start again.`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.MemoryConversationStore = exports.SqliteConversationStore = void 0;
|
|
29
29
|
exports.openConversationStore = openConversationStore;
|
|
30
|
+
const sqlite_store_1 = require("./sqlite-store");
|
|
30
31
|
const SCHEMA = `
|
|
31
32
|
CREATE TABLE IF NOT EXISTS model_messages (
|
|
32
33
|
session_id TEXT NOT NULL,
|
|
@@ -292,6 +293,7 @@ function openConversationStore(path) {
|
|
|
292
293
|
};
|
|
293
294
|
}
|
|
294
295
|
try {
|
|
296
|
+
(0, sqlite_store_1.ensureStoreDirectory)(path);
|
|
295
297
|
const db = new sqlite.DatabaseSync(path);
|
|
296
298
|
return { store: new SqliteConversationStore(db), durable: true };
|
|
297
299
|
}
|
|
@@ -106,6 +106,23 @@ export interface OpenStoreResult {
|
|
|
106
106
|
/** Why durability was unavailable. Reported so a degraded fleet is visible. */
|
|
107
107
|
reason?: string;
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Make the directory a store file goes in, when it is not there yet.
|
|
111
|
+
*
|
|
112
|
+
* sqlite creates the FILE and never the directory: a `--workspace-root` that
|
|
113
|
+
* does not exist yet made every open fail with "unable to open database
|
|
114
|
+
* file", and the runner then ran in memory and reported itself degraded —
|
|
115
|
+
* for a path it was about to create anyway on the first session. Private
|
|
116
|
+
* mode, because the stores hold transcripts and the model's conversation.
|
|
117
|
+
*
|
|
118
|
+
* A directory that ALREADY EXISTS is left exactly as it is, its mode included:
|
|
119
|
+
* `mkdir` with `recursive` does not touch one, and changing the permissions
|
|
120
|
+
* of a directory somebody chose is not this function's call.
|
|
121
|
+
*
|
|
122
|
+
* Throws when the directory cannot be made; the caller turns that into the
|
|
123
|
+
* same visible memory fallback as any other open failure.
|
|
124
|
+
*/
|
|
125
|
+
export declare function ensureStoreDirectory(path: string): void;
|
|
109
126
|
/**
|
|
110
127
|
* Open the durable store, or say plainly why it could not be opened.
|
|
111
128
|
*
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
*/
|
|
33
33
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
34
|
exports.MemoryTranscriptStore = exports.SqliteTranscriptStore = void 0;
|
|
35
|
+
exports.ensureStoreDirectory = ensureStoreDirectory;
|
|
35
36
|
exports.openTranscriptStore = openTranscriptStore;
|
|
37
|
+
const node_fs_1 = require("node:fs");
|
|
38
|
+
const node_path_1 = require("node:path");
|
|
36
39
|
const SCHEMA = `
|
|
37
40
|
CREATE TABLE IF NOT EXISTS transcript_parts (
|
|
38
41
|
session_id TEXT NOT NULL,
|
|
@@ -309,6 +312,28 @@ class MemoryTranscriptStore {
|
|
|
309
312
|
}
|
|
310
313
|
}
|
|
311
314
|
exports.MemoryTranscriptStore = MemoryTranscriptStore;
|
|
315
|
+
/**
|
|
316
|
+
* Make the directory a store file goes in, when it is not there yet.
|
|
317
|
+
*
|
|
318
|
+
* sqlite creates the FILE and never the directory: a `--workspace-root` that
|
|
319
|
+
* does not exist yet made every open fail with "unable to open database
|
|
320
|
+
* file", and the runner then ran in memory and reported itself degraded —
|
|
321
|
+
* for a path it was about to create anyway on the first session. Private
|
|
322
|
+
* mode, because the stores hold transcripts and the model's conversation.
|
|
323
|
+
*
|
|
324
|
+
* A directory that ALREADY EXISTS is left exactly as it is, its mode included:
|
|
325
|
+
* `mkdir` with `recursive` does not touch one, and changing the permissions
|
|
326
|
+
* of a directory somebody chose is not this function's call.
|
|
327
|
+
*
|
|
328
|
+
* Throws when the directory cannot be made; the caller turns that into the
|
|
329
|
+
* same visible memory fallback as any other open failure.
|
|
330
|
+
*/
|
|
331
|
+
function ensureStoreDirectory(path) {
|
|
332
|
+
// sqlite's in-memory and temporary databases have no directory.
|
|
333
|
+
if (path === ':memory:' || path === '')
|
|
334
|
+
return;
|
|
335
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
|
|
336
|
+
}
|
|
312
337
|
/**
|
|
313
338
|
* Open the durable store, or say plainly why it could not be opened.
|
|
314
339
|
*
|
|
@@ -333,6 +358,7 @@ function openTranscriptStore(path) {
|
|
|
333
358
|
};
|
|
334
359
|
}
|
|
335
360
|
try {
|
|
361
|
+
ensureStoreDirectory(path);
|
|
336
362
|
const db = new sqlite.DatabaseSync(path);
|
|
337
363
|
return { store: new SqliteTranscriptStore(db), durable: true };
|
|
338
364
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -283,6 +283,14 @@ export interface RunnerHarness {
|
|
|
283
283
|
* account name or a token.
|
|
284
284
|
*/
|
|
285
285
|
reason?: string;
|
|
286
|
+
/**
|
|
287
|
+
* The check of this harness DID NOT FINISH (it timed out), so `usable:
|
|
288
|
+
* false` here is not a statement about the harness. The runner checks again
|
|
289
|
+
* shortly. Only ever sent with `installed: true, usable: false`. A reader
|
|
290
|
+
* that does not know this field shows the entry as not usable with its
|
|
291
|
+
* reason, which says the same thing in words.
|
|
292
|
+
*/
|
|
293
|
+
transient?: boolean;
|
|
286
294
|
/** This runner can run a turn with it: it is also listed in `backends`. */
|
|
287
295
|
runnable: boolean;
|
|
288
296
|
models?: RunnerHarnessModel[];
|
package/dist/version.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The version of THIS package, as installed. */
|
|
2
2
|
export declare function getHarnessVersion(): string;
|
|
3
|
+
/** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
|
|
4
|
+
export declare function getNodeFloor(): string | null;
|
|
3
5
|
/**
|
|
4
6
|
* The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
|
|
5
7
|
* installed, or null when nothing was ever synced — a self-hosted runner or
|
package/dist/version.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getHarnessVersion = getHarnessVersion;
|
|
4
|
+
exports.getNodeFloor = getNodeFloor;
|
|
4
5
|
exports.getInstalledSandboxToolsVersion = getInstalledSandboxToolsVersion;
|
|
5
6
|
exports.getAttemptedSandboxToolsVersion = getAttemptedSandboxToolsVersion;
|
|
6
7
|
exports.getPiVersion = getPiVersion;
|
|
@@ -23,6 +24,16 @@ function getHarnessVersion() {
|
|
|
23
24
|
}
|
|
24
25
|
return cachedHarnessVersion;
|
|
25
26
|
}
|
|
27
|
+
/** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
|
|
28
|
+
function getNodeFloor() {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8'));
|
|
31
|
+
return typeof pkg.engines?.node === 'string' ? pkg.engines.node : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
26
37
|
/**
|
|
27
38
|
* The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
|
|
28
39
|
* installed, or null when nothing was ever synced — a self-hosted runner or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.9-rc.
|
|
3
|
+
"version": "0.2.9-rc.2",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|