@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -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;