@peakflo/agent-harness 0.2.8 → 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 +67 -1
- package/dist/cli.js +29 -0
- package/dist/harness-discovery.d.ts +173 -0
- package/dist/harness-discovery.js +812 -0
- package/dist/harness.d.ts +98 -1
- package/dist/harness.js +300 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +14 -2
- package/dist/node-floor.d.ts +46 -0
- package/dist/node-floor.js +76 -0
- package/dist/options.js +17 -1
- 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 +83 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
package/dist/harness.d.ts
CHANGED
|
@@ -2,8 +2,41 @@ import type { CodingAgentAdapter } from './adapters';
|
|
|
2
2
|
import { SessionModelGateway } from './adapters/model-gateway';
|
|
3
3
|
import { ControlPlaneClient } from './control-plane';
|
|
4
4
|
import { CredentialStore } from './credential-store';
|
|
5
|
+
import { type DiscoverHarnessesOptions } from './harness-discovery';
|
|
5
6
|
import { ArtifactInspector, EventSink, Notifier, SecretProvider, Telemetry, ConversationStore, TranscriptStore, WorkspaceProvider } from './ports';
|
|
6
|
-
import { HarnessOptions, HealthReport } from './types';
|
|
7
|
+
import { HarnessOptions, HealthReport, RunnerHarness } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* How often the installed harnesses are looked for again.
|
|
10
|
+
*
|
|
11
|
+
* Five minutes: short enough that a harness installed or signed in to after
|
|
12
|
+
* start shows up without a restart or a re-enrol, and long enough that the
|
|
13
|
+
* heartbeat (30 s) does not start a handful of CLIs every time. Between passes
|
|
14
|
+
* every heartbeat sends the cached answer.
|
|
15
|
+
*/
|
|
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[];
|
|
33
|
+
/** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
|
|
34
|
+
export declare const HARNESS_DISCOVERY_FEATURE = "harnessDiscovery";
|
|
35
|
+
/**
|
|
36
|
+
* A short tag for WHAT a harness list says, ignoring WHEN it was found.
|
|
37
|
+
* Two passes that found the same thing give the same tag.
|
|
38
|
+
*/
|
|
39
|
+
export declare function harnessContentKey(harnesses: readonly RunnerHarness[]): string;
|
|
7
40
|
/**
|
|
8
41
|
* The control plane refused this harness's PROTOCOL or VERSION. FINAL.
|
|
9
42
|
*
|
|
@@ -90,6 +123,8 @@ export interface HarnessDeps {
|
|
|
90
123
|
approvalRenewIntervalMs?: number;
|
|
91
124
|
/** Injected in tests. */
|
|
92
125
|
clientFactory?: (url: string) => ControlPlaneClient;
|
|
126
|
+
/** Injected in tests; defaults to the real `discoverHarnesses`. */
|
|
127
|
+
discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
|
|
93
128
|
}
|
|
94
129
|
/**
|
|
95
130
|
* THE HARNESS.
|
|
@@ -153,6 +188,27 @@ export declare class Harness {
|
|
|
153
188
|
private modelCatalog;
|
|
154
189
|
/** Epoch ms of the last successful (or attempted) model probe. */
|
|
155
190
|
private modelProbeAt;
|
|
191
|
+
/**
|
|
192
|
+
* The harnesses found on this machine, from the last discovery pass.
|
|
193
|
+
*
|
|
194
|
+
* Cached for the same reason the model catalogue is: a pass starts several
|
|
195
|
+
* CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
|
|
196
|
+
* runs discovery, and until the first pass has finished.
|
|
197
|
+
*/
|
|
198
|
+
private harnessReport;
|
|
199
|
+
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
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;
|
|
205
|
+
private harnessDiscoveryInFlight;
|
|
206
|
+
/**
|
|
207
|
+
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
208
|
+
* heartbeat answer. Null until it says so, and for ever with an older
|
|
209
|
+
* control plane.
|
|
210
|
+
*/
|
|
211
|
+
private ackedHarnessesDigest;
|
|
156
212
|
/**
|
|
157
213
|
* Empty gateway for the model probe. The probe builds a FRESH adapter and
|
|
158
214
|
* must not read a live turn's credential; a null config is "no gateway",
|
|
@@ -272,6 +328,47 @@ export declare class Harness {
|
|
|
272
328
|
* an empty-but-meaningful list) when the catalogue is empty.
|
|
273
329
|
*/
|
|
274
330
|
private applyModelCatalog;
|
|
331
|
+
/**
|
|
332
|
+
* Look for the installed harnesses again and cache the answer.
|
|
333
|
+
*
|
|
334
|
+
* NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
|
|
335
|
+
* plane put in it, so there is nothing to discover, and its capability
|
|
336
|
+
* report must stay what it was before this existed.
|
|
337
|
+
*
|
|
338
|
+
* Never rejects. One pass at a time: a second call while a pass is running
|
|
339
|
+
* returns the running one.
|
|
340
|
+
*/
|
|
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;
|
|
352
|
+
/**
|
|
353
|
+
* Attach the cached harness report to a capability report.
|
|
354
|
+
*
|
|
355
|
+
* `runnable` is decided HERE, against the backends of THIS report, and not
|
|
356
|
+
* taken from the cache: the backend list is re-probed on every heartbeat
|
|
357
|
+
* and the harness list only every few minutes, so a cached `runnable` could
|
|
358
|
+
* contradict `backends`. Pi's models are copied from its backend entry for
|
|
359
|
+
* the same reason — one source, so the two lists cannot disagree.
|
|
360
|
+
*/
|
|
361
|
+
private applyHarnessReport;
|
|
362
|
+
private harnessDiscoveryEnabled;
|
|
363
|
+
/**
|
|
364
|
+
* Whether to start a discovery pass on this heartbeat.
|
|
365
|
+
*
|
|
366
|
+
* NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
|
|
367
|
+
* pass starts up to a dozen CLI processes; a turn in progress should not
|
|
368
|
+
* compete with them for the machine, and nothing is lost by waiting: the
|
|
369
|
+
* next idle heartbeat starts the pass.
|
|
370
|
+
*/
|
|
371
|
+
private harnessesNeedRefresh;
|
|
275
372
|
/**
|
|
276
373
|
* Whether the model catalogue needs a (rare) re-probe.
|
|
277
374
|
*
|
package/dist/harness.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = 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;
|
|
5
|
+
exports.harnessContentKey = harnessContentKey;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
7
|
const node_path_1 = require("node:path");
|
|
5
8
|
const model_gateway_1 = require("./adapters/model-gateway");
|
|
6
9
|
const capabilities_1 = require("./capabilities");
|
|
@@ -9,6 +12,7 @@ const control_plane_1 = require("./control-plane");
|
|
|
9
12
|
const credential_store_1 = require("./credential-store");
|
|
10
13
|
const github_command_guards_1 = require("./github-command-guards");
|
|
11
14
|
const github_workspace_1 = require("./github-workspace");
|
|
15
|
+
const harness_discovery_1 = require("./harness-discovery");
|
|
12
16
|
const health_1 = require("./health");
|
|
13
17
|
const model_probe_1 = require("./model-probe");
|
|
14
18
|
const ports_default_1 = require("./ports-default");
|
|
@@ -29,6 +33,67 @@ const LEASE_MIN_EMPTY_POLL_MS = 250;
|
|
|
29
33
|
* process. Every six hours is a bounded refresh of a slowly changing catalog.
|
|
30
34
|
*/
|
|
31
35
|
const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* How often the installed harnesses are looked for again.
|
|
38
|
+
*
|
|
39
|
+
* Five minutes: short enough that a harness installed or signed in to after
|
|
40
|
+
* start shows up without a restart or a re-enrol, and long enough that the
|
|
41
|
+
* heartbeat (30 s) does not start a handful of CLIs every time. Between passes
|
|
42
|
+
* every heartbeat sends the cached answer.
|
|
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
|
+
}
|
|
84
|
+
/** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
|
|
85
|
+
exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
|
|
86
|
+
/**
|
|
87
|
+
* A short tag for WHAT a harness list says, ignoring WHEN it was found.
|
|
88
|
+
* Two passes that found the same thing give the same tag.
|
|
89
|
+
*/
|
|
90
|
+
function harnessContentKey(harnesses) {
|
|
91
|
+
const content = harnesses.map(({ detectedAt: _detectedAt, ...rest }) => rest);
|
|
92
|
+
return (0, node_crypto_1.createHash)('sha256')
|
|
93
|
+
.update(JSON.stringify(content))
|
|
94
|
+
.digest('hex')
|
|
95
|
+
.slice(0, 32);
|
|
96
|
+
}
|
|
32
97
|
/**
|
|
33
98
|
* The control plane refused this harness's PROTOCOL or VERSION. FINAL.
|
|
34
99
|
*
|
|
@@ -134,6 +199,27 @@ class Harness {
|
|
|
134
199
|
modelCatalog = null;
|
|
135
200
|
/** Epoch ms of the last successful (or attempted) model probe. */
|
|
136
201
|
modelProbeAt = 0;
|
|
202
|
+
/**
|
|
203
|
+
* The harnesses found on this machine, from the last discovery pass.
|
|
204
|
+
*
|
|
205
|
+
* Cached for the same reason the model catalogue is: a pass starts several
|
|
206
|
+
* CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
|
|
207
|
+
* runs discovery, and until the first pass has finished.
|
|
208
|
+
*/
|
|
209
|
+
harnessReport = null;
|
|
210
|
+
/** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
|
|
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;
|
|
216
|
+
harnessDiscoveryInFlight = null;
|
|
217
|
+
/**
|
|
218
|
+
* The `harnessesDigest` the control plane said it has stored, from its last
|
|
219
|
+
* heartbeat answer. Null until it says so, and for ever with an older
|
|
220
|
+
* control plane.
|
|
221
|
+
*/
|
|
222
|
+
ackedHarnessesDigest = null;
|
|
137
223
|
/**
|
|
138
224
|
* Empty gateway for the model probe. The probe builds a FRESH adapter and
|
|
139
225
|
* must not read a live turn's credential; a null config is "no gateway",
|
|
@@ -298,6 +384,21 @@ class Harness {
|
|
|
298
384
|
*/
|
|
299
385
|
await this.refreshModelCatalog(true);
|
|
300
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();
|
|
400
|
+
// Attaches the report only if the pass has already finished.
|
|
401
|
+
this.applyHarnessReport(this.capabilities);
|
|
301
402
|
log('Harness starting', {
|
|
302
403
|
harnessVersion: (0, version_1.getHarnessVersion)(),
|
|
303
404
|
healthPort: this.options.healthPort,
|
|
@@ -517,6 +618,10 @@ class Harness {
|
|
|
517
618
|
if (response.heartbeatIntervalMs) {
|
|
518
619
|
this.heartbeatIntervalMs = response.heartbeatIntervalMs;
|
|
519
620
|
}
|
|
621
|
+
this.ackedHarnessesDigest =
|
|
622
|
+
typeof response.harnessesDigest === 'string'
|
|
623
|
+
? response.harnessesDigest
|
|
624
|
+
: null;
|
|
520
625
|
this.observeHarnessPin(response.harnessVersion);
|
|
521
626
|
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
522
627
|
this.applyCommandsSafely(response.sessionCommands ?? []);
|
|
@@ -723,6 +828,190 @@ class Harness {
|
|
|
723
828
|
}
|
|
724
829
|
}
|
|
725
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* Look for the installed harnesses again and cache the answer.
|
|
833
|
+
*
|
|
834
|
+
* NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
|
|
835
|
+
* plane put in it, so there is nothing to discover, and its capability
|
|
836
|
+
* report must stay what it was before this existed.
|
|
837
|
+
*
|
|
838
|
+
* Never rejects. One pass at a time: a second call while a pass is running
|
|
839
|
+
* returns the running one.
|
|
840
|
+
*/
|
|
841
|
+
refreshHarnessDiscovery() {
|
|
842
|
+
if (!this.harnessDiscoveryEnabled()) {
|
|
843
|
+
return Promise.resolve();
|
|
844
|
+
}
|
|
845
|
+
if (this.harnessDiscoveryInFlight) {
|
|
846
|
+
return this.harnessDiscoveryInFlight;
|
|
847
|
+
}
|
|
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;
|
|
865
|
+
const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
|
|
866
|
+
const pass = (async () => {
|
|
867
|
+
try {
|
|
868
|
+
const fresh = await discover({
|
|
869
|
+
runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
|
|
870
|
+
...(only ? { only } : {})
|
|
871
|
+
});
|
|
872
|
+
const report = mergeHarnessReports(this.harnessReport, fresh);
|
|
873
|
+
// Same content as before: keep the OLD report, with its
|
|
874
|
+
// `detectedAt`. A pass that found nothing new must not look like a
|
|
875
|
+
// change, or the whole list would be sent again every five minutes.
|
|
876
|
+
if (!this.harnessReport ||
|
|
877
|
+
harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
|
|
878
|
+
this.harnessReport = report;
|
|
879
|
+
}
|
|
880
|
+
this.scheduleHarnessRetry(report);
|
|
881
|
+
log('Harnesses discovered', {
|
|
882
|
+
installed: report
|
|
883
|
+
.filter((harness) => harness.installed)
|
|
884
|
+
.map((harness) => harness.name),
|
|
885
|
+
usable: report
|
|
886
|
+
.filter((harness) => harness.usable)
|
|
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' } : {})
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
// discoverHarnesses never rejects by construction. If a regression
|
|
898
|
+
// makes it reject, keep the last good answer instead of failing the
|
|
899
|
+
// start-up or the heartbeat.
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
this.harnessDiscoveryInFlight = null;
|
|
903
|
+
}
|
|
904
|
+
})();
|
|
905
|
+
this.harnessDiscoveryInFlight = pass;
|
|
906
|
+
return pass;
|
|
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
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Attach the cached harness report to a capability report.
|
|
933
|
+
*
|
|
934
|
+
* `runnable` is decided HERE, against the backends of THIS report, and not
|
|
935
|
+
* taken from the cache: the backend list is re-probed on every heartbeat
|
|
936
|
+
* and the harness list only every few minutes, so a cached `runnable` could
|
|
937
|
+
* contradict `backends`. Pi's models are copied from its backend entry for
|
|
938
|
+
* the same reason — one source, so the two lists cannot disagree.
|
|
939
|
+
*/
|
|
940
|
+
applyHarnessReport(capabilities) {
|
|
941
|
+
if (!this.harnessReport) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const harnesses = this.harnessReport.map((harness) => {
|
|
945
|
+
const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
|
|
946
|
+
if (!backend) {
|
|
947
|
+
return { ...harness, runnable: false };
|
|
948
|
+
}
|
|
949
|
+
/*
|
|
950
|
+
* A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
|
|
951
|
+
*
|
|
952
|
+
* The backend probe runs on every heartbeat and is what work is routed
|
|
953
|
+
* on. The cached discovery pass is older and looks in other places, so
|
|
954
|
+
* on its own it could say "not installed" or "version check timed out"
|
|
955
|
+
* for a Pi that is running turns right now. Where the two disagree, the
|
|
956
|
+
* backend probe is right.
|
|
957
|
+
*/
|
|
958
|
+
const entry = {
|
|
959
|
+
...harness,
|
|
960
|
+
installed: true,
|
|
961
|
+
usable: true,
|
|
962
|
+
runnable: true,
|
|
963
|
+
version: backend.version ?? harness.version ?? null
|
|
964
|
+
};
|
|
965
|
+
delete entry.reason;
|
|
966
|
+
delete entry.transient;
|
|
967
|
+
if (!entry.models && backend.models && backend.models.length > 0) {
|
|
968
|
+
entry.models = backend.models.map(({ id, name }) => ({ id, name }));
|
|
969
|
+
entry.modelSource = 'backend';
|
|
970
|
+
}
|
|
971
|
+
return entry;
|
|
972
|
+
});
|
|
973
|
+
const digest = harnessContentKey(harnesses);
|
|
974
|
+
capabilities.harnessesDigest = digest;
|
|
975
|
+
/*
|
|
976
|
+
* THE LIST IS SENT ONLY WHEN THE CONTROL PLANE DOES NOT HAVE IT.
|
|
977
|
+
*
|
|
978
|
+
* It is about 20 KB on a machine with every harness, and it changes
|
|
979
|
+
* rarely. A control plane that understands the digest answers each
|
|
980
|
+
* heartbeat with the digest it has stored; while that equals ours, the
|
|
981
|
+
* list is left out and only the digest goes. An older control plane never
|
|
982
|
+
* answers with a digest, so it is sent the list every time — which it
|
|
983
|
+
* needs, because it replaces `capabilities` as a whole.
|
|
984
|
+
*/
|
|
985
|
+
if (this.ackedHarnessesDigest !== digest) {
|
|
986
|
+
capabilities.harnesses = harnesses;
|
|
987
|
+
}
|
|
988
|
+
capabilities.features = [
|
|
989
|
+
...(capabilities.features ?? []).filter((feature) => feature !== exports.HARNESS_DISCOVERY_FEATURE),
|
|
990
|
+
exports.HARNESS_DISCOVERY_FEATURE
|
|
991
|
+
];
|
|
992
|
+
}
|
|
993
|
+
harnessDiscoveryEnabled() {
|
|
994
|
+
return (this.options.runtime !== 'sandbox' &&
|
|
995
|
+
this.options.harnessDiscovery !== false);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Whether to start a discovery pass on this heartbeat.
|
|
999
|
+
*
|
|
1000
|
+
* NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
|
|
1001
|
+
* pass starts up to a dozen CLI processes; a turn in progress should not
|
|
1002
|
+
* compete with them for the machine, and nothing is lost by waiting: the
|
|
1003
|
+
* next idle heartbeat starts the pass.
|
|
1004
|
+
*/
|
|
1005
|
+
harnessesNeedRefresh() {
|
|
1006
|
+
if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
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;
|
|
1014
|
+
}
|
|
726
1015
|
/**
|
|
727
1016
|
* Whether the model catalogue needs a (rare) re-probe.
|
|
728
1017
|
*
|
|
@@ -765,7 +1054,13 @@ class Harness {
|
|
|
765
1054
|
labels: this.options.labels
|
|
766
1055
|
});
|
|
767
1056
|
this.applyModelCatalog(capabilities);
|
|
1057
|
+
this.applyHarnessReport(capabilities);
|
|
768
1058
|
this.capabilities = capabilities;
|
|
1059
|
+
// Not awaited: the answer is attached to a LATER heartbeat. Only the
|
|
1060
|
+
// cached report is ever sent, so a slow CLI cannot delay a heartbeat.
|
|
1061
|
+
if (this.harnessesNeedRefresh()) {
|
|
1062
|
+
void this.refreshHarnessDiscovery();
|
|
1063
|
+
}
|
|
769
1064
|
/*
|
|
770
1065
|
* THE MODEL CATALOGUE IS NOT RE-PROBED HERE, EVER. Only the CLI probes
|
|
771
1066
|
* run on a heartbeat; Pi discovery starts an RPC process, and doing that
|
|
@@ -791,6 +1086,10 @@ class Harness {
|
|
|
791
1086
|
if (response.heartbeatIntervalMs) {
|
|
792
1087
|
this.heartbeatIntervalMs = response.heartbeatIntervalMs;
|
|
793
1088
|
}
|
|
1089
|
+
this.ackedHarnessesDigest =
|
|
1090
|
+
typeof response.harnessesDigest === 'string'
|
|
1091
|
+
? response.harnessesDigest
|
|
1092
|
+
: null;
|
|
794
1093
|
this.observeHarnessPin(response.harnessVersion);
|
|
795
1094
|
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
796
1095
|
this.applyCommandsSafely(response.sessionCommands ?? []);
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,15 @@ export type { ProbeCapabilitiesOptions, ProbeResult } from './capabilities';
|
|
|
24
24
|
*/
|
|
25
25
|
export { flattenProviderModels, probeBackendModels, probeModelCatalog } from './model-probe';
|
|
26
26
|
export type { ModelCatalog, ModelProbeAdapterFactory, ProviderRecord } from './model-probe';
|
|
27
|
+
/**
|
|
28
|
+
* Harness discovery — which coding harnesses (Codex, Claude Code, Cursor, Pi,
|
|
29
|
+
* OpenCode) are installed on this machine, whether each is signed in, and the
|
|
30
|
+
* models each offers. Reported as `capabilities.harnesses`, apart from
|
|
31
|
+
* `capabilities.backends`: finding a harness does not mean this runner can
|
|
32
|
+
* run a turn with it.
|
|
33
|
+
*/
|
|
34
|
+
export { discoverHarnesses, KNOWN_HARNESSES, DISCOVERY_COMMAND_TIMEOUT_MS, DISCOVERY_HARNESS_TIMEOUT_MS } from './harness-discovery';
|
|
35
|
+
export type { DiscoverHarnessesOptions, DiscoveryDeps, DiscoveryExecResult } from './harness-discovery';
|
|
27
36
|
export type { ArtifactInspector, EventSink, HarnessPorts, InspectedArtifact, Notifier, SecretProvider, StoredPart, StoredPartInput, TaskMcpProvider, TaskMcpScope, Telemetry, TokenInjector, TranscriptStore, WorkspaceProvider, WorkspaceRequest } from './ports';
|
|
28
37
|
export { CompositeEventSink, DirectoryWorkspaceProvider, NoArtifactInspector, NoSecretProvider, NoopTelemetry, NullEventSink, ServerNotifier, SessionTokenInjector, StaticTaskMcpProvider } from './ports-default';
|
|
29
38
|
export type { DirectoryWorkspaceOptions } from './ports-default';
|
package/dist/index.js
CHANGED
|
@@ -23,8 +23,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
23
23
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
24
24
|
};
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.
|
|
27
|
-
exports.isProtocolSupported = exports.isPersistablePart = exports.isFinalStatus = exports.isEphemeralPartType = exports.ingestPartByteLength = exports.ROUTES = exports.PROTOCOL_VERSION_HEADER = exports.PROTOCOL_VERSION = exports.PERSISTED_PART_TYPES = exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = void 0;
|
|
26
|
+
exports.HARNESS_UPGRADE_EXIT_CODE = exports.FINAL_HTTP_STATUSES = exports.EPHEMERAL_PART_TYPES = exports.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_LEASE_WAIT_SECONDS = exports.DEFAULT_LEASE_TTL_MS = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.DEFAULT_BASE_DELAY_MS = exports.CONTRACT_ERROR_CODES = exports.openTranscriptStore = exports.SqliteTranscriptStore = exports.MemoryTranscriptStore = exports.writeToChildStdin = exports.isBenignStreamError = exports.guardStream = exports.guardChildStreams = exports.toIngestPart = exports.projectParts = exports.SessionRunner = exports.McpAuthProxy = exports.GitHubWorkspaceProvider = exports.StaticTaskMcpProvider = exports.SessionTokenInjector = exports.ServerNotifier = exports.NullEventSink = exports.NoopTelemetry = exports.NoSecretProvider = exports.NoArtifactInspector = exports.DirectoryWorkspaceProvider = exports.CompositeEventSink = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = exports.KNOWN_HARNESSES = exports.discoverHarnesses = exports.probeModelCatalog = exports.probeBackendModels = exports.flattenProviderModels = exports.probeCapabilities = exports.probe = exports.getPiVersion = exports.getHarnessVersion = exports.parseOptions = exports.OptionsError = exports.HealthServer = exports.ProtocolRefusedError = exports.ControlPlaneError = exports.ControlPlaneClient = exports.VersionRefusedError = exports.UpgradeRequestedError = exports.Harness = void 0;
|
|
27
|
+
exports.isProtocolSupported = exports.isPersistablePart = exports.isFinalStatus = exports.isEphemeralPartType = exports.ingestPartByteLength = exports.ROUTES = exports.PROTOCOL_VERSION_HEADER = exports.PROTOCOL_VERSION = exports.PERSISTED_PART_TYPES = exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = exports.MIN_SUPPORTED_PROTOCOL_VERSION = exports.MAX_LEASE_WAIT_SECONDS = exports.MAX_INGEST_PART_BYTES = exports.MAX_INGEST_PARTS = void 0;
|
|
28
28
|
/* ---- The process ---------------------------------------------------------- */
|
|
29
29
|
var harness_1 = require("./harness");
|
|
30
30
|
Object.defineProperty(exports, "Harness", { enumerable: true, get: function () { return harness_1.Harness; } });
|
|
@@ -55,6 +55,18 @@ var model_probe_1 = require("./model-probe");
|
|
|
55
55
|
Object.defineProperty(exports, "flattenProviderModels", { enumerable: true, get: function () { return model_probe_1.flattenProviderModels; } });
|
|
56
56
|
Object.defineProperty(exports, "probeBackendModels", { enumerable: true, get: function () { return model_probe_1.probeBackendModels; } });
|
|
57
57
|
Object.defineProperty(exports, "probeModelCatalog", { enumerable: true, get: function () { return model_probe_1.probeModelCatalog; } });
|
|
58
|
+
/**
|
|
59
|
+
* Harness discovery — which coding harnesses (Codex, Claude Code, Cursor, Pi,
|
|
60
|
+
* OpenCode) are installed on this machine, whether each is signed in, and the
|
|
61
|
+
* models each offers. Reported as `capabilities.harnesses`, apart from
|
|
62
|
+
* `capabilities.backends`: finding a harness does not mean this runner can
|
|
63
|
+
* run a turn with it.
|
|
64
|
+
*/
|
|
65
|
+
var harness_discovery_1 = require("./harness-discovery");
|
|
66
|
+
Object.defineProperty(exports, "discoverHarnesses", { enumerable: true, get: function () { return harness_discovery_1.discoverHarnesses; } });
|
|
67
|
+
Object.defineProperty(exports, "KNOWN_HARNESSES", { enumerable: true, get: function () { return harness_discovery_1.KNOWN_HARNESSES; } });
|
|
68
|
+
Object.defineProperty(exports, "DISCOVERY_COMMAND_TIMEOUT_MS", { enumerable: true, get: function () { return harness_discovery_1.DISCOVERY_COMMAND_TIMEOUT_MS; } });
|
|
69
|
+
Object.defineProperty(exports, "DISCOVERY_HARNESS_TIMEOUT_MS", { enumerable: true, get: function () { return harness_discovery_1.DISCOVERY_HARNESS_TIMEOUT_MS; } });
|
|
58
70
|
var ports_default_1 = require("./ports-default");
|
|
59
71
|
Object.defineProperty(exports, "CompositeEventSink", { enumerable: true, get: function () { return ports_default_1.CompositeEventSink; } });
|
|
60
72
|
Object.defineProperty(exports, "DirectoryWorkspaceProvider", { enumerable: true, get: function () { return ports_default_1.DirectoryWorkspaceProvider; } });
|
|
@@ -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
|
+
}
|