harnery 0.15.0 → 0.16.0

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.
@@ -0,0 +1,920 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import {
4
+ appendFileSync,
5
+ chmodSync,
6
+ closeSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { type NormalizedPolicy, normalizePolicy, type PolicyIsolation } from "../policy/index.ts";
20
+ import type { RunWorkItemInput } from "../work/index.ts";
21
+ import { runSupervisor, type SupervisorRunReport, type SupervisorStopReason } from "./runner.ts";
22
+ import { readSupervisor, type SupervisorRecord } from "./state.ts";
23
+
24
+ export const SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION = 1 as const;
25
+ export const SUPERVISOR_SERVICE_RUNTIME_SCHEMA_VERSION = 1 as const;
26
+ export const SUPERVISOR_SERVICE_STATUS_SCHEMA_VERSION = 1 as const;
27
+
28
+ const MAX_FILE_BYTES = 512 * 1024;
29
+ const MAX_GOALS = 100;
30
+ const MAX_ERROR = 2_000;
31
+ const FOREIGN_STATUS_STALE_MS = 30_000;
32
+
33
+ export interface SupervisorServiceEngineConfig {
34
+ default_harness?: string;
35
+ cwd?: string;
36
+ subscription_only: boolean;
37
+ allow_api_billing: boolean;
38
+ policy?: NormalizedPolicy;
39
+ isolation?: PolicyIsolation;
40
+ approval_addressee?: string;
41
+ }
42
+
43
+ export interface SupervisorServiceConfig {
44
+ schema_version: typeof SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION;
45
+ goal_ids: string[];
46
+ wake_interval_ms: number;
47
+ heartbeat_interval_ms: number;
48
+ error_backoff_base_ms: number;
49
+ error_backoff_max_ms: number;
50
+ engine: SupervisorServiceEngineConfig;
51
+ created_at: string;
52
+ }
53
+
54
+ export interface ConfigureSupervisorServiceInput {
55
+ coordRoot: string;
56
+ goalIds: readonly string[];
57
+ wakeIntervalMs?: number;
58
+ heartbeatIntervalMs?: number;
59
+ errorBackoffBaseMs?: number;
60
+ errorBackoffMaxMs?: number;
61
+ engine?: Partial<SupervisorServiceEngineConfig>;
62
+ }
63
+
64
+ export type SupervisorServiceGoalState = "idle" | "backoff" | "awaiting_change";
65
+
66
+ export interface SupervisorServiceGoalRuntime {
67
+ state: SupervisorServiceGoalState;
68
+ consecutive_errors: number;
69
+ observed_fingerprint?: string;
70
+ next_wake_at?: string;
71
+ last_tick_at?: string;
72
+ last_stop_reason?: SupervisorStopReason;
73
+ last_error?: string;
74
+ }
75
+
76
+ export interface SupervisorServiceRuntime {
77
+ schema_version: typeof SUPERVISOR_SERVICE_RUNTIME_SCHEMA_VERSION;
78
+ config_created_at: string;
79
+ updated_at: string;
80
+ goals: Record<string, SupervisorServiceGoalRuntime>;
81
+ }
82
+
83
+ export type SupervisorServiceProcessState = "starting" | "running" | "stopping" | "stopped";
84
+
85
+ export interface SupervisorServiceStatusRecord {
86
+ schema_version: typeof SUPERVISOR_SERVICE_STATUS_SCHEMA_VERSION;
87
+ pid: number;
88
+ host: string;
89
+ nonce: string;
90
+ state: SupervisorServiceProcessState;
91
+ started_at: string;
92
+ heartbeat_at: string;
93
+ config_created_at: string;
94
+ sweep_count: number;
95
+ active_goal_id?: string;
96
+ last_sweep_at?: string;
97
+ next_wake_at?: string;
98
+ last_error?: string;
99
+ stopped_at?: string;
100
+ }
101
+
102
+ export interface SupervisorServiceStatus {
103
+ running: boolean;
104
+ stale: boolean;
105
+ record?: SupervisorServiceStatusRecord;
106
+ config?: SupervisorServiceConfig;
107
+ runtime?: SupervisorServiceRuntime;
108
+ }
109
+
110
+ export interface SupervisorServiceSweepOutcome {
111
+ goal_id: string;
112
+ action: "tick" | "skip" | "backoff";
113
+ reason: string;
114
+ stop_reason?: SupervisorStopReason;
115
+ next_wake_at?: string;
116
+ }
117
+
118
+ export interface SupervisorServiceSweepReport {
119
+ started_at: string;
120
+ ended_at: string;
121
+ outcomes: SupervisorServiceSweepOutcome[];
122
+ }
123
+
124
+ export interface RunSupervisorServiceSweepInput {
125
+ coordRoot: string;
126
+ config: SupervisorServiceConfig;
127
+ engine?: Omit<RunWorkItemInput["engine"], "maxAgents" | "concurrency" | "specialists">;
128
+ actor?: string;
129
+ now?: () => number;
130
+ readGoal?: (coordRoot: string, goalId: string) => SupervisorRecord;
131
+ runGoal?: (goalId: string) => Promise<SupervisorRunReport>;
132
+ onGoalStart?: (goalId: string) => void;
133
+ }
134
+
135
+ export interface RunSupervisorServiceDaemonInput {
136
+ coordRoot: string;
137
+ engine: Omit<RunWorkItemInput["engine"], "maxAgents" | "concurrency" | "specialists">;
138
+ actor?: string;
139
+ maxSweeps?: number;
140
+ onLog?: (line: string) => void;
141
+ }
142
+
143
+ interface ServiceLease {
144
+ pid: number;
145
+ host: string;
146
+ nonce: string;
147
+ created_at: string;
148
+ }
149
+
150
+ export function configureSupervisorService(
151
+ input: ConfigureSupervisorServiceInput,
152
+ ): SupervisorServiceConfig {
153
+ const coordRoot = resolve(input.coordRoot);
154
+ const active = readStatusRecord(coordRoot);
155
+ if (active && statusOwnerIsLive(active)) {
156
+ throw new Error(`stop supervisor service pid ${active.pid} before changing its configuration`);
157
+ }
158
+ const goalIds = unique(input.goalIds.map((value) => value.trim()).filter(Boolean));
159
+ if (goalIds.length < 1 || goalIds.length > MAX_GOALS) {
160
+ throw new Error(`supervisor service requires from 1 to ${MAX_GOALS} unique goal ids`);
161
+ }
162
+ for (const goalId of goalIds) readSupervisor(coordRoot, goalId);
163
+ const wakeIntervalMs = positive(
164
+ input.wakeIntervalMs ?? 5_000,
165
+ "service wake_interval_ms",
166
+ 60 * 60 * 1_000,
167
+ );
168
+ const heartbeatIntervalMs = positive(
169
+ input.heartbeatIntervalMs ?? 2_000,
170
+ "service heartbeat_interval_ms",
171
+ 60_000,
172
+ );
173
+ const errorBackoffBaseMs = positive(
174
+ input.errorBackoffBaseMs ?? 2_000,
175
+ "service error_backoff_base_ms",
176
+ 60 * 60 * 1_000,
177
+ );
178
+ const errorBackoffMaxMs = positive(
179
+ input.errorBackoffMaxMs ?? 5 * 60_000,
180
+ "service error_backoff_max_ms",
181
+ 24 * 60 * 60 * 1_000,
182
+ );
183
+ if (errorBackoffMaxMs < errorBackoffBaseMs) {
184
+ throw new Error("service error_backoff_max_ms must be at least error_backoff_base_ms");
185
+ }
186
+ const config: SupervisorServiceConfig = {
187
+ schema_version: SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION,
188
+ goal_ids: goalIds,
189
+ wake_interval_ms: wakeIntervalMs,
190
+ heartbeat_interval_ms: heartbeatIntervalMs,
191
+ error_backoff_base_ms: errorBackoffBaseMs,
192
+ error_backoff_max_ms: errorBackoffMaxMs,
193
+ engine: normalizeEngineConfig(coordRoot, input.engine),
194
+ created_at: new Date().toISOString(),
195
+ };
196
+ const dir = serviceDir(coordRoot);
197
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
198
+ chmodSync(dir, 0o700);
199
+ writePrivateJsonAtomic(serviceConfigPath(coordRoot), config);
200
+ return readSupervisorServiceConfig(coordRoot);
201
+ }
202
+
203
+ export function readSupervisorServiceConfig(coordRootRaw: string): SupervisorServiceConfig {
204
+ const coordRoot = resolve(coordRootRaw);
205
+ const config = readBoundedJson<SupervisorServiceConfig>(
206
+ serviceConfigPath(coordRoot),
207
+ "supervisor service configuration",
208
+ );
209
+ validateConfig(coordRoot, config);
210
+ return config;
211
+ }
212
+
213
+ export function readSupervisorServiceRuntime(
214
+ coordRootRaw: string,
215
+ ): SupervisorServiceRuntime | undefined {
216
+ const coordRoot = resolve(coordRootRaw);
217
+ if (!existsSync(serviceRuntimePath(coordRoot))) return undefined;
218
+ const runtime = readBoundedJson<SupervisorServiceRuntime>(
219
+ serviceRuntimePath(coordRoot),
220
+ "supervisor service runtime",
221
+ );
222
+ if (
223
+ runtime.schema_version !== SUPERVISOR_SERVICE_RUNTIME_SCHEMA_VERSION ||
224
+ !validTimestamp(runtime.config_created_at) ||
225
+ !validTimestamp(runtime.updated_at) ||
226
+ !runtime.goals ||
227
+ typeof runtime.goals !== "object" ||
228
+ Array.isArray(runtime.goals)
229
+ ) {
230
+ throw new Error("supervisor service runtime has an unsupported schema");
231
+ }
232
+ return runtime;
233
+ }
234
+
235
+ export function readSupervisorServiceStatus(coordRootRaw: string): SupervisorServiceStatus {
236
+ const coordRoot = resolve(coordRootRaw);
237
+ const record = readStatusRecord(coordRoot);
238
+ let config: SupervisorServiceConfig | undefined;
239
+ let runtime: SupervisorServiceRuntime | undefined;
240
+ try {
241
+ config = readSupervisorServiceConfig(coordRoot);
242
+ } catch {
243
+ // A service has no usable configuration until explicitly configured.
244
+ }
245
+ try {
246
+ runtime = readSupervisorServiceRuntime(coordRoot);
247
+ } catch {
248
+ // Status remains readable when recoverable runtime state is corrupt.
249
+ }
250
+ if (!record) return { running: false, stale: false, config, runtime };
251
+ const running = statusOwnerIsLive(record);
252
+ return { running, stale: !running && record.state !== "stopped", record, config, runtime };
253
+ }
254
+
255
+ export async function spawnSupervisorService(
256
+ coordRootRaw: string,
257
+ ): Promise<SupervisorServiceStatus> {
258
+ const coordRoot = resolve(coordRootRaw);
259
+ readSupervisorServiceConfig(coordRoot);
260
+ const current = readSupervisorServiceStatus(coordRoot);
261
+ if (current.running) {
262
+ throw new Error(`supervisor service is already running under pid ${current.record?.pid}`);
263
+ }
264
+ mkdirSync(serviceDir(coordRoot), { recursive: true, mode: 0o700 });
265
+ const logFd = openSync(serviceLogPath(coordRoot), "a", 0o600);
266
+ chmodSync(serviceLogPath(coordRoot), 0o600);
267
+ const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
268
+ if (!existsSync(harnBin)) {
269
+ closeSync(logFd);
270
+ throw new Error(`cannot find harn executable at ${harnBin}`);
271
+ }
272
+ let spawnError: Error | undefined;
273
+ const child = spawn(harnBin, ["supervisor", "service", "daemon"], {
274
+ cwd: coordRoot,
275
+ detached: true,
276
+ stdio: ["ignore", logFd, logFd],
277
+ env: {
278
+ ...process.env,
279
+ HARNERY_COORD_ROOT_OVERRIDE: coordRoot,
280
+ HARNERY_OUTPUT_SESSION_TEE: "0",
281
+ },
282
+ });
283
+ child.once("error", (error) => {
284
+ spawnError = error;
285
+ });
286
+ closeSync(logFd);
287
+ child.unref();
288
+ const deadline = Date.now() + 5_000;
289
+ while (Date.now() < deadline) {
290
+ await delay(50);
291
+ if (spawnError) throw spawnError;
292
+ const status = readSupervisorServiceStatus(coordRoot);
293
+ if (status.running && status.record?.pid === child.pid) return status;
294
+ if (child.exitCode !== null) break;
295
+ }
296
+ throw new Error(`supervisor service failed to start; inspect ${serviceLogPath(coordRoot)}`);
297
+ }
298
+
299
+ export function requestSupervisorServiceStop(coordRootRaw: string): SupervisorServiceStatus {
300
+ const coordRoot = resolve(coordRootRaw);
301
+ const status = readSupervisorServiceStatus(coordRoot);
302
+ if (!status.running || !status.record) return status;
303
+ writePrivateJsonAtomic(serviceStopPath(coordRoot), {
304
+ requested_at: new Date().toISOString(),
305
+ requested_by_pid: process.pid,
306
+ });
307
+ if (status.record.host === hostname()) {
308
+ try {
309
+ process.kill(status.record.pid, "SIGTERM");
310
+ } catch {
311
+ // The durable stop request remains for a racing or restarted daemon.
312
+ }
313
+ }
314
+ return readSupervisorServiceStatus(coordRoot);
315
+ }
316
+
317
+ export async function runSupervisorServiceSweep(
318
+ input: RunSupervisorServiceSweepInput,
319
+ ): Promise<SupervisorServiceSweepReport> {
320
+ const coordRoot = resolve(input.coordRoot);
321
+ const now = input.now ?? Date.now;
322
+ const readGoal = input.readGoal ?? readSupervisor;
323
+ const runtime = runtimeForConfig(coordRoot, input.config, now());
324
+ const startedAt = new Date(now()).toISOString();
325
+ const outcomes: SupervisorServiceSweepOutcome[] = [];
326
+ const runGoal =
327
+ input.runGoal ??
328
+ (async (goalId: string) => {
329
+ if (!input.engine) throw new Error("supervisor service sweep requires an engine");
330
+ return await runSupervisor({
331
+ coordRoot,
332
+ goalId,
333
+ mode: "tick",
334
+ actor: input.actor ?? "supervisor-service",
335
+ engine: input.engine,
336
+ });
337
+ });
338
+
339
+ for (const goalId of input.config.goal_ids) {
340
+ const timestamp = now();
341
+ const record = readGoal(coordRoot, goalId);
342
+ const fingerprint = goalFingerprint(record);
343
+ const previous = runtime.goals[goalId] ?? emptyGoalRuntime();
344
+ const changed =
345
+ previous.observed_fingerprint !== undefined && previous.observed_fingerprint !== fingerprint;
346
+ if (previous.state === "backoff" && !changed && !wakeDue(previous.next_wake_at, timestamp)) {
347
+ outcomes.push({
348
+ goal_id: goalId,
349
+ action: "skip",
350
+ reason: "service error backoff has not elapsed",
351
+ next_wake_at: previous.next_wake_at,
352
+ });
353
+ continue;
354
+ }
355
+ if (previous.state === "awaiting_change" && !changed) {
356
+ outcomes.push({
357
+ goal_id: goalId,
358
+ action: "skip",
359
+ reason: "durable goal state has not changed",
360
+ });
361
+ continue;
362
+ }
363
+ if (record.projection.state !== "ready") {
364
+ runtime.goals[goalId] = {
365
+ ...previous,
366
+ state: "awaiting_change",
367
+ consecutive_errors: 0,
368
+ observed_fingerprint: fingerprint,
369
+ next_wake_at: undefined,
370
+ last_error: undefined,
371
+ };
372
+ outcomes.push({
373
+ goal_id: goalId,
374
+ action: "skip",
375
+ reason: `goal is ${record.projection.state}: ${record.projection.reason}`,
376
+ });
377
+ persistRuntime(coordRoot, runtime, timestamp);
378
+ continue;
379
+ }
380
+
381
+ input.onGoalStart?.(goalId);
382
+ try {
383
+ const report = await runGoal(goalId);
384
+ const completedAt = now();
385
+ const awaitingChange = report.projection.state !== "ready";
386
+ runtime.goals[goalId] = {
387
+ state: awaitingChange ? "awaiting_change" : "idle",
388
+ consecutive_errors: 0,
389
+ observed_fingerprint: projectionFingerprint(report.projection),
390
+ next_wake_at: awaitingChange ? undefined : new Date(completedAt).toISOString(),
391
+ last_tick_at: new Date(completedAt).toISOString(),
392
+ last_stop_reason: report.stop_reason,
393
+ };
394
+ outcomes.push({
395
+ goal_id: goalId,
396
+ action: "tick",
397
+ reason: report.reason,
398
+ stop_reason: report.stop_reason,
399
+ next_wake_at: runtime.goals[goalId]?.next_wake_at,
400
+ });
401
+ appendServiceEvent(coordRoot, {
402
+ event: "goal.tick",
403
+ goal_id: goalId,
404
+ stop_reason: report.stop_reason,
405
+ reason: boundedError(report.reason),
406
+ cycles: report.cycles,
407
+ dispatches: report.dispatches,
408
+ acceptances: report.acceptances,
409
+ });
410
+ persistRuntime(coordRoot, runtime, completedAt);
411
+ } catch (error) {
412
+ const failedAt = now();
413
+ const consecutiveErrors = previous.consecutive_errors + 1;
414
+ const backoff = Math.min(
415
+ input.config.error_backoff_max_ms,
416
+ input.config.error_backoff_base_ms * 2 ** Math.min(consecutiveErrors - 1, 20),
417
+ );
418
+ let observed = fingerprint;
419
+ try {
420
+ observed = goalFingerprint(readGoal(coordRoot, goalId));
421
+ } catch {
422
+ // Preserve the pre-attempt fingerprint when reread also fails.
423
+ }
424
+ const nextWakeAt = new Date(failedAt + backoff).toISOString();
425
+ const message = boundedError((error as Error).message);
426
+ runtime.goals[goalId] = {
427
+ ...previous,
428
+ state: "backoff",
429
+ consecutive_errors: consecutiveErrors,
430
+ observed_fingerprint: observed,
431
+ next_wake_at: nextWakeAt,
432
+ last_tick_at: new Date(failedAt).toISOString(),
433
+ last_error: message,
434
+ };
435
+ outcomes.push({
436
+ goal_id: goalId,
437
+ action: "backoff",
438
+ reason: message,
439
+ next_wake_at: nextWakeAt,
440
+ });
441
+ appendServiceEvent(coordRoot, {
442
+ event: "goal.error",
443
+ goal_id: goalId,
444
+ error: message,
445
+ consecutive_errors: consecutiveErrors,
446
+ next_wake_at: nextWakeAt,
447
+ });
448
+ persistRuntime(coordRoot, runtime, failedAt);
449
+ }
450
+ }
451
+ const endedAt = new Date(now()).toISOString();
452
+ persistRuntime(coordRoot, runtime, Date.parse(endedAt));
453
+ return { started_at: startedAt, ended_at: endedAt, outcomes };
454
+ }
455
+
456
+ export async function runSupervisorServiceDaemon(
457
+ input: RunSupervisorServiceDaemonInput,
458
+ ): Promise<SupervisorServiceStatusRecord> {
459
+ const coordRoot = resolve(input.coordRoot);
460
+ const config = readSupervisorServiceConfig(coordRoot);
461
+ const release = acquireServiceLease(coordRoot);
462
+ rmSync(serviceStopPath(coordRoot), { force: true });
463
+ const now = new Date().toISOString();
464
+ const status: SupervisorServiceStatusRecord = {
465
+ schema_version: SUPERVISOR_SERVICE_STATUS_SCHEMA_VERSION,
466
+ pid: process.pid,
467
+ host: hostname(),
468
+ nonce: randomUUID(),
469
+ state: "starting",
470
+ started_at: now,
471
+ heartbeat_at: now,
472
+ config_created_at: config.created_at,
473
+ sweep_count: 0,
474
+ };
475
+ let stopRequested = false;
476
+ const log = input.onLog ?? (() => undefined);
477
+ const writeStatus = (): void => {
478
+ status.heartbeat_at = new Date().toISOString();
479
+ writePrivateJsonAtomic(serviceStatusPath(coordRoot), status);
480
+ };
481
+ const requestStop = (): void => {
482
+ stopRequested = true;
483
+ status.state = "stopping";
484
+ writeStatus();
485
+ };
486
+ process.on("SIGINT", requestStop);
487
+ process.on("SIGTERM", requestStop);
488
+ status.state = "running";
489
+ writeStatus();
490
+ appendServiceEvent(coordRoot, {
491
+ event: "service.started",
492
+ pid: process.pid,
493
+ goals: config.goal_ids,
494
+ config_created_at: config.created_at,
495
+ });
496
+ log(`supervisor service started for ${config.goal_ids.length} goal(s)`);
497
+ const heartbeat = setInterval(() => {
498
+ try {
499
+ writeStatus();
500
+ } catch (error) {
501
+ log(`supervisor service heartbeat error: ${boundedError((error as Error).message)}`);
502
+ }
503
+ }, config.heartbeat_interval_ms);
504
+ try {
505
+ while (!stopRequested && !existsSync(serviceStopPath(coordRoot))) {
506
+ let sleepMs = config.wake_interval_ms;
507
+ try {
508
+ const report = await runSupervisorServiceSweep({
509
+ coordRoot,
510
+ config,
511
+ engine: input.engine,
512
+ actor: input.actor,
513
+ onGoalStart: (goalId) => {
514
+ status.active_goal_id = goalId;
515
+ writeStatus();
516
+ },
517
+ });
518
+ status.sweep_count++;
519
+ status.active_goal_id = undefined;
520
+ status.last_sweep_at = report.ended_at;
521
+ status.last_error = undefined;
522
+ status.next_wake_at = new Date(Date.now() + config.wake_interval_ms).toISOString();
523
+ writeStatus();
524
+ const summary = renderSweepLog(report);
525
+ if (summary) log(summary);
526
+ } catch (error) {
527
+ sleepMs = config.error_backoff_max_ms;
528
+ status.active_goal_id = undefined;
529
+ status.last_error = boundedError((error as Error).message);
530
+ status.next_wake_at = new Date(Date.now() + config.error_backoff_max_ms).toISOString();
531
+ writeStatus();
532
+ appendServiceEvent(coordRoot, {
533
+ event: "service.sweep_error",
534
+ error: status.last_error,
535
+ });
536
+ log(`supervisor service sweep error: ${status.last_error}`);
537
+ }
538
+ if (input.maxSweeps !== undefined && status.sweep_count >= input.maxSweeps) break;
539
+ if (!stopRequested && !existsSync(serviceStopPath(coordRoot))) {
540
+ await interruptibleDelay(
541
+ sleepMs,
542
+ () => stopRequested || existsSync(serviceStopPath(coordRoot)),
543
+ );
544
+ }
545
+ }
546
+ } finally {
547
+ clearInterval(heartbeat);
548
+ process.off("SIGINT", requestStop);
549
+ process.off("SIGTERM", requestStop);
550
+ status.state = "stopped";
551
+ status.active_goal_id = undefined;
552
+ status.stopped_at = new Date().toISOString();
553
+ writeStatus();
554
+ appendServiceEvent(coordRoot, {
555
+ event: "service.stopped",
556
+ pid: process.pid,
557
+ sweeps: status.sweep_count,
558
+ });
559
+ rmSync(serviceStopPath(coordRoot), { force: true });
560
+ release();
561
+ }
562
+ return status;
563
+ }
564
+
565
+ export function supervisorServiceLogPath(coordRoot: string): string {
566
+ return serviceLogPath(resolve(coordRoot));
567
+ }
568
+
569
+ function runtimeForConfig(
570
+ coordRoot: string,
571
+ config: SupervisorServiceConfig,
572
+ timestamp: number,
573
+ ): SupervisorServiceRuntime {
574
+ let existing: SupervisorServiceRuntime | undefined;
575
+ try {
576
+ existing = readSupervisorServiceRuntime(coordRoot);
577
+ } catch {
578
+ // Runtime is a recoverable scheduling hint; durable goal state remains authoritative.
579
+ }
580
+ if (existing?.config_created_at === config.created_at) {
581
+ const goals = Object.fromEntries(
582
+ config.goal_ids.map((goalId) => [goalId, existing.goals[goalId] ?? emptyGoalRuntime()]),
583
+ );
584
+ return { ...existing, goals };
585
+ }
586
+ return {
587
+ schema_version: SUPERVISOR_SERVICE_RUNTIME_SCHEMA_VERSION,
588
+ config_created_at: config.created_at,
589
+ updated_at: new Date(timestamp).toISOString(),
590
+ goals: Object.fromEntries(config.goal_ids.map((goalId) => [goalId, emptyGoalRuntime()])),
591
+ };
592
+ }
593
+
594
+ function emptyGoalRuntime(): SupervisorServiceGoalRuntime {
595
+ return { state: "idle", consecutive_errors: 0 };
596
+ }
597
+
598
+ function persistRuntime(
599
+ coordRoot: string,
600
+ runtime: SupervisorServiceRuntime,
601
+ timestamp: number,
602
+ ): void {
603
+ runtime.updated_at = new Date(timestamp).toISOString();
604
+ writePrivateJsonAtomic(serviceRuntimePath(coordRoot), runtime);
605
+ }
606
+
607
+ function goalFingerprint(record: SupervisorRecord): string {
608
+ return projectionFingerprint(record.projection);
609
+ }
610
+
611
+ function projectionFingerprint(projection: SupervisorRecord["projection"]): string {
612
+ return JSON.stringify([
613
+ projection.state,
614
+ projection.next_action,
615
+ projection.attempts_used,
616
+ projection.attempts_remaining,
617
+ projection.ready_work,
618
+ projection.resumable_work,
619
+ projection.retryable_work,
620
+ projection.attention_work,
621
+ ]);
622
+ }
623
+
624
+ function wakeDue(value: string | undefined, now: number): boolean {
625
+ return !value || Date.parse(value) <= now;
626
+ }
627
+
628
+ function normalizeEngineConfig(
629
+ coordRoot: string,
630
+ input: Partial<SupervisorServiceEngineConfig> | undefined,
631
+ ): SupervisorServiceEngineConfig {
632
+ return {
633
+ default_harness: optionalBounded(input?.default_harness, "default_harness", 100),
634
+ cwd: input?.cwd ? resolve(input.cwd) : coordRoot,
635
+ subscription_only: bool(input?.subscription_only ?? false, "subscription_only"),
636
+ allow_api_billing: bool(input?.allow_api_billing ?? false, "allow_api_billing"),
637
+ policy: input?.policy ? normalizePolicy(input.policy) : undefined,
638
+ isolation: normalizeIsolation(input?.isolation),
639
+ approval_addressee: optionalBounded(input?.approval_addressee, "approval_addressee", 200),
640
+ };
641
+ }
642
+
643
+ function validateConfig(coordRoot: string, config: SupervisorServiceConfig): void {
644
+ if (
645
+ config.schema_version !== SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION ||
646
+ !validTimestamp(config.created_at) ||
647
+ !Array.isArray(config.goal_ids)
648
+ ) {
649
+ throw new Error("supervisor service configuration has an unsupported schema");
650
+ }
651
+ const normalized: SupervisorServiceConfig = {
652
+ schema_version: SUPERVISOR_SERVICE_CONFIG_SCHEMA_VERSION,
653
+ goal_ids: unique(config.goal_ids.map((goalId) => goalId.trim()).filter(Boolean)),
654
+ wake_interval_ms: positive(
655
+ config.wake_interval_ms,
656
+ "service wake_interval_ms",
657
+ 60 * 60 * 1_000,
658
+ ),
659
+ heartbeat_interval_ms: positive(
660
+ config.heartbeat_interval_ms,
661
+ "service heartbeat_interval_ms",
662
+ 60_000,
663
+ ),
664
+ error_backoff_base_ms: positive(
665
+ config.error_backoff_base_ms,
666
+ "service error_backoff_base_ms",
667
+ 60 * 60 * 1_000,
668
+ ),
669
+ error_backoff_max_ms: positive(
670
+ config.error_backoff_max_ms,
671
+ "service error_backoff_max_ms",
672
+ 24 * 60 * 60 * 1_000,
673
+ ),
674
+ engine: normalizeEngineConfig(coordRoot, config.engine),
675
+ created_at: config.created_at,
676
+ };
677
+ if (
678
+ normalized.goal_ids.length < 1 ||
679
+ normalized.goal_ids.length > MAX_GOALS ||
680
+ normalized.error_backoff_max_ms < normalized.error_backoff_base_ms ||
681
+ JSON.stringify(normalized) !== JSON.stringify(config)
682
+ ) {
683
+ throw new Error("supervisor service configuration is not canonical");
684
+ }
685
+ }
686
+
687
+ function normalizeIsolation(value: unknown): PolicyIsolation | undefined {
688
+ if (value === undefined) return undefined;
689
+ if (value === "shared" || value === "worktree" || value === "sandbox" || value === "remote") {
690
+ return value;
691
+ }
692
+ throw new Error("service isolation must be shared, worktree, sandbox, or remote");
693
+ }
694
+
695
+ function acquireServiceLease(coordRoot: string): () => void {
696
+ const path = serviceLeasePath(coordRoot);
697
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
698
+ const owner: ServiceLease = {
699
+ pid: process.pid,
700
+ host: hostname(),
701
+ nonce: randomUUID(),
702
+ created_at: new Date().toISOString(),
703
+ };
704
+ const acquire = (): boolean => {
705
+ try {
706
+ const fd = openSync(path, "wx", 0o600);
707
+ try {
708
+ writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
709
+ } finally {
710
+ closeSync(fd);
711
+ }
712
+ return true;
713
+ } catch (error) {
714
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
715
+ return false;
716
+ }
717
+ };
718
+ if (!acquire()) {
719
+ const existing = readLease(path);
720
+ if (existing && leaseOwnerIsLive(existing)) {
721
+ throw new Error(`supervisor service is already running under pid ${existing.pid}`);
722
+ }
723
+ unlinkSync(path);
724
+ if (!acquire()) throw new Error("supervisor service lease raced with another process");
725
+ }
726
+ return () => {
727
+ try {
728
+ const existing = readLease(path);
729
+ if (existing?.nonce === owner.nonce) unlinkSync(path);
730
+ } catch {
731
+ // A stale lease is recoverable by the next explicit service start.
732
+ }
733
+ };
734
+ }
735
+
736
+ function readLease(path: string): ServiceLease | null {
737
+ try {
738
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<ServiceLease>;
739
+ return Number.isSafeInteger(value.pid) &&
740
+ (value.pid ?? 0) > 0 &&
741
+ typeof value.host === "string" &&
742
+ typeof value.nonce === "string" &&
743
+ typeof value.created_at === "string"
744
+ ? (value as ServiceLease)
745
+ : null;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+
751
+ function leaseOwnerIsLive(lease: ServiceLease): boolean {
752
+ if (lease.host !== hostname()) {
753
+ const age = Date.now() - Date.parse(lease.created_at);
754
+ return Number.isFinite(age) && age < FOREIGN_STATUS_STALE_MS;
755
+ }
756
+ return pidAlive(lease.pid);
757
+ }
758
+
759
+ function readStatusRecord(coordRoot: string): SupervisorServiceStatusRecord | undefined {
760
+ if (!existsSync(serviceStatusPath(coordRoot))) return undefined;
761
+ try {
762
+ const value = readBoundedJson<SupervisorServiceStatusRecord>(
763
+ serviceStatusPath(coordRoot),
764
+ "supervisor service status",
765
+ );
766
+ if (
767
+ value.schema_version !== SUPERVISOR_SERVICE_STATUS_SCHEMA_VERSION ||
768
+ !Number.isSafeInteger(value.pid) ||
769
+ value.pid < 1 ||
770
+ typeof value.host !== "string" ||
771
+ typeof value.nonce !== "string" ||
772
+ !validTimestamp(value.started_at) ||
773
+ !validTimestamp(value.heartbeat_at)
774
+ ) {
775
+ return undefined;
776
+ }
777
+ return value;
778
+ } catch {
779
+ return undefined;
780
+ }
781
+ }
782
+
783
+ function statusOwnerIsLive(record: SupervisorServiceStatusRecord): boolean {
784
+ if (record.state === "stopped") return false;
785
+ if (record.host === hostname()) return pidAlive(record.pid);
786
+ const age = Date.now() - Date.parse(record.heartbeat_at);
787
+ return Number.isFinite(age) && age < FOREIGN_STATUS_STALE_MS;
788
+ }
789
+
790
+ function pidAlive(pid: number): boolean {
791
+ try {
792
+ process.kill(pid, 0);
793
+ return true;
794
+ } catch (error) {
795
+ return (error as NodeJS.ErrnoException).code === "EPERM";
796
+ }
797
+ }
798
+
799
+ function appendServiceEvent(coordRoot: string, data: Record<string, unknown>): void {
800
+ const path = serviceEventsPath(coordRoot);
801
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
802
+ appendFileSync(
803
+ path,
804
+ `${JSON.stringify({ schema_version: 1, ts: new Date().toISOString(), ...data })}\n`,
805
+ { encoding: "utf8", mode: 0o600 },
806
+ );
807
+ chmodSync(path, 0o600);
808
+ }
809
+
810
+ function renderSweepLog(report: SupervisorServiceSweepReport): string | undefined {
811
+ const counts = report.outcomes.reduce<Record<string, number>>((acc, outcome) => {
812
+ acc[outcome.action] = (acc[outcome.action] ?? 0) + 1;
813
+ return acc;
814
+ }, {});
815
+ if (!counts.tick && !counts.backoff) return undefined;
816
+ return `supervisor service sweep: ${counts.tick ?? 0} tick, ${counts.skip ?? 0} skip, ${counts.backoff ?? 0} backoff`;
817
+ }
818
+
819
+ function readBoundedJson<T>(path: string, label: string): T {
820
+ if (!existsSync(path)) throw new Error(`${label} does not exist`);
821
+ const size = statSync(path).size;
822
+ if (size <= 0 || size > MAX_FILE_BYTES) throw new Error(`${label} has invalid size ${size}`);
823
+ try {
824
+ return JSON.parse(readFileSync(path, "utf8")) as T;
825
+ } catch (error) {
826
+ throw new Error(`cannot parse ${label}: ${(error as Error).message}`);
827
+ }
828
+ }
829
+
830
+ function writePrivateJsonAtomic(path: string, value: unknown): void {
831
+ const body = `${JSON.stringify(value, null, 2)}\n`;
832
+ if (Buffer.byteLength(body) > MAX_FILE_BYTES) {
833
+ throw new Error(`supervisor service file exceeds ${MAX_FILE_BYTES} bytes`);
834
+ }
835
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
836
+ chmodSync(dirname(path), 0o700);
837
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
838
+ writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
839
+ renameSync(temporary, path);
840
+ chmodSync(path, 0o600);
841
+ }
842
+
843
+ function serviceDir(coordRoot: string): string {
844
+ return join(resolve(coordRoot), ".harnery", "supervisor-service");
845
+ }
846
+
847
+ function serviceConfigPath(coordRoot: string): string {
848
+ return join(serviceDir(coordRoot), "config.json");
849
+ }
850
+
851
+ function serviceRuntimePath(coordRoot: string): string {
852
+ return join(serviceDir(coordRoot), "runtime.json");
853
+ }
854
+
855
+ function serviceStatusPath(coordRoot: string): string {
856
+ return join(serviceDir(coordRoot), "status.json");
857
+ }
858
+
859
+ function serviceLeasePath(coordRoot: string): string {
860
+ return join(serviceDir(coordRoot), "lease.json");
861
+ }
862
+
863
+ function serviceStopPath(coordRoot: string): string {
864
+ return join(serviceDir(coordRoot), "stop.json");
865
+ }
866
+
867
+ function serviceLogPath(coordRoot: string): string {
868
+ return join(serviceDir(coordRoot), "service.log");
869
+ }
870
+
871
+ function serviceEventsPath(coordRoot: string): string {
872
+ return join(serviceDir(coordRoot), "events.jsonl");
873
+ }
874
+
875
+ function positive(value: unknown, field: string, max: number): number {
876
+ if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) {
877
+ throw new Error(`${field} must be an integer from 1 to ${max}`);
878
+ }
879
+ return value as number;
880
+ }
881
+
882
+ function bool(value: unknown, field: string): boolean {
883
+ if (typeof value !== "boolean") throw new Error(`service ${field} must be boolean`);
884
+ return value;
885
+ }
886
+
887
+ function optionalBounded(value: unknown, field: string, max: number): string | undefined {
888
+ if (value === undefined) return undefined;
889
+ if (typeof value !== "string") throw new Error(`service ${field} must be a string`);
890
+ const normalized = value.trim();
891
+ if (!normalized) throw new Error(`service ${field} must not be empty`);
892
+ if (normalized.length > max) throw new Error(`service ${field} exceeds ${max} characters`);
893
+ return normalized;
894
+ }
895
+
896
+ function validTimestamp(value: unknown): value is string {
897
+ return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
898
+ }
899
+
900
+ function boundedError(message: string): string {
901
+ const normalized = message.replace(/\s+/g, " ").trim() || "unknown service error";
902
+ return normalized.length > MAX_ERROR ? `${normalized.slice(0, MAX_ERROR - 1)}…` : normalized;
903
+ }
904
+
905
+ function unique<T>(values: T[]): T[] {
906
+ return [...new Set(values)];
907
+ }
908
+
909
+ function delay(ms: number): Promise<void> {
910
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
911
+ }
912
+
913
+ async function interruptibleDelay(ms: number, interrupted: () => boolean): Promise<void> {
914
+ const deadline = Date.now() + ms;
915
+ while (!interrupted()) {
916
+ const remaining = deadline - Date.now();
917
+ if (remaining <= 0) return;
918
+ await delay(Math.min(remaining, 100));
919
+ }
920
+ }