@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
package/dist/harness.js
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = void 0;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const model_gateway_1 = require("./adapters/model-gateway");
|
|
6
|
+
const capabilities_1 = require("./capabilities");
|
|
7
|
+
const contract_1 = require("./contract");
|
|
8
|
+
const control_plane_1 = require("./control-plane");
|
|
9
|
+
const credential_store_1 = require("./credential-store");
|
|
10
|
+
const github_command_guards_1 = require("./github-command-guards");
|
|
11
|
+
const github_workspace_1 = require("./github-workspace");
|
|
12
|
+
const health_1 = require("./health");
|
|
13
|
+
const model_probe_1 = require("./model-probe");
|
|
14
|
+
const ports_default_1 = require("./ports-default");
|
|
15
|
+
const lease_renewal_1 = require("./runtime/lease-renewal");
|
|
16
|
+
const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
|
|
17
|
+
const session_runner_1 = require("./runtime/session-runner");
|
|
18
|
+
const conversation_store_1 = require("./store/conversation-store");
|
|
19
|
+
const sqlite_store_1 = require("./store/sqlite-store");
|
|
20
|
+
const version_1 = require("./version");
|
|
21
|
+
/** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
|
|
22
|
+
const LEASE_ERROR_BACKOFF_MS = 5_000;
|
|
23
|
+
/**
|
|
24
|
+
* How often the model catalogue is re-probed once the harness is running.
|
|
25
|
+
*
|
|
26
|
+
* A long interval, deliberately: Pi model discovery starts a short-lived RPC
|
|
27
|
+
* process. Every six hours is a bounded refresh of a slowly changing catalog.
|
|
28
|
+
*/
|
|
29
|
+
const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
30
|
+
/**
|
|
31
|
+
* The control plane refused this harness's PROTOCOL or VERSION. FINAL.
|
|
32
|
+
*
|
|
33
|
+
* A distinct class rather than a message match, so a caller cannot break the
|
|
34
|
+
* gate by rewording an error string. The process must EXIT — not merely throw —
|
|
35
|
+
* because the health socket keeps the Node event loop alive for ever, and a
|
|
36
|
+
* harness that only threw would hang while the gate quietly never took effect.
|
|
37
|
+
* Phase 0 shipped exactly that bug.
|
|
38
|
+
*/
|
|
39
|
+
class VersionRefusedError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'VersionRefusedError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.VersionRefusedError = VersionRefusedError;
|
|
46
|
+
/** The sandbox supervisor, not the harness, owns package installation. */
|
|
47
|
+
class UpgradeRequestedError extends Error {
|
|
48
|
+
harnessVersion;
|
|
49
|
+
constructor(harnessVersion) {
|
|
50
|
+
super(`Harness upgrade requested: ${harnessVersion}`);
|
|
51
|
+
this.harnessVersion = harnessVersion;
|
|
52
|
+
this.name = 'UpgradeRequestedError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.UpgradeRequestedError = UpgradeRequestedError;
|
|
56
|
+
function log(message, extra) {
|
|
57
|
+
process.stdout.write(`${JSON.stringify({
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
component: 'pf-agent-harness',
|
|
60
|
+
message,
|
|
61
|
+
...extra
|
|
62
|
+
})}\n`);
|
|
63
|
+
}
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
setTimeout(resolve, ms);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* THE HARNESS.
|
|
71
|
+
*
|
|
72
|
+
* ONE PROGRAM, THREE HOSTING MODES. A Daytona sandbox, a customer VM and the
|
|
73
|
+
* 20x desktop run this identical loop; nothing below asks which it is. Where a
|
|
74
|
+
* host genuinely differs it supplies a PORT, and the ports are capabilities
|
|
75
|
+
* ("can you notify a human?"), never identities ("are you the desktop?").
|
|
76
|
+
*
|
|
77
|
+
* THE LOOP, and why it has this shape:
|
|
78
|
+
*
|
|
79
|
+
* register -> heartbeat (30s, forever) + lease (25s poll, forever)
|
|
80
|
+
*
|
|
81
|
+
* Both are OUTBOUND. Nothing dials in, there is no listening port reachable
|
|
82
|
+
* from the network, and that single property is what lets a customer VM behind
|
|
83
|
+
* NAT work with no tunnel and no firewall change. It is also why an abort or a
|
|
84
|
+
* user's answer to a question rides back on `sessionCommands` in the HEARTBEAT
|
|
85
|
+
* response rather than arriving on a socket.
|
|
86
|
+
*
|
|
87
|
+
* Long-poll rather than a WebSocket because Cloud Run bills a request slot for
|
|
88
|
+
* the WHOLE life of a connection and caps it at 3600s. One persistent socket per
|
|
89
|
+
* runner would be one billed slot per runner, permanently.
|
|
90
|
+
*/
|
|
91
|
+
class Harness {
|
|
92
|
+
options;
|
|
93
|
+
deps;
|
|
94
|
+
client;
|
|
95
|
+
health;
|
|
96
|
+
proxy;
|
|
97
|
+
store;
|
|
98
|
+
/** The model-facing conversation log. See `ports.ts#ConversationStore`. */
|
|
99
|
+
conversation;
|
|
100
|
+
/** False when the log is in memory only, so a restart forgets. Reported. */
|
|
101
|
+
conversationDurable;
|
|
102
|
+
storeDurable;
|
|
103
|
+
storeReason;
|
|
104
|
+
workspace;
|
|
105
|
+
secrets;
|
|
106
|
+
events;
|
|
107
|
+
telemetry;
|
|
108
|
+
notifier;
|
|
109
|
+
artifacts;
|
|
110
|
+
tokens = new ports_default_1.SessionTokenInjector(null);
|
|
111
|
+
startedAt = Date.now();
|
|
112
|
+
status = 'starting';
|
|
113
|
+
tenantId = null;
|
|
114
|
+
runnerId = null;
|
|
115
|
+
capabilities = null;
|
|
116
|
+
/**
|
|
117
|
+
* The cached model catalogue, probed at start-up and on a LONG interval.
|
|
118
|
+
*
|
|
119
|
+
* Probed ONCE, not per heartbeat: Pi discovery starts a short-lived RPC
|
|
120
|
+
* process, and doing that every 30 seconds would be a regression. The cache
|
|
121
|
+
* is the flat `models` + per-backend list
|
|
122
|
+
* that every capability report after start reuses.
|
|
123
|
+
*/
|
|
124
|
+
modelCatalog = null;
|
|
125
|
+
/** Epoch ms of the last successful (or attempted) model probe. */
|
|
126
|
+
modelProbeAt = 0;
|
|
127
|
+
/**
|
|
128
|
+
* Empty gateway for the model probe. The probe builds a FRESH adapter and
|
|
129
|
+
* must not read a live turn's credential; a null config is "no gateway",
|
|
130
|
+
* which makes the probe list the host's own local models — and the control
|
|
131
|
+
* plane separately supplies the gateway list.
|
|
132
|
+
*/
|
|
133
|
+
modelProbeGateway = new model_gateway_1.SessionModelGateway();
|
|
134
|
+
heartbeatIntervalMs;
|
|
135
|
+
leaseWaitSeconds = contract_1.DEFAULT_LEASE_WAIT_SECONDS;
|
|
136
|
+
heartbeatTimer = null;
|
|
137
|
+
/** Fires when the runner credential should be rotated. Durable runtimes re-persist the new one. */
|
|
138
|
+
rotateTimer = null;
|
|
139
|
+
credentialStore;
|
|
140
|
+
/** True when this run reused a persisted credential rather than enrolling. */
|
|
141
|
+
reusedStoredCredential = false;
|
|
142
|
+
lastHeartbeatAt = null;
|
|
143
|
+
lastHeartbeatError = null;
|
|
144
|
+
consecutiveHeartbeatFailures = 0;
|
|
145
|
+
stopping = false;
|
|
146
|
+
/** Releases this Harness's reference to the process-wide generic wrappers. */
|
|
147
|
+
releaseGitHubGuard = null;
|
|
148
|
+
/** sessionId -> the controller that aborts its turn. */
|
|
149
|
+
active = new Map();
|
|
150
|
+
/** Renewal workers that must receive a credential rotation in flight. */
|
|
151
|
+
activeRenewals = new Set();
|
|
152
|
+
/** commandIds already applied. `SessionCommand` is idempotent by contract. */
|
|
153
|
+
appliedCommands = new Set();
|
|
154
|
+
/** Session-end cleanup waits for an active turn to save its dirty work. */
|
|
155
|
+
pendingWorkspaceReleases = new Set();
|
|
156
|
+
/**
|
|
157
|
+
* sessionId -> the runner executing its turn.
|
|
158
|
+
*
|
|
159
|
+
* `active` above holds only an AbortController, which is all an `abort`
|
|
160
|
+
* command needs. An `answer` needs the RUNNER: the adapter and the backend
|
|
161
|
+
* session id live inside the turn, and the answer has to reach them from the
|
|
162
|
+
* command handler's stack. This map is what makes wire 3 possible.
|
|
163
|
+
*/
|
|
164
|
+
runners = new Map();
|
|
165
|
+
leaseLoop = null;
|
|
166
|
+
/** Set when the control plane refuses this build. Ends the process. */
|
|
167
|
+
fatal = null;
|
|
168
|
+
/** A changed pin waits here until every live lease has completed. */
|
|
169
|
+
pendingHarnessVersion = null;
|
|
170
|
+
/** Same idea, for the INDEPENDENT sandbox-tools pin (gh, node deps, ...). */
|
|
171
|
+
pendingSandboxToolsVersion = null;
|
|
172
|
+
constructor(options, deps = {}) {
|
|
173
|
+
this.options = options;
|
|
174
|
+
this.deps = deps;
|
|
175
|
+
this.client =
|
|
176
|
+
deps.clientFactory?.(options.controlPlaneUrl) ??
|
|
177
|
+
new control_plane_1.ControlPlaneClient(options.controlPlaneUrl);
|
|
178
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs;
|
|
179
|
+
this.health = new health_1.HealthServer(options.healthPort, () => this.snapshot());
|
|
180
|
+
const opened = deps.store !== undefined
|
|
181
|
+
? { store: deps.store, durable: true, reason: undefined }
|
|
182
|
+
: (0, sqlite_store_1.openTranscriptStore)((0, node_path_1.join)(options.workspaceRoot, 'harness.sqlite'));
|
|
183
|
+
this.store = opened.store;
|
|
184
|
+
this.storeDurable = opened.durable;
|
|
185
|
+
this.storeReason = opened.reason;
|
|
186
|
+
/*
|
|
187
|
+
* WHERE THE CONVERSATION LIVES BETWEEN TURNS.
|
|
188
|
+
*
|
|
189
|
+
* Kept in its OWN sqlite file rather than beside the transcript, because
|
|
190
|
+
* the two answer to different owners: `harness.sqlite` is a cache of a UI
|
|
191
|
+
* projection and may be dropped whenever it is convenient, while this is
|
|
192
|
+
* the model's memory and dropping it is a user-visible regression. A
|
|
193
|
+
* separate file makes "clear the transcript cache" a safe operation.
|
|
194
|
+
*/
|
|
195
|
+
const conversation = deps.conversation !== undefined
|
|
196
|
+
? { store: deps.conversation, durable: true, reason: undefined }
|
|
197
|
+
: (0, conversation_store_1.openConversationStore)((0, node_path_1.join)(options.workspaceRoot, 'conversation.sqlite'));
|
|
198
|
+
this.conversation = conversation.store;
|
|
199
|
+
this.conversationDurable = conversation.durable;
|
|
200
|
+
this.workspace =
|
|
201
|
+
deps.workspace ??
|
|
202
|
+
new github_workspace_1.GitHubWorkspaceProvider({
|
|
203
|
+
root: options.workspaceRoot,
|
|
204
|
+
port: options.githubProxyPort,
|
|
205
|
+
directUrls: [options.controlPlaneUrl]
|
|
206
|
+
});
|
|
207
|
+
this.secrets = deps.secrets ?? new ports_default_1.NoSecretProvider();
|
|
208
|
+
this.events = deps.events ?? new ports_default_1.NullEventSink();
|
|
209
|
+
this.telemetry = deps.telemetry ?? new ports_default_1.NoopTelemetry();
|
|
210
|
+
this.notifier = deps.notifier ?? new ports_default_1.ServerNotifier();
|
|
211
|
+
this.artifacts = deps.artifacts ?? new ports_default_1.NoArtifactInspector();
|
|
212
|
+
this.credentialStore =
|
|
213
|
+
deps.credentialStore ??
|
|
214
|
+
(0, credential_store_1.createCredentialStore)(options.runtime, options.workspaceRoot);
|
|
215
|
+
this.proxy = new mcp_auth_proxy_1.McpAuthProxy({
|
|
216
|
+
tokens: this.tokens,
|
|
217
|
+
notifier: this.notifier
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** The actual bound health port. Used by tests to prove the socket closed. */
|
|
221
|
+
getHealthPort() {
|
|
222
|
+
return this.health.boundPort;
|
|
223
|
+
}
|
|
224
|
+
snapshot() {
|
|
225
|
+
return {
|
|
226
|
+
// 'busy' is deliberately NOT reported here. A probe asks "may this host be
|
|
227
|
+
// used at all", and a host mid-turn is still perfectly healthy.
|
|
228
|
+
status: this.status === 'busy' ? 'ready' : this.status,
|
|
229
|
+
harnessVersion: (0, version_1.getHarnessVersion)(),
|
|
230
|
+
nodeVersion: process.version,
|
|
231
|
+
piVersion: (0, version_1.getPiVersion)(),
|
|
232
|
+
registered: this.client.isRegistered(),
|
|
233
|
+
tenantId: this.tenantId,
|
|
234
|
+
// Phase 0 called it sandboxId. The canonical name is runnerId; the old
|
|
235
|
+
// field is still populated so an existing probe does not break on the
|
|
236
|
+
// upgrade, and it goes when nothing reads it.
|
|
237
|
+
sandboxId: this.runnerId,
|
|
238
|
+
runnerId: this.runnerId,
|
|
239
|
+
protocolVersion: undefined,
|
|
240
|
+
// A harness that cannot durably hold the conversation is degraded, not
|
|
241
|
+
// merely slower: on a restart it forgets every session it was mid-way
|
|
242
|
+
// through, and the user sees an agent that lost the thread. The control
|
|
243
|
+
// plane still holds the record, so it is recoverable — which is exactly
|
|
244
|
+
// why it must be VISIBLE rather than silent.
|
|
245
|
+
degraded: this.options.degraded ||
|
|
246
|
+
!this.storeDurable ||
|
|
247
|
+
!this.conversationDurable,
|
|
248
|
+
degradedReason: this.options.degradedReason ??
|
|
249
|
+
(this.storeDurable ? undefined : this.storeReason) ??
|
|
250
|
+
(this.conversationDurable
|
|
251
|
+
? undefined
|
|
252
|
+
: 'conversation log is in memory only; a restart will forget history'),
|
|
253
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
254
|
+
activeSessions: this.active.size,
|
|
255
|
+
lastHeartbeatAt: this.lastHeartbeatAt,
|
|
256
|
+
lastHeartbeatError: this.lastHeartbeatError
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/* ------------------------------------------------------------------ */
|
|
260
|
+
/* Lifecycle */
|
|
261
|
+
/* ------------------------------------------------------------------ */
|
|
262
|
+
async start() {
|
|
263
|
+
// The health check comes up FIRST, so a probe sees 'starting' rather than a
|
|
264
|
+
// connection refused while registration is still retrying.
|
|
265
|
+
await this.health.start();
|
|
266
|
+
await this.proxy.start();
|
|
267
|
+
this.capabilities = await (0, capabilities_1.probeCapabilities)({
|
|
268
|
+
maxConcurrentSessions: this.options.maxConcurrentSessions,
|
|
269
|
+
labels: this.options.labels
|
|
270
|
+
});
|
|
271
|
+
// Install immediately before model probing can start a backend. The lease
|
|
272
|
+
// is process-wide and reference-counted, so concurrent Harness instances
|
|
273
|
+
// share one generic launcher instead of racing to rewrite global PATH.
|
|
274
|
+
if (this.options.runtime === 'sandbox') {
|
|
275
|
+
const guard = await (0, github_command_guards_1.acquireGhGuardPath)(this.options.workspaceRoot);
|
|
276
|
+
this.releaseGitHubGuard = guard.release;
|
|
277
|
+
}
|
|
278
|
+
/*
|
|
279
|
+
* MODELS ARE PROBED ONCE, AT START-UP — not on every heartbeat, because
|
|
280
|
+
* enumerating providers starts a Pi discovery process. The catalogue is
|
|
281
|
+
* attached to every capability report after this; a re-probe happens only
|
|
282
|
+
* on a long interval or when a backend newly appears.
|
|
283
|
+
*/
|
|
284
|
+
await this.refreshModelCatalog(true);
|
|
285
|
+
this.applyModelCatalog(this.capabilities);
|
|
286
|
+
log('Harness starting', {
|
|
287
|
+
harnessVersion: (0, version_1.getHarnessVersion)(),
|
|
288
|
+
healthPort: this.options.healthPort,
|
|
289
|
+
backends: this.capabilities.backends.map((b) => b.name),
|
|
290
|
+
git: this.capabilities.git,
|
|
291
|
+
docker: this.capabilities.docker,
|
|
292
|
+
durableStore: this.storeDurable,
|
|
293
|
+
durableConversation: this.conversationDurable,
|
|
294
|
+
degraded: this.options.degraded
|
|
295
|
+
});
|
|
296
|
+
if (!this.storeDurable) {
|
|
297
|
+
// Visible, not silent. A fallback nobody can see is how an outage becomes
|
|
298
|
+
// a mystery three weeks later.
|
|
299
|
+
log('Durable store unavailable, running in memory', {
|
|
300
|
+
reason: this.storeReason
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await this.register();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
// The health server and the proxy are already listening, and a listening
|
|
308
|
+
// server keeps the Node event loop alive for ever. Without this the
|
|
309
|
+
// process would HANG on a refused registration instead of exiting, and
|
|
310
|
+
// the version gate would never recycle the host.
|
|
311
|
+
await this.stop();
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
this.status = 'ready';
|
|
315
|
+
log('Harness ready', { tenantId: this.tenantId, runnerId: this.runnerId });
|
|
316
|
+
this.scheduleHeartbeat();
|
|
317
|
+
this.leaseLoop = this.runLeaseLoop();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Block until the harness stops for a reason worth reporting.
|
|
321
|
+
*
|
|
322
|
+
* Resolves on a clean drain; rejects when the control plane refused this
|
|
323
|
+
* build, so the CLI can exit non-zero and the host is recycled onto a
|
|
324
|
+
* supported version.
|
|
325
|
+
*/
|
|
326
|
+
async wait() {
|
|
327
|
+
await this.leaseLoop;
|
|
328
|
+
if (this.fatal) {
|
|
329
|
+
throw this.fatal;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async stop(options = {}) {
|
|
333
|
+
if (this.stopping) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.stopping = true;
|
|
337
|
+
this.status = 'draining';
|
|
338
|
+
if (this.heartbeatTimer) {
|
|
339
|
+
clearTimeout(this.heartbeatTimer);
|
|
340
|
+
this.heartbeatTimer = null;
|
|
341
|
+
}
|
|
342
|
+
if (this.rotateTimer) {
|
|
343
|
+
clearTimeout(this.rotateTimer);
|
|
344
|
+
this.rotateTimer = null;
|
|
345
|
+
}
|
|
346
|
+
// Abort in-flight turns BEFORE deregistering, so the control plane's
|
|
347
|
+
// requeue count is accurate rather than racing the turns it is counting.
|
|
348
|
+
const hadActive = this.active.size > 0;
|
|
349
|
+
for (const controller of this.active.values()) {
|
|
350
|
+
controller.abort();
|
|
351
|
+
}
|
|
352
|
+
/*
|
|
353
|
+
* A DURABLE RUNNER DOES NOT DEREGISTER.
|
|
354
|
+
*
|
|
355
|
+
* Deregistration REVOKES the credential server-side (handlers/runners.ts —
|
|
356
|
+
* it nulls credentialHash). For a sandbox that is correct: it is ephemeral,
|
|
357
|
+
* its credential is memory-only, and a fresh one always exists. But a
|
|
358
|
+
* self-hosted or desktop runner PERSISTS its credential precisely so it can
|
|
359
|
+
* restart on it — and a clean SIGTERM (a systemd restart, a host reboot)
|
|
360
|
+
* would otherwise revoke the very credential we saved, re-bricking the host
|
|
361
|
+
* we just un-bricked. Skipping deregister keeps that credential valid; the
|
|
362
|
+
* in-flight leases are still recovered, by the same heartbeat-liveness path
|
|
363
|
+
* a crash relies on (three missed beats, then the watchdog requeues).
|
|
364
|
+
*
|
|
365
|
+
* `sandbox` still says goodbye, so its leases requeue immediately.
|
|
366
|
+
*/
|
|
367
|
+
const durable = this.options.runtime !== 'sandbox';
|
|
368
|
+
if (this.client.isRegistered() &&
|
|
369
|
+
!durable &&
|
|
370
|
+
!options.preserveRegistration) {
|
|
371
|
+
try {
|
|
372
|
+
await this.client.deregister('shutdown', hadActive);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
// A goodbye that cannot be delivered must never block a shutdown.
|
|
376
|
+
// Liveness is the heartbeat: three missed beats and the watchdog
|
|
377
|
+
// requeues everything this runner held.
|
|
378
|
+
log('Deregister failed, continuing shutdown', {
|
|
379
|
+
error: error instanceof Error ? error.message : String(error)
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (durable && this.client.isRegistered()) {
|
|
384
|
+
log('Durable runner: skipping deregister to preserve the credential', {
|
|
385
|
+
runtime: this.options.runtime,
|
|
386
|
+
hadActiveSessions: hadActive
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
await this.proxy.stop();
|
|
390
|
+
await this.workspace.stop?.();
|
|
391
|
+
await this.health.stop();
|
|
392
|
+
this.releaseGitHubGuard?.();
|
|
393
|
+
this.releaseGitHubGuard = null;
|
|
394
|
+
this.tokens.revoke();
|
|
395
|
+
if (!options.preserveRegistration) {
|
|
396
|
+
this.client.forgetCredential();
|
|
397
|
+
}
|
|
398
|
+
log('Harness stopped');
|
|
399
|
+
}
|
|
400
|
+
/* ------------------------------------------------------------------ */
|
|
401
|
+
/* Registration */
|
|
402
|
+
/* ------------------------------------------------------------------ */
|
|
403
|
+
async register() {
|
|
404
|
+
const capabilities = this.capabilities ?? (await (0, capabilities_1.probeCapabilities)());
|
|
405
|
+
// 1. REUSE A PERSISTED CREDENTIAL when there is a valid one.
|
|
406
|
+
//
|
|
407
|
+
// This is the whole fix for "a crashed harness can never rejoin": a
|
|
408
|
+
// self-hosted or desktop runner that was SIGKILLed, OOM-ed or rebooted comes
|
|
409
|
+
// back on its stored, still-valid credential — no fresh single-use enrolment
|
|
410
|
+
// token required. `sandbox` uses a no-op store, so it always falls through
|
|
411
|
+
// to enrolment, where a fresh token always exists.
|
|
412
|
+
if (await this.tryStoredCredential(capabilities)) {
|
|
413
|
+
this.reusedStoredCredential = true;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// 2. ENROL. The ordinary first-boot path, and the fallback when no usable
|
|
417
|
+
// stored credential exists (absent, expired or rejected).
|
|
418
|
+
if (!this.options.enrolmentToken) {
|
|
419
|
+
throw new Error('No usable stored runner credential and no enrolment token. Provide ' +
|
|
420
|
+
'--token / PF_ENROLMENT_TOKEN for the first registration.');
|
|
421
|
+
}
|
|
422
|
+
// Back-off and the final-status short-circuit both live in the client, so
|
|
423
|
+
// every route obeys the same retry rule and no route can drift from it.
|
|
424
|
+
const response = await this.client.register({
|
|
425
|
+
enrolmentToken: this.options.enrolmentToken,
|
|
426
|
+
capabilities,
|
|
427
|
+
runtime: this.options.runtime,
|
|
428
|
+
degraded: this.options.degraded,
|
|
429
|
+
degradedReason: this.options.degradedReason
|
|
430
|
+
});
|
|
431
|
+
// FAIL CLOSED. `isRegistered()` is the real invariant, not `accepted`:
|
|
432
|
+
// a 200 that simply omits the field must not be read as consent.
|
|
433
|
+
if (response.accepted === false || !this.client.isRegistered()) {
|
|
434
|
+
// "refused harness version" is asserted verbatim by the sandbox e2e
|
|
435
|
+
// against the REAL image. Rewording it turns a passing check into one
|
|
436
|
+
// that can never fail, which is the whole reason the final-status list
|
|
437
|
+
// is matched on the STATUS and never on a message.
|
|
438
|
+
throw new VersionRefusedError(`Control plane refused harness version ${(0, version_1.getHarnessVersion)()}: ${response.reason ?? 'no runner credential in the response'}${response.minProtocolVersion
|
|
439
|
+
? ` (min protocol ${response.minProtocolVersion})`
|
|
440
|
+
: ''}`);
|
|
441
|
+
}
|
|
442
|
+
this.tenantId = response.tenantId ?? null;
|
|
443
|
+
this.runnerId = this.client.getRunnerId();
|
|
444
|
+
if (response.heartbeatIntervalMs) {
|
|
445
|
+
this.heartbeatIntervalMs = response.heartbeatIntervalMs;
|
|
446
|
+
}
|
|
447
|
+
if (response.leaseWaitSeconds) {
|
|
448
|
+
this.leaseWaitSeconds = response.leaseWaitSeconds;
|
|
449
|
+
}
|
|
450
|
+
// PERSIST FOR DURABLE RUNTIMES, so the next boot after a crash reuses this
|
|
451
|
+
// credential instead of re-enrolling with a token that has already burned.
|
|
452
|
+
this.persistCredential(response);
|
|
453
|
+
this.scheduleRotation(response.rotateAfter, response.credentialExpiresAt);
|
|
454
|
+
// This exact phrase is asserted by the sandbox e2e suite against the REAL
|
|
455
|
+
// image. Rewording it silently turns a passing check into one that can
|
|
456
|
+
// never fail, so it is kept verbatim from Phase 0.
|
|
457
|
+
log('Registered with control plane', {
|
|
458
|
+
tenantId: this.tenantId,
|
|
459
|
+
runnerId: this.runnerId,
|
|
460
|
+
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
461
|
+
leaseWaitSeconds: this.leaseWaitSeconds
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Try to authenticate with a persisted credential. Returns true on success.
|
|
466
|
+
*
|
|
467
|
+
* The stored credential is VALIDATED by an actual authenticated call — a
|
|
468
|
+
* heartbeat, the lightest one — before the harness commits to it. That is what
|
|
469
|
+
* makes the fallback safe: a stored credential that the control plane has
|
|
470
|
+
* revoked (a `401`/`403`) is dropped here and enrolment is retried, so a stale
|
|
471
|
+
* credential cannot wedge the runner. A TRANSIENT failure is NOT treated as a
|
|
472
|
+
* rejection — re-enrolling on a network blip would burn an already-spent token
|
|
473
|
+
* — so the harness keeps the credential and lets the normal loop retry.
|
|
474
|
+
*/
|
|
475
|
+
async tryStoredCredential(capabilities) {
|
|
476
|
+
const stored = this.credentialStore.load();
|
|
477
|
+
if (!stored) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
if (stored.credentialExpiresAt &&
|
|
481
|
+
Date.parse(stored.credentialExpiresAt) <= Date.now()) {
|
|
482
|
+
log('Stored runner credential has expired; re-enrolling', {
|
|
483
|
+
runnerId: stored.runnerId,
|
|
484
|
+
credentialExpiresAt: stored.credentialExpiresAt
|
|
485
|
+
});
|
|
486
|
+
this.credentialStore.clear();
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
this.client.useStoredCredential(stored.runnerId, stored.credential);
|
|
490
|
+
try {
|
|
491
|
+
const response = await this.client.heartbeat({
|
|
492
|
+
status: this.status,
|
|
493
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
494
|
+
activeSessions: 0,
|
|
495
|
+
activeSessionIds: [],
|
|
496
|
+
degraded: this.options.degraded || !this.storeDurable,
|
|
497
|
+
capabilities
|
|
498
|
+
});
|
|
499
|
+
this.tenantId = stored.tenantId;
|
|
500
|
+
this.runnerId = stored.runnerId;
|
|
501
|
+
if (response.heartbeatIntervalMs) {
|
|
502
|
+
this.heartbeatIntervalMs = response.heartbeatIntervalMs;
|
|
503
|
+
}
|
|
504
|
+
this.observeHarnessPin(response.harnessVersion);
|
|
505
|
+
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
506
|
+
for (const command of response.sessionCommands ?? []) {
|
|
507
|
+
this.applyCommand(command);
|
|
508
|
+
}
|
|
509
|
+
log('Reused stored runner credential', {
|
|
510
|
+
runnerId: stored.runnerId,
|
|
511
|
+
tenantId: stored.tenantId
|
|
512
|
+
});
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
const rejected = error instanceof control_plane_1.ControlPlaneError &&
|
|
517
|
+
(error.status === 401 || error.status === 403);
|
|
518
|
+
if (rejected) {
|
|
519
|
+
log('Stored runner credential was rejected; re-enrolling', {
|
|
520
|
+
runnerId: stored.runnerId,
|
|
521
|
+
status: error.status,
|
|
522
|
+
code: error.code
|
|
523
|
+
});
|
|
524
|
+
this.credentialStore.clear();
|
|
525
|
+
this.client.forgetCredential();
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
// Transient (a network blip, a 5xx, or the 426 running-fleet gate, which
|
|
529
|
+
// the lease loop turns fatal). Keep the credential and proceed — the
|
|
530
|
+
// heartbeat and lease loops retry, and re-enrolling here would throw away
|
|
531
|
+
// a good credential over a momentary outage.
|
|
532
|
+
this.tenantId = stored.tenantId;
|
|
533
|
+
this.runnerId = stored.runnerId;
|
|
534
|
+
log('Could not validate stored credential (transient); proceeding', {
|
|
535
|
+
runnerId: stored.runnerId,
|
|
536
|
+
error: error instanceof Error ? error.message : String(error)
|
|
537
|
+
});
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Persist the runner credential for durable runtimes.
|
|
543
|
+
*
|
|
544
|
+
* A no-op for `sandbox` (its store discards everything) and for a response
|
|
545
|
+
* that carried no credential. This is what a crash on the NEXT run reuses.
|
|
546
|
+
*/
|
|
547
|
+
persistCredential(response) {
|
|
548
|
+
if (!response.runnerCredential || !this.runnerId) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
this.credentialStore.save({
|
|
552
|
+
runnerId: this.runnerId,
|
|
553
|
+
tenantId: this.tenantId,
|
|
554
|
+
credential: response.runnerCredential,
|
|
555
|
+
credentialExpiresAt: response.credentialExpiresAt ?? null,
|
|
556
|
+
savedAt: new Date().toISOString()
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/* ------------------------------------------------------------------ */
|
|
560
|
+
/* Credential rotation */
|
|
561
|
+
/* ------------------------------------------------------------------ */
|
|
562
|
+
/**
|
|
563
|
+
* Schedule a rotation at `rotateAfter`, comfortably before the credential
|
|
564
|
+
* expires. The control plane owns the cadence; if it names neither a
|
|
565
|
+
* `rotateAfter` nor an expiry, no rotation is scheduled and the credential
|
|
566
|
+
* simply lives out its TTL.
|
|
567
|
+
*/
|
|
568
|
+
scheduleRotation(rotateAfterIso, credentialExpiresAtIso) {
|
|
569
|
+
if (this.stopping) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
let delay = null;
|
|
574
|
+
if (rotateAfterIso) {
|
|
575
|
+
delay = Math.max(0, Date.parse(rotateAfterIso) - now);
|
|
576
|
+
}
|
|
577
|
+
else if (credentialExpiresAtIso) {
|
|
578
|
+
// Halfway to expiry, so a failed rotation still has a whole grace period
|
|
579
|
+
// to retry in.
|
|
580
|
+
delay = Math.max(0, (Date.parse(credentialExpiresAtIso) - now) / 2);
|
|
581
|
+
}
|
|
582
|
+
if (delay === null || Number.isNaN(delay)) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (this.rotateTimer) {
|
|
586
|
+
clearTimeout(this.rotateTimer);
|
|
587
|
+
}
|
|
588
|
+
this.rotateTimer = setTimeout(() => {
|
|
589
|
+
void this.rotateCredential();
|
|
590
|
+
}, delay);
|
|
591
|
+
this.rotateTimer.unref();
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Rotate the runner credential and re-persist it.
|
|
595
|
+
*
|
|
596
|
+
* A credential cannot authenticate its own replacement once expired, so
|
|
597
|
+
* rotation is scheduled AHEAD of expiry and never used to climb out of a 401.
|
|
598
|
+
* A failed rotation keeps the current credential and retries later; a final
|
|
599
|
+
* status stops trying, because the credential is gone for good and the loop
|
|
600
|
+
* will surface that.
|
|
601
|
+
*/
|
|
602
|
+
async rotateCredential() {
|
|
603
|
+
if (this.stopping) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const response = await this.client.rotateCredential((0, version_1.getHarnessVersion)());
|
|
608
|
+
if (this.runnerId && response.runnerCredential) {
|
|
609
|
+
this.credentialStore.save({
|
|
610
|
+
runnerId: this.runnerId,
|
|
611
|
+
tenantId: this.tenantId,
|
|
612
|
+
credential: response.runnerCredential,
|
|
613
|
+
credentialExpiresAt: response.credentialExpiresAt ?? null,
|
|
614
|
+
savedAt: new Date().toISOString()
|
|
615
|
+
});
|
|
616
|
+
for (const renewal of this.activeRenewals) {
|
|
617
|
+
renewal.setCredential?.(response.runnerCredential);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
log('Rotated runner credential', { rotateAfter: response.rotateAfter });
|
|
621
|
+
this.scheduleRotation(response.rotateAfter, response.credentialExpiresAt);
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
log('Credential rotation failed', {
|
|
625
|
+
error: error instanceof Error ? error.message : String(error)
|
|
626
|
+
});
|
|
627
|
+
if (!(error instanceof control_plane_1.ControlPlaneError && error.final) &&
|
|
628
|
+
!this.stopping) {
|
|
629
|
+
this.rotateTimer = setTimeout(() => {
|
|
630
|
+
void this.rotateCredential();
|
|
631
|
+
}, 30_000);
|
|
632
|
+
this.rotateTimer.unref();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/* ------------------------------------------------------------------ */
|
|
637
|
+
/* Heartbeat */
|
|
638
|
+
/* ------------------------------------------------------------------ */
|
|
639
|
+
/**
|
|
640
|
+
* Re-probe the model catalogue.
|
|
641
|
+
*
|
|
642
|
+
* Runs ONCE at start-up and on a long interval after that — never per
|
|
643
|
+
* heartbeat, because the probe starts a Pi discovery process.
|
|
644
|
+
* `force` re-probes regardless of age (used at start-up). Never rejects.
|
|
645
|
+
*/
|
|
646
|
+
async refreshModelCatalog(force = false) {
|
|
647
|
+
const backends = this.capabilities?.backends ?? [];
|
|
648
|
+
const build = this.deps.getAdapter;
|
|
649
|
+
if (!build || backends.length === 0) {
|
|
650
|
+
this.modelCatalog = null;
|
|
651
|
+
this.modelProbeAt = Date.now();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!force &&
|
|
655
|
+
this.modelCatalog &&
|
|
656
|
+
Date.now() - this.modelProbeAt < MODEL_PROBE_REFRESH_MS) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const factory = (backend) => {
|
|
660
|
+
try {
|
|
661
|
+
return build(backend, { modelGateway: this.modelProbeGateway }) ?? null;
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// A backend the host cannot wire (nothing installed, adapter throws) is
|
|
665
|
+
// "no models here", never an error on the probe path.
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
this.modelProbeAt = Date.now();
|
|
670
|
+
try {
|
|
671
|
+
const catalog = await (0, model_probe_1.probeModelCatalog)(backends, factory);
|
|
672
|
+
this.modelCatalog = catalog;
|
|
673
|
+
log('Model catalogue probed', {
|
|
674
|
+
backends: catalog.attempted,
|
|
675
|
+
models: catalog.flat.length
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// probeModelCatalog NEVER rejects by construction; this is a belt-and-
|
|
680
|
+
// braces guard so a regression cannot take down the start-up/register
|
|
681
|
+
// path. Without a catalog the capabilities simply carry no models.
|
|
682
|
+
this.modelCatalog = null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Attach the cached model catalogue to a capability report.
|
|
687
|
+
*
|
|
688
|
+
* Mutates `capabilities` in place: the flat list on `models`, and each
|
|
689
|
+
* installed backend's own list on `backends[].models`. Absent (rather than
|
|
690
|
+
* an empty-but-meaningful list) when the catalogue is empty.
|
|
691
|
+
*/
|
|
692
|
+
applyModelCatalog(capabilities) {
|
|
693
|
+
const catalog = this.modelCatalog;
|
|
694
|
+
if (!catalog) {
|
|
695
|
+
capabilities.models = undefined;
|
|
696
|
+
for (const backend of capabilities.backends) {
|
|
697
|
+
delete backend.models;
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
capabilities.models = catalog.flat.length > 0 ? catalog.flat : undefined;
|
|
702
|
+
for (const backend of capabilities.backends) {
|
|
703
|
+
const models = catalog.byBackend[backend.name];
|
|
704
|
+
if (models && models.length > 0) {
|
|
705
|
+
backend.models = models;
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
delete backend.models;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Whether the model catalogue needs a (rare) re-probe.
|
|
714
|
+
*
|
|
715
|
+
* Interval-only, and the interval doubles as a cooldown: `refreshModelCatalog`
|
|
716
|
+
* stamps `modelProbeAt` even when the probe itself fails, so a host whose
|
|
717
|
+
* models cannot be enumerated does not keep starting a process on every
|
|
718
|
+
* heartbeat trying. Start-up force bypasses this.
|
|
719
|
+
*
|
|
720
|
+
* NEVER WHILE A TURN IS RUNNING. Pi discovery and a live turn share the Pi
|
|
721
|
+
* agent directory, including models.json; avoiding concurrent discovery
|
|
722
|
+
* keeps provider configuration changes out of an active model call.
|
|
723
|
+
* Deferring costs nothing: the catalogue only
|
|
724
|
+
* changes when the host's model config does, and the next idle heartbeat
|
|
725
|
+
* picks it up.
|
|
726
|
+
*/
|
|
727
|
+
modelsNeedRefresh() {
|
|
728
|
+
if (this.active.size > 0) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
return Date.now() - this.modelProbeAt >= MODEL_PROBE_REFRESH_MS;
|
|
732
|
+
}
|
|
733
|
+
scheduleHeartbeat() {
|
|
734
|
+
if (this.stopping) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
738
|
+
void this.sendHeartbeat();
|
|
739
|
+
}, this.heartbeatIntervalMs);
|
|
740
|
+
// Never hold the event loop open on the heartbeat alone.
|
|
741
|
+
this.heartbeatTimer.unref();
|
|
742
|
+
}
|
|
743
|
+
async sendHeartbeat() {
|
|
744
|
+
try {
|
|
745
|
+
// Capabilities are RE-PROBED, not cached from registration. A host is
|
|
746
|
+
// upgraded in place: docker can appear and a backend can be installed
|
|
747
|
+
// without the runner ever restarting, and the control plane routes work
|
|
748
|
+
// on this answer.
|
|
749
|
+
const capabilities = await (0, capabilities_1.probeCapabilities)({
|
|
750
|
+
maxConcurrentSessions: this.options.maxConcurrentSessions,
|
|
751
|
+
labels: this.options.labels
|
|
752
|
+
});
|
|
753
|
+
this.applyModelCatalog(capabilities);
|
|
754
|
+
this.capabilities = capabilities;
|
|
755
|
+
/*
|
|
756
|
+
* THE MODEL CATALOGUE IS NOT RE-PROBED HERE, EVER. Only the CLI probes
|
|
757
|
+
* run on a heartbeat; Pi discovery starts an RPC process, and doing that
|
|
758
|
+
* every heartbeat would be a regression. The
|
|
759
|
+
* cached catalogue (probed at start-up) is attached above; a refresh is
|
|
760
|
+
* kicked off only when the long interval has elapsed or a backend newly
|
|
761
|
+
* appeared, and its result lands on a LATER heartbeat.
|
|
762
|
+
*/
|
|
763
|
+
if (this.modelsNeedRefresh()) {
|
|
764
|
+
void this.refreshModelCatalog();
|
|
765
|
+
}
|
|
766
|
+
const response = await this.client.heartbeat({
|
|
767
|
+
status: this.status,
|
|
768
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
769
|
+
activeSessions: this.active.size,
|
|
770
|
+
activeSessionIds: Array.from(this.active.keys()),
|
|
771
|
+
degraded: this.options.degraded || !this.storeDurable,
|
|
772
|
+
capabilities
|
|
773
|
+
});
|
|
774
|
+
this.lastHeartbeatAt = new Date().toISOString();
|
|
775
|
+
this.lastHeartbeatError = null;
|
|
776
|
+
this.consecutiveHeartbeatFailures = 0;
|
|
777
|
+
if (response.heartbeatIntervalMs) {
|
|
778
|
+
this.heartbeatIntervalMs = response.heartbeatIntervalMs;
|
|
779
|
+
}
|
|
780
|
+
this.observeHarnessPin(response.harnessVersion);
|
|
781
|
+
this.observeSandboxToolsPin(response.sandboxToolsVersion);
|
|
782
|
+
for (const command of response.sessionCommands ?? []) {
|
|
783
|
+
this.applyCommand(command);
|
|
784
|
+
}
|
|
785
|
+
if (response.action === 'drain') {
|
|
786
|
+
this.status = 'draining';
|
|
787
|
+
log('Control plane asked the harness to drain');
|
|
788
|
+
}
|
|
789
|
+
else if (response.action === 'restart') {
|
|
790
|
+
log('Control plane asked the harness to restart');
|
|
791
|
+
await this.stop();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
// A 426 here is the RUNNING-FLEET version gate: the floor was raised
|
|
797
|
+
// under a runner that had already registered. It is FINAL, and retrying
|
|
798
|
+
// would hammer a control plane that will never serve this build again.
|
|
799
|
+
if (error instanceof control_plane_1.ProtocolRefusedError) {
|
|
800
|
+
this.fatal = new VersionRefusedError(`Control plane raised its protocol floor: ${error.message}`);
|
|
801
|
+
await this.stop();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.consecutiveHeartbeatFailures += 1;
|
|
805
|
+
this.lastHeartbeatError =
|
|
806
|
+
error instanceof Error ? error.message : String(error);
|
|
807
|
+
log('Heartbeat failed', {
|
|
808
|
+
error: this.lastHeartbeatError,
|
|
809
|
+
consecutive: this.consecutiveHeartbeatFailures
|
|
810
|
+
});
|
|
811
|
+
// After the control plane has already written this runner off, keep
|
|
812
|
+
// beating but stop taking work: finishing a turn whose lease was requeued
|
|
813
|
+
// onto another runner would interleave two writers into one transcript.
|
|
814
|
+
if (this.consecutiveHeartbeatFailures >= contract_1.MISSED_HEARTBEATS_BEFORE_OFFLINE &&
|
|
815
|
+
this.status === 'ready') {
|
|
816
|
+
this.status = 'draining';
|
|
817
|
+
log('Presumed offline by the control plane, draining');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
this.scheduleHeartbeat();
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Apply a command that rode back on a heartbeat.
|
|
824
|
+
*
|
|
825
|
+
* Idempotent by `commandId`, because the contract lets the control plane
|
|
826
|
+
* repeat a command until it observes the effect — it has no other way to
|
|
827
|
+
* know, since it cannot dial in and ask.
|
|
828
|
+
*/
|
|
829
|
+
applyCommand(command) {
|
|
830
|
+
if (this.appliedCommands.has(command.commandId)) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
this.appliedCommands.add(command.commandId);
|
|
834
|
+
if (command.command === 'abort') {
|
|
835
|
+
const controller = this.active.get(command.sessionId);
|
|
836
|
+
if (controller) {
|
|
837
|
+
log('Aborting session by control-plane command', {
|
|
838
|
+
sessionId: command.sessionId
|
|
839
|
+
});
|
|
840
|
+
controller.abort();
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (command.command === 'release-workspace') {
|
|
845
|
+
const controller = this.active.get(command.sessionId);
|
|
846
|
+
if (controller) {
|
|
847
|
+
this.pendingWorkspaceReleases.add(command.sessionId);
|
|
848
|
+
controller.abort();
|
|
849
|
+
log('Workspace release deferred until the active turn ends', {
|
|
850
|
+
sessionId: command.sessionId
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
void this.releaseSessionWorkspace(command.sessionId);
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (command.command === 'answer') {
|
|
859
|
+
/*
|
|
860
|
+
* WIRE 3 — THE ACTUAL BLOCKER OF DEFECT G1, NOW JOINED UP.
|
|
861
|
+
*
|
|
862
|
+
* This used to be a comment saying the command was "recorded as applied
|
|
863
|
+
* rather than retried for ever". Everything either side of it worked: the
|
|
864
|
+
* UI posts an answer, the control plane queues it as a session command,
|
|
865
|
+
* the heartbeat carries it back, and the adapter has had
|
|
866
|
+
* `respondToApproval` all along. The command arrived here and was
|
|
867
|
+
* DROPPED.
|
|
868
|
+
*
|
|
869
|
+
* That is why the permission mode could not simply be turned on. With
|
|
870
|
+
* this line missing, passing the real mode through would have converted
|
|
871
|
+
* "silently unrestricted" into "silently hangs on every turn" — and since
|
|
872
|
+
* the default agent stores `permissionMode: 'default'`, it would have
|
|
873
|
+
* done so to every session at once.
|
|
874
|
+
*
|
|
875
|
+
* FIRE AND FORGET, DELIBERATELY. `applyCommand` is called from the
|
|
876
|
+
* heartbeat and renew paths, and neither may be blocked on a backend HTTP
|
|
877
|
+
* call: a slow backend would otherwise stall the beat that proves this
|
|
878
|
+
* runner is alive, and three missed beats is the definition of offline.
|
|
879
|
+
*/
|
|
880
|
+
const runner = this.runners.get(command.sessionId);
|
|
881
|
+
if (!runner) {
|
|
882
|
+
/*
|
|
883
|
+
* FORGET IT RATHER THAN MARKING IT APPLIED.
|
|
884
|
+
*
|
|
885
|
+
* This path is reached when the turn ended between the command being
|
|
886
|
+
* queued and it arriving — `runLeasedTurn`'s `finally` has already
|
|
887
|
+
* removed the runner. Leaving the commandId in `appliedCommands` would
|
|
888
|
+
* mark the user's answer as handled when nothing handled it, which is
|
|
889
|
+
* the very "recorded as applied, retried never" defect this work set
|
|
890
|
+
* out to fix, surviving on one entry path.
|
|
891
|
+
*
|
|
892
|
+
* There is no runner to write a transcript part through, so the log is
|
|
893
|
+
* all that can be said here; what this buys is that a redelivery would
|
|
894
|
+
* be attempted rather than discarded as a duplicate.
|
|
895
|
+
*/
|
|
896
|
+
this.appliedCommands.delete(command.commandId);
|
|
897
|
+
log('Answer arrived for a session this runner is no longer running', {
|
|
898
|
+
sessionId: command.sessionId,
|
|
899
|
+
commandId: command.commandId
|
|
900
|
+
});
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
void runner
|
|
904
|
+
.answerPendingApproval({
|
|
905
|
+
sessionId: command.sessionId,
|
|
906
|
+
partId: command.partId,
|
|
907
|
+
answer: command.answer,
|
|
908
|
+
optionId: command.optionId
|
|
909
|
+
})
|
|
910
|
+
.then((outcome) => {
|
|
911
|
+
log('Applied answer command', {
|
|
912
|
+
sessionId: command.sessionId,
|
|
913
|
+
commandId: command.commandId,
|
|
914
|
+
outcome
|
|
915
|
+
});
|
|
916
|
+
/*
|
|
917
|
+
* 'failed' IS THE ONLY OUTCOME THAT NEEDS RECOVERY, AND IT ARRIVES
|
|
918
|
+
* HERE RATHER THAN IN `.catch()`.
|
|
919
|
+
*
|
|
920
|
+
* `answerPendingApproval` RESOLVES with 'failed' when the backend
|
|
921
|
+
* could not be told — it does not reject. An earlier version of this
|
|
922
|
+
* handler put the recovery in `.catch()` only, and because the runner
|
|
923
|
+
* swallowed that error and returned a plain `false`, the recovery was
|
|
924
|
+
* UNREACHABLE: the commandId stayed applied, no error part was
|
|
925
|
+
* written, and the user's click vanished silently. Exactly the
|
|
926
|
+
* failure the comment below describes, in the code meant to prevent
|
|
927
|
+
* it.
|
|
928
|
+
*
|
|
929
|
+
* 'ignored' is NOT a failure — a stale or duplicate answer is an
|
|
930
|
+
* ordinary outcome and must not tell the user anything went wrong.
|
|
931
|
+
*/
|
|
932
|
+
if (outcome === 'failed') {
|
|
933
|
+
this.recoverFailedAnswer(runner, command, 'the backend did not accept the answer');
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
.catch((error) => {
|
|
937
|
+
/*
|
|
938
|
+
* A FAILED ANSWER MUST NOT BE SILENTLY FORGOTTEN.
|
|
939
|
+
*
|
|
940
|
+
* `appliedCommands.add` ran before the delivery, and the control
|
|
941
|
+
* plane stamps `delivered_at` at hand-off and never re-sends. So a
|
|
942
|
+
* transient failure here — a backend hiccup or slow process — would
|
|
943
|
+
* otherwise drop the user's approval PERMANENTLY, leaving the
|
|
944
|
+
* question card open with no way to answer it again and the turn
|
|
945
|
+
* waiting for ever (the approval wait is deliberately excluded from
|
|
946
|
+
* the turn deadline, so nothing else ends it either).
|
|
947
|
+
*
|
|
948
|
+
* Forgetting the commandId is the cheap half: it costs nothing, and
|
|
949
|
+
* if the command is ever delivered again the answer is retried
|
|
950
|
+
* instead of being discarded as a duplicate. The control plane does
|
|
951
|
+
* not re-send today, so this is a latch for a retry that does not yet
|
|
952
|
+
* exist rather than a fix on its own — which is why the failure is
|
|
953
|
+
* ALSO pushed into the transcript, where the user can see it.
|
|
954
|
+
*/
|
|
955
|
+
log('Answer command threw', {
|
|
956
|
+
sessionId: command.sessionId,
|
|
957
|
+
commandId: command.commandId,
|
|
958
|
+
error: error instanceof Error ? error.message : String(error)
|
|
959
|
+
});
|
|
960
|
+
this.recoverFailedAnswer(runner, command, error instanceof Error ? error.message : String(error));
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
async releaseSessionWorkspace(sessionId) {
|
|
965
|
+
try {
|
|
966
|
+
const released = await this.workspace.release(sessionId, { force: true });
|
|
967
|
+
log(released === false
|
|
968
|
+
? 'Session workspace release refused; keeping workspace'
|
|
969
|
+
: 'Session workspace release processed', { sessionId });
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
log('Session workspace release failed; keeping workspace', {
|
|
973
|
+
sessionId,
|
|
974
|
+
error: error instanceof Error ? error.message : String(error)
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* A user's answer did not reach the backend. Do the two things that keep it
|
|
980
|
+
* from vanishing.
|
|
981
|
+
*
|
|
982
|
+
* 1. FORGET THE commandId, so a redelivery is retried rather than discarded
|
|
983
|
+
* as a duplicate. On its own this fixes nothing — the control plane stamps
|
|
984
|
+
* `delivered_at` at hand-off and does not resend today — so it is a latch
|
|
985
|
+
* for a retry that does not yet exist.
|
|
986
|
+
* 2. TELL THE USER, in the transcript. This is the half that actually
|
|
987
|
+
* reaches them: without it the question card stays open, the turn keeps
|
|
988
|
+
* waiting (approval time is deliberately outside the turn deadline, so
|
|
989
|
+
* nothing else ends it), and a click is indistinguishable from a slow
|
|
990
|
+
* backend.
|
|
991
|
+
*/
|
|
992
|
+
recoverFailedAnswer(runner, command, reason) {
|
|
993
|
+
this.appliedCommands.delete(command.commandId);
|
|
994
|
+
void runner.reportApprovalFailure(command.sessionId, reason).catch(() => {
|
|
995
|
+
// If even that cannot be delivered, the log at the call site is all
|
|
996
|
+
// there is. Never let reporting a failure become a second failure.
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/* ------------------------------------------------------------------ */
|
|
1000
|
+
/* The lease loop */
|
|
1001
|
+
/* ------------------------------------------------------------------ */
|
|
1002
|
+
async runLeaseLoop() {
|
|
1003
|
+
while (!this.stopping && !this.fatal) {
|
|
1004
|
+
// Either pin can move independently — a tools-only bump must drain and
|
|
1005
|
+
// re-invoke the supervisor exactly like a harness bump does, because
|
|
1006
|
+
// pf-bootstrap's re-dial of /harness/bootstrap is what actually
|
|
1007
|
+
// resyncs BOTH layers, not just the one that changed.
|
|
1008
|
+
if (this.pendingHarnessVersion || this.pendingSandboxToolsVersion) {
|
|
1009
|
+
if (this.active.size === 0) {
|
|
1010
|
+
await this.requestUpgrade(this.pendingHarnessVersion ?? (0, version_1.getHarnessVersion)(), this.pendingSandboxToolsVersion ?? undefined);
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
// A pin can change while a long poll is returning a live lease. Finish
|
|
1014
|
+
// that lease, but do not open another poll while the update is pending.
|
|
1015
|
+
await sleep(500);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (this.status === 'draining') {
|
|
1019
|
+
// Draining means "finish what you hold, take nothing new".
|
|
1020
|
+
if (this.active.size === 0) {
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
await sleep(1_000);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const max = this.capabilities?.maxConcurrentSessions ?? 1;
|
|
1027
|
+
if (this.active.size >= max) {
|
|
1028
|
+
await sleep(500);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
let lease = null;
|
|
1032
|
+
try {
|
|
1033
|
+
lease = await this.client.lease(this.leaseWaitSeconds);
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
if (error instanceof control_plane_1.ProtocolRefusedError) {
|
|
1037
|
+
this.fatal = new VersionRefusedError(`Control plane raised its protocol floor: ${error.message}`);
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
if (error instanceof control_plane_1.ControlPlaneError && error.final) {
|
|
1041
|
+
this.fatal = error;
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
log('Lease poll failed', {
|
|
1045
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1046
|
+
});
|
|
1047
|
+
await sleep(LEASE_ERROR_BACKOFF_MS);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (!lease) {
|
|
1051
|
+
// 204: no work. Poll again immediately — the wait already happened
|
|
1052
|
+
// server-side, which is the entire point of a bounded long-poll.
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
this.observeHarnessPin(lease.harnessVersion);
|
|
1056
|
+
this.observeSandboxToolsPin(lease.sandboxToolsVersion);
|
|
1057
|
+
void this.runLeasedTurn(lease);
|
|
1058
|
+
}
|
|
1059
|
+
await this.stop();
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Record a changed pin. Only managed sandboxes have the baked supervisor
|
|
1063
|
+
* that can service the distinguished exit code.
|
|
1064
|
+
*/
|
|
1065
|
+
observeHarnessPin(version) {
|
|
1066
|
+
if (this.options.runtime !== 'sandbox' ||
|
|
1067
|
+
typeof version !== 'string' ||
|
|
1068
|
+
version.length === 0 ||
|
|
1069
|
+
version === (0, version_1.getHarnessVersion)() ||
|
|
1070
|
+
this.pendingHarnessVersion) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
this.pendingHarnessVersion = version;
|
|
1074
|
+
log('Harness upgrade pending; waiting for idle', {
|
|
1075
|
+
currentHarnessVersion: (0, version_1.getHarnessVersion)(),
|
|
1076
|
+
requestedHarnessVersion: version,
|
|
1077
|
+
activeSessions: this.active.size
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Same shape as observeHarnessPin(), for the INDEPENDENT sandbox-tools pin.
|
|
1082
|
+
* A host that never reported an installed tools version (self-hosted,
|
|
1083
|
+
* desktop, or a sandbox that predates this layer) has nothing to compare
|
|
1084
|
+
* against and is left alone rather than treated as "version none, always
|
|
1085
|
+
* behind" — that would drain and exit-75 every such host on every lease.
|
|
1086
|
+
*/
|
|
1087
|
+
observeSandboxToolsPin(version) {
|
|
1088
|
+
const installed = (0, version_1.getInstalledSandboxToolsVersion)();
|
|
1089
|
+
if (this.options.runtime !== 'sandbox' ||
|
|
1090
|
+
typeof version !== 'string' ||
|
|
1091
|
+
version.length === 0 ||
|
|
1092
|
+
installed === null ||
|
|
1093
|
+
version === installed ||
|
|
1094
|
+
this.pendingSandboxToolsVersion) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
// THE LOOP GUARD. pf-bootstrap already tried this EXACT pin this boot
|
|
1098
|
+
// cycle and it did not stick (bad hash, registry outage, a malformed
|
|
1099
|
+
// value) — arming it again would drain, exit 75, reboot, hit the same
|
|
1100
|
+
// failure, fall back or keep what's there, and arrive right back here
|
|
1101
|
+
// with the SAME mismatch, forever, taking zero leases the whole time. A
|
|
1102
|
+
// control plane that sends a genuinely different pin — including a
|
|
1103
|
+
// corrected one — is unaffected, since this only suppresses an exact
|
|
1104
|
+
// repeat.
|
|
1105
|
+
if (version === (0, version_1.getAttemptedSandboxToolsVersion)()) {
|
|
1106
|
+
log('Sandbox tools pin already attempted and not achieved this boot; not re-arming', {
|
|
1107
|
+
installedSandboxToolsVersion: installed,
|
|
1108
|
+
attemptedSandboxToolsVersion: version
|
|
1109
|
+
});
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.pendingSandboxToolsVersion = version;
|
|
1113
|
+
log('Sandbox tools upgrade pending; waiting for idle', {
|
|
1114
|
+
installedSandboxToolsVersion: installed,
|
|
1115
|
+
requestedSandboxToolsVersion: version,
|
|
1116
|
+
activeSessions: this.active.size
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
/** Stop once. A failed install is then the supervisor's terminal decision. */
|
|
1120
|
+
async requestUpgrade(harnessVersion, sandboxToolsVersion) {
|
|
1121
|
+
const runnerId = this.runnerId;
|
|
1122
|
+
const runnerCredential = this.client.getRunnerCredential();
|
|
1123
|
+
if (!runnerId || !runnerCredential) {
|
|
1124
|
+
this.fatal = new Error('Cannot request a harness upgrade without a live runner credential');
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
this.status = 'draining';
|
|
1128
|
+
this.deps.onUpgradeRequested?.({
|
|
1129
|
+
harnessVersion,
|
|
1130
|
+
...(sandboxToolsVersion ? { sandboxToolsVersion } : {}),
|
|
1131
|
+
runnerId,
|
|
1132
|
+
tenantId: this.tenantId,
|
|
1133
|
+
runnerCredential
|
|
1134
|
+
});
|
|
1135
|
+
this.fatal = new UpgradeRequestedError(harnessVersion);
|
|
1136
|
+
await this.stop({ preserveRegistration: true });
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Run one leased turn.
|
|
1140
|
+
*
|
|
1141
|
+
* NOT awaited by the loop, so a runner allowed more than one concurrent
|
|
1142
|
+
* session actually gets one. Every failure is contained here: a turn that
|
|
1143
|
+
* throws must not end the lease loop, or one bad session would take the whole
|
|
1144
|
+
* runner offline.
|
|
1145
|
+
*/
|
|
1146
|
+
async runLeasedTurn(lease) {
|
|
1147
|
+
const { sessionId } = lease.session;
|
|
1148
|
+
// The exact loopback-proxy targets this session will reach. Keyed on these
|
|
1149
|
+
// urls, this session's MCP token cannot be overwritten or revoked by a
|
|
1150
|
+
// concurrent turn — each url embeds its own session id.
|
|
1151
|
+
const mcpTargetUrls = (lease.session.agent.mcpServers ?? []).map((server) => server.url);
|
|
1152
|
+
const controller = new AbortController();
|
|
1153
|
+
this.active.set(sessionId, controller);
|
|
1154
|
+
this.status = 'busy';
|
|
1155
|
+
const githubWorkspace = this.workspace;
|
|
1156
|
+
if (lease.githubCredential) {
|
|
1157
|
+
githubWorkspace.setCredential?.(sessionId, lease.githubCredential);
|
|
1158
|
+
}
|
|
1159
|
+
// KEEP THE LEASE ALIVE FOR THE DURATION OF THE TURN.
|
|
1160
|
+
//
|
|
1161
|
+
// Ingest piggy-backs a renewal on every batch, so a chatty turn never needs
|
|
1162
|
+
// this. But a turn that is slow AND SILENT — one long tool call, one slow
|
|
1163
|
+
// model — would produce nothing to ingest for over LEASE_TTL_MS (120s), its
|
|
1164
|
+
// lease would expire, and the watchdog would hand the SAME turn to another
|
|
1165
|
+
// runner while this one is still working. The timer renews well inside that
|
|
1166
|
+
// window; a `renewed:false` means another runner already owns the session,
|
|
1167
|
+
// so we ABANDON rather than keep spending model budget on stolen work.
|
|
1168
|
+
/*
|
|
1169
|
+
* `renewal` and `runner` (below) reference EACH OTHER: `runner`'s
|
|
1170
|
+
* `onApprovalStateChange` calls `renewal.setUrgent`, and (per the LONG
|
|
1171
|
+
* TURNS fix) `renewalCallbacks.onModelCredential` calls
|
|
1172
|
+
* `runner.updateModelCredential`. Neither can be declared strictly after
|
|
1173
|
+
* the other, so this indirection breaks the cycle: `runnerRef` exists
|
|
1174
|
+
* (as `null`) before `renewalCallbacks` is built, and is pointed at the
|
|
1175
|
+
* real instance once it exists, a few statements below. Safe because
|
|
1176
|
+
* both callbacks are event-driven and fire only later, never during this
|
|
1177
|
+
* function's own synchronous setup.
|
|
1178
|
+
*/
|
|
1179
|
+
let runnerRef = null;
|
|
1180
|
+
/*
|
|
1181
|
+
* THE RENEWAL IS ALSO THE APPROVAL CHANNEL. Two jobs, one call — see the
|
|
1182
|
+
* long note on `APPROVAL_RENEW_INTERVAL_MS` for why the renew route and not
|
|
1183
|
+
* the heartbeat, and why the cadence changes.
|
|
1184
|
+
*/
|
|
1185
|
+
const renewalCallbacks = {
|
|
1186
|
+
leaseId: lease.leaseId,
|
|
1187
|
+
intervalMs: this.deps.leaseRenewIntervalMs,
|
|
1188
|
+
// While a permission prompt is open, renew — and therefore collect
|
|
1189
|
+
// commands — every couple of seconds instead of every minute.
|
|
1190
|
+
urgentIntervalMs: this.deps.approvalRenewIntervalMs,
|
|
1191
|
+
onCommands: (commands) => {
|
|
1192
|
+
for (const command of commands) {
|
|
1193
|
+
this.applyCommand(command);
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
githubTokenExpiresAt: lease.githubCredential?.expiresAt,
|
|
1197
|
+
onGithubCredential: (credential) => {
|
|
1198
|
+
githubWorkspace.setCredential?.(sessionId, credential);
|
|
1199
|
+
log('GitHub installation token refreshed in memory', {
|
|
1200
|
+
sessionId,
|
|
1201
|
+
expiresAt: credential.expiresAt
|
|
1202
|
+
});
|
|
1203
|
+
},
|
|
1204
|
+
// See `LeaseResponse.modelTokenExpiresAt` — absent for a sandbox's
|
|
1205
|
+
// per-SANDBOX key, so this simply never enters the refresh margin on
|
|
1206
|
+
// that runtime and the callback never fires there.
|
|
1207
|
+
modelTokenExpiresAt: lease.modelTokenExpiresAt,
|
|
1208
|
+
onModelCredential: (credential) => {
|
|
1209
|
+
runnerRef?.updateModelCredential(sessionId, credential.token, credential.expiresAt);
|
|
1210
|
+
log('Per-turn model key refreshed in memory', {
|
|
1211
|
+
sessionId,
|
|
1212
|
+
expiresAt: credential.expiresAt
|
|
1213
|
+
});
|
|
1214
|
+
},
|
|
1215
|
+
onLost: (reason) => {
|
|
1216
|
+
log('Lease lost mid-turn; aborting', { sessionId, reason });
|
|
1217
|
+
controller.abort();
|
|
1218
|
+
},
|
|
1219
|
+
log
|
|
1220
|
+
};
|
|
1221
|
+
const isolated = this.client.isolatedLeaseRenewalConfig();
|
|
1222
|
+
let renewal;
|
|
1223
|
+
if (isolated) {
|
|
1224
|
+
try {
|
|
1225
|
+
renewal = (0, lease_renewal_1.startIsolatedLeaseRenewal)({
|
|
1226
|
+
...renewalCallbacks,
|
|
1227
|
+
url: isolated.url,
|
|
1228
|
+
runnerCredential: isolated.runnerCredential
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
catch (error) {
|
|
1232
|
+
log('Could not start isolated lease renewal; using main event loop', {
|
|
1233
|
+
leaseId: lease.leaseId,
|
|
1234
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1235
|
+
});
|
|
1236
|
+
renewal = (0, lease_renewal_1.startLeaseRenewal)({
|
|
1237
|
+
...renewalCallbacks,
|
|
1238
|
+
client: this.client
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
renewal = (0, lease_renewal_1.startLeaseRenewal)({
|
|
1244
|
+
...renewalCallbacks,
|
|
1245
|
+
client: this.client
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
this.activeRenewals.add(renewal);
|
|
1249
|
+
// The session-scoped MCP token is installed for the duration of the turn
|
|
1250
|
+
// and revoked immediately after. It never reaches the backend: the agent is
|
|
1251
|
+
// pointed at the loopback proxy, and the proxy attaches this token itself.
|
|
1252
|
+
//
|
|
1253
|
+
// PER TARGET, NOT PROCESS-WIDE. `runLeasedTurn` is started with `void` and
|
|
1254
|
+
// never awaited, so with `maxConcurrentSessions > 1` several turns are in
|
|
1255
|
+
// flight at once. A single shared token holder let turn B overwrite turn A's
|
|
1256
|
+
// credential and let whichever finished first revoke it out from under the
|
|
1257
|
+
// other — after which the survivor's MCP calls carried the wrong session's
|
|
1258
|
+
// token (or none) and the gateway answered 401, surfacing as an OAuth error
|
|
1259
|
+
// and a `create_artifact` that failed for the rest of the turn.
|
|
1260
|
+
this.tokens.registerTargets(mcpTargetUrls, lease.mcpToken);
|
|
1261
|
+
/*
|
|
1262
|
+
* ONE GATEWAY PER TURN, NOT ONE PER PROCESS.
|
|
1263
|
+
*
|
|
1264
|
+
* `runLeasedTurn` is started with `void` and never awaited, so with
|
|
1265
|
+
* `maxConcurrentSessions > 1` several turns are in flight at once. A single
|
|
1266
|
+
* shared holder would let turn B overwrite turn A's credential, and let
|
|
1267
|
+
* whichever finished first REVOKE the gateway out from under the other —
|
|
1268
|
+
* after which the survivor silently loses its `peakflo` provider and falls
|
|
1269
|
+
* back to the host's own credentials, which is the exact failure the
|
|
1270
|
+
* refusal in `SessionRunner` exists to prevent.
|
|
1271
|
+
*
|
|
1272
|
+
* The holder is deliberately NOT shared the way `tokens` is. That one must
|
|
1273
|
+
* be re-pointed rather than replaced because the loopback MCP proxy keeps a
|
|
1274
|
+
* reference for the whole process life. Nothing holds a gateway that long:
|
|
1275
|
+
* `getAdapter` builds a NEW adapter for every turn, so a fresh holder per
|
|
1276
|
+
* turn is both safe and strictly more correct.
|
|
1277
|
+
*
|
|
1278
|
+
* S4's concurrency measurement did not catch this, and it is worth saying
|
|
1279
|
+
* why: all three concurrent turns used the same agent, so the credential
|
|
1280
|
+
* being overwritten was byte-identical to the one replacing it. The test
|
|
1281
|
+
* passed for the wrong reason.
|
|
1282
|
+
*/
|
|
1283
|
+
const modelGateway = new model_gateway_1.SessionModelGateway();
|
|
1284
|
+
const runner = new session_runner_1.SessionRunner({
|
|
1285
|
+
getAdapter: (backend) => {
|
|
1286
|
+
const build = this.deps.getAdapter;
|
|
1287
|
+
if (!build) {
|
|
1288
|
+
throw new Error(`No adapter is wired for backend "${backend}". ` +
|
|
1289
|
+
'Pass getAdapter in HarnessDeps.');
|
|
1290
|
+
}
|
|
1291
|
+
return build(backend, { modelGateway });
|
|
1292
|
+
},
|
|
1293
|
+
modelGateway,
|
|
1294
|
+
store: this.store,
|
|
1295
|
+
workspace: this.workspace,
|
|
1296
|
+
proxy: this.proxy,
|
|
1297
|
+
secrets: this.secrets,
|
|
1298
|
+
events: this.events,
|
|
1299
|
+
telemetry: this.telemetry,
|
|
1300
|
+
notifier: this.notifier,
|
|
1301
|
+
artifacts: this.artifacts,
|
|
1302
|
+
conversation: this.conversation,
|
|
1303
|
+
fetchHistory: async (id) => {
|
|
1304
|
+
const history = await this.client.sessionHistory(id, lease.leaseId);
|
|
1305
|
+
return {
|
|
1306
|
+
backendSessionId: history.backendSessionId ?? null,
|
|
1307
|
+
// Threaded through DELIBERATELY. Dropping it here is what let a cold
|
|
1308
|
+
// runner adopt another machine's Pi session file: the guard in
|
|
1309
|
+
// `loadMemory` cannot refuse an owner it was never told.
|
|
1310
|
+
backendOwnerId: history.backendOwnerId ?? null,
|
|
1311
|
+
messages: history.messages ?? []
|
|
1312
|
+
};
|
|
1313
|
+
},
|
|
1314
|
+
/*
|
|
1315
|
+
* THE LATENCY WIRE. The runner tells the harness the moment a turn starts
|
|
1316
|
+
* or stops waiting on a human, and the harness uses that to decide how
|
|
1317
|
+
* hard to poll the control plane for the answer. Nothing else in the
|
|
1318
|
+
* process knows a permission is outstanding.
|
|
1319
|
+
*/
|
|
1320
|
+
onApprovalStateChange: (id, waiting) => {
|
|
1321
|
+
/*
|
|
1322
|
+
* NO SET IS KEPT HERE. An earlier version also maintained an
|
|
1323
|
+
* `awaitingApproval` Set that nothing ever read — the cadence is driven
|
|
1324
|
+
* entirely by `setUrgent` below. A write-only collection invites the
|
|
1325
|
+
* next reader to assume it gates something, so it is gone rather than
|
|
1326
|
+
* left as decoration.
|
|
1327
|
+
*/
|
|
1328
|
+
// Event-driven, not polled: the cadence changes at the instant the
|
|
1329
|
+
// prompt opens, so the first tight renewal happens ~2s later rather
|
|
1330
|
+
// than up to a minute later.
|
|
1331
|
+
renewal.setUrgent(waiting);
|
|
1332
|
+
log('Approval state changed', { sessionId: id, waiting });
|
|
1333
|
+
},
|
|
1334
|
+
ingest: (id, parts, turn, final, modelMessages) => this.client
|
|
1335
|
+
.ingest(id, {
|
|
1336
|
+
leaseId: lease.leaseId,
|
|
1337
|
+
parts,
|
|
1338
|
+
turn,
|
|
1339
|
+
final,
|
|
1340
|
+
modelMessages
|
|
1341
|
+
})
|
|
1342
|
+
.then(() => undefined)
|
|
1343
|
+
});
|
|
1344
|
+
runnerRef = runner;
|
|
1345
|
+
// Registered BEFORE the turn starts. A permission can be raised on the
|
|
1346
|
+
// backend's very first tool call, and an answer that arrives before this
|
|
1347
|
+
// map is populated would be dropped as "a session this runner is not
|
|
1348
|
+
// running" — which is indistinguishable, in the log, from a real
|
|
1349
|
+
// mis-routing.
|
|
1350
|
+
this.runners.set(sessionId, runner);
|
|
1351
|
+
try {
|
|
1352
|
+
const result = await runner.run(lease, controller.signal);
|
|
1353
|
+
log('Turn finished', {
|
|
1354
|
+
sessionId,
|
|
1355
|
+
stopReason: result.turn.stopReason,
|
|
1356
|
+
parts: result.partsProduced
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
// Reaching here means the CONTROL PLANE could not be told, which is the
|
|
1361
|
+
// one failure the runner cannot report through the control plane. The
|
|
1362
|
+
// lease will expire and the watchdog will requeue the session.
|
|
1363
|
+
log('Turn failed to report', {
|
|
1364
|
+
sessionId,
|
|
1365
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
finally {
|
|
1369
|
+
renewal.stop();
|
|
1370
|
+
this.activeRenewals.delete(renewal);
|
|
1371
|
+
let workProtected = true;
|
|
1372
|
+
if ((this.options.autoCommitDirtyWorktree ??
|
|
1373
|
+
this.options.runtime === 'sandbox') &&
|
|
1374
|
+
this.workspace.preserveAtTurnEnd) {
|
|
1375
|
+
try {
|
|
1376
|
+
workProtected = await this.workspace.preserveAtTurnEnd(sessionId);
|
|
1377
|
+
if (!workProtected) {
|
|
1378
|
+
log('worktree-wip-push-failed', { sessionId });
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) {
|
|
1382
|
+
workProtected = false;
|
|
1383
|
+
log('worktree-wip-push-failed', {
|
|
1384
|
+
sessionId,
|
|
1385
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
// Keep the session active until preservation finishes. A release command
|
|
1390
|
+
// that arrives during the commit must defer, never delete under Git.
|
|
1391
|
+
this.active.delete(sessionId);
|
|
1392
|
+
this.runners.delete(sessionId);
|
|
1393
|
+
// Revoke ONLY this session's tokens. A process-wide revoke here blanked
|
|
1394
|
+
// the credential of every concurrent turn still running.
|
|
1395
|
+
this.tokens.revokeTargets(mcpTargetUrls);
|
|
1396
|
+
modelGateway.revoke();
|
|
1397
|
+
githubWorkspace.clearCredential?.(sessionId);
|
|
1398
|
+
if (this.pendingWorkspaceReleases.delete(sessionId)) {
|
|
1399
|
+
if (workProtected) {
|
|
1400
|
+
await this.releaseSessionWorkspace(sessionId);
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
log('Session workspace release deferred after WIP push failure', {
|
|
1404
|
+
sessionId
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (this.status === 'busy' && this.active.size === 0) {
|
|
1409
|
+
this.status = 'ready';
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
exports.Harness = Harness;
|