@ours.network/fleet 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +38 -2
  2. package/dist/application/role-removal-service.js +1 -1
  3. package/dist/application/session-control.d.ts +14 -10
  4. package/dist/application/session-control.js +14 -3
  5. package/dist/atomic-file.d.ts +7 -1
  6. package/dist/atomic-file.js +33 -5
  7. package/dist/build-info.json +10 -0
  8. package/dist/capabilities.d.ts +20 -0
  9. package/dist/capabilities.js +21 -0
  10. package/dist/cli.js +98 -10
  11. package/dist/config.d.ts +9 -2
  12. package/dist/config.js +16 -2
  13. package/dist/creation.d.ts +16 -0
  14. package/dist/creation.js +28 -0
  15. package/dist/docs.d.ts +1 -1
  16. package/dist/docs.js +70 -4
  17. package/dist/doctor.d.ts +5 -0
  18. package/dist/doctor.js +87 -2
  19. package/dist/harness/acp-agent.d.ts +3 -0
  20. package/dist/harness/acp-agent.js +4 -1
  21. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  22. package/dist/harness/codex-app-server-proxy.js +133 -0
  23. package/dist/harness/codex.js +79 -11
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +1 -0
  26. package/dist/loops/manager.d.ts +42 -1
  27. package/dist/loops/manager.js +115 -16
  28. package/dist/loops/state.d.ts +46 -2
  29. package/dist/loops/state.js +81 -3
  30. package/dist/monitor.d.ts +21 -0
  31. package/dist/monitor.js +42 -0
  32. package/dist/ops.d.ts +6 -0
  33. package/dist/ops.js +46 -1
  34. package/dist/owner-channel/channel.d.ts +18 -2
  35. package/dist/owner-channel/channel.js +146 -2
  36. package/dist/owner-channel/commands.d.ts +2 -2
  37. package/dist/owner-channel/commands.js +7 -2
  38. package/dist/owner-channel/notices.d.ts +2 -0
  39. package/dist/owner-channel/notices.js +3 -0
  40. package/dist/provenance.d.ts +77 -0
  41. package/dist/provenance.js +283 -0
  42. package/dist/runner.d.ts +7 -1
  43. package/dist/runner.js +100 -14
  44. package/dist/session/acp.d.ts +40 -4
  45. package/dist/session/acp.js +157 -30
  46. package/dist/session/arbiter.d.ts +28 -2
  47. package/dist/session/arbiter.js +75 -4
  48. package/dist/session/control.js +12 -6
  49. package/dist/session/event-log.d.ts +109 -0
  50. package/dist/session/event-log.js +247 -0
  51. package/dist/session/events.d.ts +21 -0
  52. package/dist/session/events.js +105 -26
  53. package/dist/session/tmux.d.ts +3 -2
  54. package/dist/session/tmux.js +2 -0
  55. package/dist/session/types.d.ts +39 -2
  56. package/dist/session/types.js +11 -1
  57. package/dist/spawn.d.ts +3 -3
  58. package/dist/spawn.js +40 -14
  59. package/dist/temp-lifecycle.d.ts +62 -0
  60. package/dist/temp-lifecycle.js +437 -0
  61. package/package.json +5 -3
package/dist/spawn.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
- import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
@@ -12,6 +12,7 @@ import { VERSION } from './version.js';
12
12
  import './harness/claude-code.js';
13
13
  import './harness/codex.js';
14
14
  import { getAdapter } from './harness/registry.js';
15
+ import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, } from './temp-lifecycle.js';
15
16
  /**
16
17
  * The provenance record written by the most recent spawn in this process, so
17
18
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -341,22 +342,37 @@ export async function spawnPermanent(o, deps, creation = {}) {
341
342
  return file;
342
343
  }, creation);
343
344
  }
344
- const detachedSupervisor = (binPath, args, dir) => {
345
- // Log to the temp dir; the fd stays valid even after runTemp removes the dir.
345
+ /** Fallback used only when service-manager supervision is explicitly disabled. */
346
+ const spawnDetached = (binPath, args, dir) => {
347
+ // Log to the temp dir; the child fd stays valid when retirement moves the
348
+ // directory into the evidence archive.
346
349
  const out = openSync(join(dir, 'supervisor.log'), 'a');
347
- const child = spawnChild(process.execPath, [binPath, ...args], {
348
- detached: true,
349
- stdio: ['ignore', out, out],
350
- });
350
+ let child;
351
+ try {
352
+ child = spawnChild(process.execPath, [binPath, ...args], {
353
+ detached: true,
354
+ stdio: ['ignore', out, out],
355
+ });
356
+ }
357
+ finally {
358
+ closeSync(out);
359
+ }
351
360
  child.unref();
361
+ if (!child.pid)
362
+ throw new Error('detached temporary supervisor did not report a pid');
363
+ return child.pid;
352
364
  };
353
- /** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
354
- export async function spawnTemp(o, binPath, launch = detachedSupervisor, creation = {}) {
365
+ const independentSupervisor = makeTempSupervisorLauncher({ spawnDetached });
366
+ /** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
367
+ export async function spawnTemp(o, binPath, launch = independentSupervisor, creation = {}) {
355
368
  validateSpawnOpts(o);
356
369
  if (o.isolationFile)
357
370
  readIsolationFile(o.isolationFile); // fail before reserving
358
371
  if (o.missionFile)
359
372
  readMissionFile(o.missionFile); // fail before reserving
373
+ // Retire only supervisors whose recorded owner is definitively stopped. This
374
+ // bounded pass keeps the active roster clean without deleting old evidence.
375
+ await reclaimStaleTempState();
360
376
  // Temporary roles go through the SAME reservation boundary as permanent ones
361
377
  // (6.4): a temp agent competes for the same names.
362
378
  creation.onStage?.('reserving');
@@ -425,19 +441,29 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
425
441
  });
426
442
  writeProvenance(dir, provenance);
427
443
  lastProvenance = provenance;
428
- tx.record({ stage: `temp state dir ${dir}`, undo: () => rmSync(dir, { recursive: true, force: true }) });
444
+ tx.record({
445
+ stage: `temp state dir ${dir}`,
446
+ undo: () => {
447
+ // A failed launch is still lifecycle evidence: briefing, provenance,
448
+ // metadata and supervisor output explain what happened. Remove it from
449
+ // the live roster by atomic archive, never recursive deletion.
450
+ archiveTempState(o.name, 'startup-failure', 'failed', 'temporary creation rolled back after launch/setup failure; evidence preserved');
451
+ },
452
+ });
429
453
  writeFileSync(join(dir, 'role.yaml'), stringify(role));
430
454
  // Snapshot the fleet start-stagger so the detached temp supervisor (no config path
431
455
  // threaded through it) honors the same launch gate — a burst of temp spawns spaces
432
456
  // out; a lone temp spawn still waits zero (time-based gate).
433
457
  if (cfg.startStaggerMs > 0)
434
458
  writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
435
- // Run the supervisor DETACHED — NOT inside a tmux session named <name>.
459
+ prepareTempSupervisor(dir, o.name);
460
+ // Run the supervisor independently — NOT inside a tmux session named <name>.
436
461
  // `_run-temp` -> runOnce() creates AND kills the tmux session <name> for the
437
462
  // agent itself; a supervisor sharing that session name would SIGHUP its own
438
- // process before the agent ever launches. Detaching mirrors how systemd hosts
439
- // the supervisor for permanent roles, leaving runOnce to own the <name> session.
463
+ // process before the agent ever launches. On a service-managed host the temp
464
+ // runner gets its own transient unit/job, so stopping the coordinator's unit
465
+ // cannot kill a live worker in the coordinator's cgroup.
440
466
  onStage?.('starting_temp');
441
- launch(binPath, ['_run-temp', o.name], dir);
467
+ await launch(binPath, ['_run-temp', o.name], dir);
442
468
  return dir;
443
469
  }
@@ -0,0 +1,62 @@
1
+ import { type Exec } from './exec.js';
2
+ export declare const TEMP_SUPERVISOR_FILE = ".temp-supervisor.json";
3
+ export declare const TEMP_TERMINATION_FILE = "termination.jsonl";
4
+ export declare const TEMP_STOP_REQUEST_FILE = ".temp-stop-request.json";
5
+ export declare const TEMP_RECLAIM_BATCH = 32;
6
+ export declare const TEMP_LAUNCH_GRACE_MS = 60000;
7
+ export type TempSupervisorKind = 'systemd-transient' | 'launchd-transient' | 'detached';
8
+ export type TempTerminationReason = 'identity-closed' | 'session-ended' | 'operator-stop' | 'supervisor-signal' | 'startup-failure' | 'stale-supervisor';
9
+ export interface TempSupervisorRecord {
10
+ version: 1;
11
+ role: string;
12
+ launchId: string;
13
+ createdAt: string;
14
+ phase: 'launching' | 'active';
15
+ kind?: TempSupervisorKind;
16
+ target?: string;
17
+ pid?: number;
18
+ binPath?: string;
19
+ }
20
+ export interface TempTerminationRecord {
21
+ version: 1;
22
+ role: string;
23
+ launchId?: string;
24
+ at: string;
25
+ reason: TempTerminationReason;
26
+ outcome: 'retired' | 'reclaimed' | 'failed';
27
+ detail: string;
28
+ }
29
+ export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void | Promise<void>;
30
+ export declare const tempSystemdUnit: (name: string) => string;
31
+ export declare const tempLaunchdLabel: (name: string) => string;
32
+ export declare function prepareTempSupervisor(dir: string, role: string): TempSupervisorRecord;
33
+ export declare function readTempSupervisor(dir: string): TempSupervisorRecord | undefined;
34
+ /**
35
+ * Launch a temp supervisor outside the caller's service-manager ownership
36
+ * boundary. `detached: true` creates a new process group but does not escape a
37
+ * systemd cgroup; a transient unit does, and is not enabled across reboot.
38
+ */
39
+ export declare function makeTempSupervisorLauncher(options?: {
40
+ exec?: Exec;
41
+ platform?: NodeJS.Platform;
42
+ supervisor?: string;
43
+ spawnDetached?: (binPath: string, args: string[], dir: string) => number;
44
+ }): SupervisorLauncher;
45
+ export declare function markTempSupervisorActive(dir: string, pid?: number): Promise<void>;
46
+ export declare function requestedTempStopReason(dir: string): 'operator-stop' | undefined;
47
+ /** Move retired state out of the live roster without deleting any evidence. */
48
+ export declare function archiveTempState(role: string, reason: TempTerminationReason, outcome: TempTerminationRecord['outcome'], detail: string, now?: Date): string | undefined;
49
+ interface TempLifecycleDeps {
50
+ exec?: Exec;
51
+ now?(): number;
52
+ kill?(pid: number, signal: NodeJS.Signals | 0): void;
53
+ log?(line: string): void;
54
+ }
55
+ export declare function tempSupervisorLiveness(dir: string, deps?: TempLifecycleDeps): Promise<'running' | 'stopped' | 'unknown'>;
56
+ export declare function stopTempSupervisor(role: string, deps?: TempLifecycleDeps): Promise<'stopped' | 'already-stopped'>;
57
+ /**
58
+ * Move a bounded batch of definitely-dead temp state into the evidence archive.
59
+ * Unknown/legacy/live entries are preserved; absence of proof is never cleanup authority.
60
+ */
61
+ export declare function reclaimStaleTempState(deps?: TempLifecycleDeps): Promise<string[]>;
62
+ export {};
@@ -0,0 +1,437 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, join } from 'node:path';
4
+ import { replaceFileAtomically, withFileLock } from './atomic-file.js';
5
+ import { realExec } from './exec.js';
6
+ import { stateRoot, tmpRoot } from './paths.js';
7
+ export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
8
+ export const TEMP_TERMINATION_FILE = 'termination.jsonl';
9
+ export const TEMP_STOP_REQUEST_FILE = '.temp-stop-request.json';
10
+ const TEMP_GLOBAL_TERMINATION_MARKER = '.termination-globally-recorded';
11
+ export const TEMP_RECLAIM_BATCH = 32;
12
+ export const TEMP_LAUNCH_GRACE_MS = 60_000;
13
+ const metadataPath = (dir) => join(dir, TEMP_SUPERVISOR_FILE);
14
+ export const tempSystemdUnit = (name) => `ours-fleet-temp-${name}.service`;
15
+ export const tempLaunchdLabel = (name) => `network.ours.fleet.temp.${name}`;
16
+ export function prepareTempSupervisor(dir, role) {
17
+ const record = {
18
+ version: 1, role, launchId: randomUUID(), createdAt: new Date().toISOString(), phase: 'launching',
19
+ };
20
+ replaceFileAtomically(metadataPath(dir), JSON.stringify(record, null, 2) + '\n');
21
+ return record;
22
+ }
23
+ export function readTempSupervisor(dir) {
24
+ try {
25
+ const value = JSON.parse(readFileSync(metadataPath(dir), 'utf8'));
26
+ if (value.version !== 1 || typeof value.role !== 'string' || typeof value.launchId !== 'string')
27
+ return undefined;
28
+ return value;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ const metadataLockPath = (dir) => join(stateRoot(), 'locks', 'temp-supervisors', encodeURIComponent(basename(dir)));
35
+ async function updateTempSupervisor(dir, update) {
36
+ // The launcher and the just-started supervisor are separate processes. Lock
37
+ // their read/merge/write updates so neither can discard the other's kind,
38
+ // target, pid or phase. The lock lives outside the role directory so an
39
+ // already-archived role is never accidentally recreated by a late writer.
40
+ return withFileLock(metadataLockPath(dir), () => {
41
+ if (!existsSync(dir))
42
+ return undefined;
43
+ const current = readTempSupervisor(dir);
44
+ if (!current)
45
+ throw new Error(`temporary supervisor metadata is missing from ${dir}`);
46
+ const next = { ...current, ...update };
47
+ replaceFileAtomically(metadataPath(dir), JSON.stringify(next, null, 2) + '\n');
48
+ return next;
49
+ });
50
+ }
51
+ /**
52
+ * Launch a temp supervisor outside the caller's service-manager ownership
53
+ * boundary. `detached: true` creates a new process group but does not escape a
54
+ * systemd cgroup; a transient unit does, and is not enabled across reboot.
55
+ */
56
+ export function makeTempSupervisorLauncher(options = {}) {
57
+ const exec = options.exec ?? realExec;
58
+ const platform = options.platform ?? process.platform;
59
+ const supervisor = options.supervisor ?? process.env.OURS_FLEET_SUPERVISOR;
60
+ return async (binPath, args, dir) => {
61
+ const inherited = [
62
+ 'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'CODEX_HOME',
63
+ // The child supervisor performs daemon identity and wake probes itself;
64
+ // it must resolve the same ours profile as the spawning supervisor.
65
+ 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_CONFIG',
66
+ ]
67
+ .flatMap(key => process.env[key] !== undefined ? [`${key}=${process.env[key]}`] : []);
68
+ const role = args.at(-1);
69
+ if (!role)
70
+ throw new Error('temporary supervisor launch requires a role name');
71
+ const log = join(dir, 'supervisor.log');
72
+ if (supervisor !== 'none' && platform === 'linux') {
73
+ const target = tempSystemdUnit(role);
74
+ await updateTempSupervisor(dir, { phase: 'launching', kind: 'systemd-transient', target, binPath });
75
+ const result = await exec('systemd-run', [
76
+ '--user', '--quiet', '--collect', `--unit=${target}`,
77
+ '--property=Type=exec', '--property=KillMode=control-group', '--property=TimeoutStopSec=15s',
78
+ `--property=StandardOutput=append:${log}`, `--property=StandardError=append:${log}`,
79
+ ...inherited.map(value => `--setenv=${value}`),
80
+ process.execPath, binPath, ...args,
81
+ ]);
82
+ if (result.code !== 0)
83
+ throw new Error(`systemd-run ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
84
+ await updateTempSupervisor(dir, { phase: 'active' });
85
+ return;
86
+ }
87
+ if (supervisor !== 'none' && platform === 'darwin') {
88
+ const target = tempLaunchdLabel(role);
89
+ await updateTempSupervisor(dir, { phase: 'launching', kind: 'launchd-transient', target, binPath });
90
+ const result = await exec('launchctl', [
91
+ 'submit', '-l', target, '-o', log, '-e', log, '--',
92
+ '/usr/bin/env', ...inherited, process.execPath, binPath, ...args,
93
+ ]);
94
+ if (result.code !== 0)
95
+ throw new Error(`launchctl submit ${target} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
96
+ await updateTempSupervisor(dir, { phase: 'active' });
97
+ return;
98
+ }
99
+ if (!options.spawnDetached)
100
+ throw new Error('detached temp launch requires a spawnDetached implementation');
101
+ const pid = options.spawnDetached(binPath, args, dir);
102
+ await updateTempSupervisor(dir, { phase: 'active', kind: 'detached', pid, binPath });
103
+ };
104
+ }
105
+ export async function markTempSupervisorActive(dir, pid = process.pid) {
106
+ const current = readTempSupervisor(dir);
107
+ if (!current)
108
+ return;
109
+ await updateTempSupervisor(dir, { phase: 'active', pid });
110
+ }
111
+ export function requestedTempStopReason(dir) {
112
+ try {
113
+ const value = JSON.parse(readFileSync(join(dir, TEMP_STOP_REQUEST_FILE), 'utf8'));
114
+ return value.reason === 'operator-stop' ? value.reason : undefined;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ function archiveRoot() {
121
+ return join(stateRoot(), 'recovery', 'temporary');
122
+ }
123
+ function appendTermination(dir, record) {
124
+ const line = JSON.stringify(record) + '\n';
125
+ appendFileSync(join(dir, TEMP_TERMINATION_FILE), line, { mode: 0o600 });
126
+ appendGlobalTermination(line);
127
+ // Normal retirement no longer rereads the entire global journal. This
128
+ // durable per-archive marker tells crash recovery the append completed; only
129
+ // the narrow crash seam before this marker needs the legacy dedupe scan.
130
+ replaceFileAtomically(join(dir, TEMP_GLOBAL_TERMINATION_MARKER), line);
131
+ }
132
+ function appendGlobalTermination(line, checkExisting = false) {
133
+ mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
134
+ const path = join(archiveRoot(), 'terminations.jsonl');
135
+ // Recovery may revisit a .retiring directory after a crash between the
136
+ // global append and final rename. Avoid duplicating that exact event.
137
+ if (checkExisting) {
138
+ try {
139
+ if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
140
+ return;
141
+ }
142
+ catch { /* the journal does not exist yet */ }
143
+ }
144
+ appendFileSync(path, line, { mode: 0o600 });
145
+ }
146
+ /** Pick a sibling path without overwriting evidence from an earlier attempt. */
147
+ function collisionSafeArchivePaths(targetBase, retiringBase) {
148
+ for (let attempt = 0;; attempt++) {
149
+ const discriminator = attempt === 0 ? '' : `-${attempt + 1}`;
150
+ const target = `${targetBase}${discriminator}`;
151
+ const retiring = `${retiringBase}${discriminator}`;
152
+ if (!existsSync(target) && !existsSync(retiring))
153
+ return { target, retiring };
154
+ }
155
+ }
156
+ function roleFromRetiringName(name) {
157
+ const stem = name.slice(1, -'.retiring'.length);
158
+ // Archive names end in the eight-hex launch discriminator, optionally plus
159
+ // a numeric collision discriminator. Strip from the RIGHT so role names such
160
+ // as Developer-3 and Tester-2 remain intact.
161
+ return /^(.*)-[0-9a-f]{8}(?:-\d+)?$/i.exec(stem)?.[1] ?? stem;
162
+ }
163
+ /** Move retired state out of the live roster without deleting any evidence. */
164
+ export function archiveTempState(role, reason, outcome, detail, now = new Date()) {
165
+ const dir = join(tmpRoot(), role);
166
+ if (!existsSync(dir))
167
+ return undefined;
168
+ const supervisor = readTempSupervisor(dir);
169
+ const record = {
170
+ version: 1, role, launchId: supervisor?.launchId, at: now.toISOString(), reason, outcome, detail,
171
+ };
172
+ mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
173
+ const stamp = now.toISOString().replaceAll(/[:.]/g, '-');
174
+ const suffix = supervisor?.launchId.slice(0, 8) ?? randomUUID().slice(0, 8);
175
+ const paths = collisionSafeArchivePaths(join(archiveRoot(), `${stamp}-${role}-${suffix}`), join(archiveRoot(), `.${role}-${suffix}.retiring`));
176
+ // The rename is the idempotency boundary: only one concurrent retirement
177
+ // owns the live directory. Everyone else sees it absent and does nothing.
178
+ try {
179
+ renameSync(dir, paths.retiring);
180
+ }
181
+ catch (error) {
182
+ if (error.code === 'ENOENT' && !existsSync(dir))
183
+ return undefined;
184
+ throw error;
185
+ }
186
+ try {
187
+ appendTermination(paths.retiring, record);
188
+ }
189
+ finally {
190
+ renameSync(paths.retiring, paths.target);
191
+ }
192
+ return paths.target;
193
+ }
194
+ /** Finish bounded archive renames interrupted by process or host termination. */
195
+ function recoverInterruptedArchives(now) {
196
+ let names;
197
+ try {
198
+ names = readdirSync(archiveRoot(), { withFileTypes: true })
199
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink()
200
+ && entry.name.startsWith('.') && entry.name.endsWith('.retiring'))
201
+ .map(entry => entry.name)
202
+ .sort()
203
+ .slice(0, TEMP_RECLAIM_BATCH);
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ const recovered = [];
209
+ for (const name of names) {
210
+ const source = join(archiveRoot(), name);
211
+ const supervisor = readTempSupervisor(source);
212
+ let line;
213
+ try {
214
+ line = readFileSync(join(source, TEMP_TERMINATION_FILE), 'utf8')
215
+ .split('\n').filter(Boolean).at(-1);
216
+ }
217
+ catch { /* synthesize the audit event below */ }
218
+ if (!line) {
219
+ const record = {
220
+ version: 1,
221
+ role: supervisor?.role ?? roleFromRetiringName(name),
222
+ launchId: supervisor?.launchId,
223
+ at: now.toISOString(),
224
+ reason: 'stale-supervisor',
225
+ outcome: 'reclaimed',
226
+ detail: 'completed an evidence archive interrupted before its termination journal was durable',
227
+ };
228
+ line = JSON.stringify(record);
229
+ appendFileSync(join(source, TEMP_TERMINATION_FILE), line + '\n', { mode: 0o600 });
230
+ }
231
+ if (!existsSync(join(source, TEMP_GLOBAL_TERMINATION_MARKER))) {
232
+ appendGlobalTermination(line + '\n', true);
233
+ replaceFileAtomically(join(source, TEMP_GLOBAL_TERMINATION_MARKER), line + '\n');
234
+ }
235
+ const suffix = name.slice(1, -'.retiring'.length);
236
+ const targetBase = join(archiveRoot(), `${now.toISOString().replaceAll(/[:.]/g, '-')}-recovered-${suffix}`);
237
+ let target = targetBase;
238
+ for (let attempt = 2; existsSync(target); attempt++)
239
+ target = `${targetBase}-${attempt}`;
240
+ renameSync(source, target);
241
+ recovered.push(target);
242
+ }
243
+ return recovered;
244
+ }
245
+ async function detachedProcessLiveness(record, deps) {
246
+ if (!record.pid || !Number.isSafeInteger(record.pid) || record.pid < 2)
247
+ return 'unknown';
248
+ try {
249
+ (deps.kill ?? process.kill)(record.pid, 0);
250
+ }
251
+ catch (error) {
252
+ return error.code === 'ESRCH' ? 'stopped' : 'unknown';
253
+ }
254
+ try {
255
+ const argv = readFileSync(`/proc/${record.pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
256
+ const i = argv.indexOf('_run-temp');
257
+ return i >= 1 && argv[i + 1] === record.role ? 'running' : 'stopped';
258
+ }
259
+ catch { /* macOS and other non-/proc hosts use ps below */ }
260
+ const result = await (deps.exec ?? realExec)('ps', ['-p', String(record.pid), '-o', 'command=']);
261
+ if (result.code !== 0)
262
+ return result.code === 1 ? 'stopped' : 'unknown';
263
+ const role = record.role.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
264
+ return new RegExp(`(?:^|\\s)_run-temp\\s+${role}(?:\\s|$)`).test(result.stdout.trim())
265
+ ? 'running' : 'stopped';
266
+ }
267
+ async function exactTempSupervisorPids(role, exec) {
268
+ const processes = await exec('ps', ['-ax', '-o', 'pid=', '-o', 'command=']);
269
+ if (processes.code !== 0)
270
+ return undefined;
271
+ const escaped = role.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
272
+ const pattern = new RegExp(`(?:^|\\s)_run-temp\\s+${escaped}(?:\\s|$)`);
273
+ return processes.stdout.split('\n').flatMap(line => {
274
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
275
+ return match && pattern.test(match[2]) ? [Number(match[1])] : [];
276
+ }).filter(pid => Number.isSafeInteger(pid) && pid >= 2);
277
+ }
278
+ export async function tempSupervisorLiveness(dir, deps = {}) {
279
+ const record = readTempSupervisor(dir);
280
+ if (!record)
281
+ return 'unknown';
282
+ const exec = deps.exec ?? realExec;
283
+ const age = (deps.now ?? Date.now)() - Date.parse(record.createdAt);
284
+ // A concurrent spawn writes the target before systemd-run/launchctl has
285
+ // registered it. A not-yet-created unit looks exactly like an inactive one,
286
+ // so launch phase plus its bounded grace is not cleanup authority.
287
+ if (record.phase === 'launching'
288
+ && (!Number.isFinite(age) || age < TEMP_LAUNCH_GRACE_MS))
289
+ return 'unknown';
290
+ // Older writers could lose `kind` in an unlocked metadata RMW while retaining
291
+ // the supervisor pid. Exact argv ownership is stronger than the missing tag.
292
+ if (record.pid && (!record.kind || record.kind === 'detached'))
293
+ return detachedProcessLiveness(record, deps);
294
+ if (record.kind === 'systemd-transient' && record.target) {
295
+ const result = await exec('systemctl', [
296
+ '--user', 'show', '-p', 'ActiveState', '--value', record.target,
297
+ ]);
298
+ const state = result.stdout.trim();
299
+ if (['active', 'activating', 'reloading', 'deactivating'].includes(state))
300
+ return 'running';
301
+ if (['inactive', 'failed'].includes(state)
302
+ || /not (?:be )?(?:found|loaded)|could not be found/i.test(result.stderr))
303
+ return 'stopped';
304
+ return 'unknown';
305
+ }
306
+ if (record.kind === 'launchd-transient' && record.target) {
307
+ const result = await exec('launchctl', ['print', `gui/${process.getuid?.() ?? 501}/${record.target}`]);
308
+ if (result.code === 0)
309
+ return 'running';
310
+ return /could not find service|no such process/i.test(`${result.stdout}\n${result.stderr}`)
311
+ ? 'stopped' : 'unknown';
312
+ }
313
+ if (record.kind === 'detached')
314
+ return detachedProcessLiveness(record, deps);
315
+ if (!Number.isFinite(age) || age < TEMP_LAUNCH_GRACE_MS)
316
+ return 'unknown';
317
+ // Incomplete records are settled only from an exact process-table match.
318
+ // Zero matches proves the old supervisor is gone; ambiguity remains unknown.
319
+ const matches = await exactTempSupervisorPids(record.role, exec);
320
+ if (!matches || matches.length > 1)
321
+ return 'unknown';
322
+ return matches.length === 1 ? 'running' : 'stopped';
323
+ }
324
+ export async function stopTempSupervisor(role, deps = {}) {
325
+ const dir = join(tmpRoot(), role);
326
+ if (!existsSync(dir))
327
+ return 'already-stopped';
328
+ const exec = deps.exec ?? realExec;
329
+ let record = readTempSupervisor(dir);
330
+ if (!record) {
331
+ // Upgrade seam for pre-metadata temp roles: derive an exact supervisor only
332
+ // from the OS process table, adopt that one pid into the new fenced record,
333
+ // and refuse ambiguity. Never infer ownership from a directory alone.
334
+ const matches = await exactTempSupervisorPids(role, exec);
335
+ if (!matches)
336
+ throw new Error(`temporary role '${role}' has no supervisor metadata and the process table `
337
+ + 'could not be read; refusing an unverified process kill');
338
+ if (matches.length > 1)
339
+ throw new Error(`temporary role '${role}' has ${matches.length} matching legacy supervisors; `
340
+ + 'refusing an ambiguous process kill');
341
+ if (matches.length === 0)
342
+ return 'already-stopped';
343
+ prepareTempSupervisor(dir, role);
344
+ record = await updateTempSupervisor(dir, {
345
+ phase: 'active', kind: 'detached', pid: matches[0], binPath: '(legacy adopted)',
346
+ });
347
+ }
348
+ if (!record)
349
+ return 'already-stopped';
350
+ if (record.role !== role)
351
+ throw new Error(`temporary role '${role}' metadata names '${record.role}'; refusing mismatched supervisor control`);
352
+ replaceFileAtomically(join(dir, TEMP_STOP_REQUEST_FILE), JSON.stringify({
353
+ version: 1, role, reason: 'operator-stop', requestedAt: new Date().toISOString(),
354
+ }) + '\n');
355
+ if (record.kind === 'systemd-transient' && record.target) {
356
+ const result = await exec('systemctl', ['--user', 'stop', record.target]);
357
+ if (result.code !== 0
358
+ && !/not (?:be )?(?:found|loaded)|could not be found/i.test(result.stderr))
359
+ throw new Error(`systemctl stop ${record.target} failed: ${result.stderr.trim()}`);
360
+ return result.code === 0 ? 'stopped' : 'already-stopped';
361
+ }
362
+ if (record.kind === 'launchd-transient' && record.target) {
363
+ const result = await exec('launchctl', ['remove', record.target]);
364
+ if (result.code !== 0 && !/could not find|no such process/i.test(`${result.stdout}\n${result.stderr}`))
365
+ throw new Error(`launchctl remove ${record.target} failed: ${result.stderr.trim()}`);
366
+ return result.code === 0 ? 'stopped' : 'already-stopped';
367
+ }
368
+ if (record.pid && (!record.kind || record.kind === 'detached')) {
369
+ const live = await detachedProcessLiveness(record, deps);
370
+ if (live === 'stopped')
371
+ return 'already-stopped';
372
+ if (live === 'unknown')
373
+ throw new Error(`temporary role '${role}' process ownership could not be verified; refusing to signal pid ${record.pid}`);
374
+ (deps.kill ?? process.kill)(record.pid, 'SIGTERM');
375
+ return 'stopped';
376
+ }
377
+ // A valid-but-incomplete record from an interrupted/unlocked older launch is
378
+ // not permanently unremovable. Adopt exactly one matching legacy process, or
379
+ // prove there is none; never guess when the process table is unreadable or
380
+ // more than one candidate exists.
381
+ const matches = await exactTempSupervisorPids(role, exec);
382
+ if (!matches)
383
+ throw new Error(`temporary role '${role}' has incomplete supervisor metadata and the process table `
384
+ + 'could not be read; refusing an unverified process kill');
385
+ if (matches.length > 1)
386
+ throw new Error(`temporary role '${role}' has incomplete supervisor metadata and ${matches.length} `
387
+ + 'matching supervisors; refusing an ambiguous process kill');
388
+ if (matches.length === 0)
389
+ return 'already-stopped';
390
+ record = await updateTempSupervisor(dir, {
391
+ phase: 'active', kind: 'detached', pid: matches[0], binPath: '(incomplete metadata adopted)',
392
+ });
393
+ if (!record)
394
+ return 'already-stopped';
395
+ const live = await detachedProcessLiveness(record, deps);
396
+ if (live !== 'running') {
397
+ if (live === 'stopped')
398
+ return 'already-stopped';
399
+ throw new Error(`temporary role '${role}' adopted process ownership could not be verified; `
400
+ + `refusing to signal pid ${record.pid}`);
401
+ }
402
+ (deps.kill ?? process.kill)(record.pid, 'SIGTERM');
403
+ return 'stopped';
404
+ }
405
+ /**
406
+ * Move a bounded batch of definitely-dead temp state into the evidence archive.
407
+ * Unknown/legacy/live entries are preserved; absence of proof is never cleanup authority.
408
+ */
409
+ export async function reclaimStaleTempState(deps = {}) {
410
+ const now = new Date((deps.now ?? Date.now)());
411
+ const recovered = recoverInterruptedArchives(now);
412
+ let entries = [];
413
+ try {
414
+ entries = readdirSync(tmpRoot(), { withFileTypes: true })
415
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
416
+ .map(entry => ({ name: entry.name, mtimeMs: statSync(join(tmpRoot(), entry.name)).mtimeMs }))
417
+ .sort((a, b) => a.mtimeMs - b.mtimeMs)
418
+ .slice(0, TEMP_RECLAIM_BATCH);
419
+ }
420
+ catch {
421
+ return recovered;
422
+ }
423
+ const archived = [...recovered];
424
+ for (const entry of entries) {
425
+ const dir = join(tmpRoot(), entry.name);
426
+ if (!readTempSupervisor(dir))
427
+ continue; // legacy evidence has no safe ownership proof
428
+ if (await tempSupervisorLiveness(dir, deps) !== 'stopped')
429
+ continue;
430
+ const target = archiveTempState(entry.name, 'stale-supervisor', 'reclaimed', 'supervisor is definitively stopped; state moved from the live roster without deletion', now);
431
+ if (target) {
432
+ archived.push(target);
433
+ deps.log?.(`reclaimed stale temporary role '${entry.name}' to ${target}`);
434
+ }
435
+ }
436
+ return archived;
437
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -23,13 +23,15 @@
23
23
  },
24
24
  "scripts": {
25
25
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
26
- "build": "npm run clean && tsc -p tsconfig.json && vite build",
26
+ "build": "npm run clean && tsc -p tsconfig.json && vite build && node scripts/build-info.mjs",
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
28
28
  "lint": "npm run typecheck",
29
29
  "test": "vitest run",
30
30
  "test:web": "vitest run test/web",
31
+ "test:pack": "vitest run --config vitest.integration.config.ts",
31
32
  "test:e2e": "npm run build && playwright test",
32
33
  "dev:web": "vite",
34
+ "prepack": "npm run build",
33
35
  "prepublishOnly": "npm run build && npm test"
34
36
  },
35
37
  "dependencies": {
@@ -50,7 +52,7 @@
50
52
  },
51
53
  "optionalDependencies": {
52
54
  "@agentclientprotocol/claude-agent-acp": "^0.63.0",
53
- "@agentclientprotocol/codex-acp": "^1.1.7",
55
+ "@agentclientprotocol/codex-acp": "1.1.7",
54
56
  "node-pty": "^1.1.0"
55
57
  },
56
58
  "devDependencies": {