@ours.network/fleet 0.17.0 → 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 (65) 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/fleet-proxy.js +2 -2
  20. package/dist/harness/acp-agent.d.ts +3 -0
  21. package/dist/harness/acp-agent.js +4 -1
  22. package/dist/harness/codex-app-server-proxy.d.ts +4 -0
  23. package/dist/harness/codex-app-server-proxy.js +133 -0
  24. package/dist/harness/codex.js +116 -11
  25. package/dist/harness/types.d.ts +2 -0
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +42 -1
  29. package/dist/loops/manager.js +115 -16
  30. package/dist/loops/state.d.ts +46 -2
  31. package/dist/loops/state.js +81 -3
  32. package/dist/monitor.d.ts +21 -0
  33. package/dist/monitor.js +42 -0
  34. package/dist/ops.d.ts +6 -0
  35. package/dist/ops.js +46 -1
  36. package/dist/owner-channel/channel.d.ts +18 -2
  37. package/dist/owner-channel/channel.js +146 -2
  38. package/dist/owner-channel/commands.d.ts +2 -2
  39. package/dist/owner-channel/commands.js +7 -2
  40. package/dist/owner-channel/notices.d.ts +2 -0
  41. package/dist/owner-channel/notices.js +3 -0
  42. package/dist/permissions.d.ts +2 -0
  43. package/dist/permissions.js +5 -0
  44. package/dist/provenance.d.ts +77 -0
  45. package/dist/provenance.js +283 -0
  46. package/dist/runner.d.ts +7 -1
  47. package/dist/runner.js +100 -14
  48. package/dist/session/acp.d.ts +40 -4
  49. package/dist/session/acp.js +272 -37
  50. package/dist/session/arbiter.d.ts +28 -2
  51. package/dist/session/arbiter.js +75 -4
  52. package/dist/session/control.js +12 -6
  53. package/dist/session/event-log.d.ts +109 -0
  54. package/dist/session/event-log.js +247 -0
  55. package/dist/session/events.d.ts +21 -0
  56. package/dist/session/events.js +105 -26
  57. package/dist/session/tmux.d.ts +3 -2
  58. package/dist/session/tmux.js +2 -0
  59. package/dist/session/types.d.ts +39 -2
  60. package/dist/session/types.js +11 -1
  61. package/dist/spawn.d.ts +3 -3
  62. package/dist/spawn.js +40 -14
  63. package/dist/temp-lifecycle.d.ts +62 -0
  64. package/dist/temp-lifecycle.js +437 -0
  65. package/package.json +5 -3
@@ -62,10 +62,38 @@ export interface TurnResult {
62
62
  * session is gone, and `timeout` explicitly does NOT say the prompt was lost.
63
63
  */
64
64
  export type ControlFailureKind = 'offline' | 'control-unavailable' | 'timeout' | 'rejected' | 'backend';
65
+ /** Stable body-free reason shared by ACP recovery and durable ingress. */
66
+ export declare const ACP_CANCEL_DEADLINE_EXCEEDED = "ACP_CANCEL_DEADLINE_EXCEEDED";
65
67
  export declare class SessionControlError extends Error {
66
68
  readonly kind: ControlFailureKind;
67
- constructor(kind: ControlFailureKind, message: string);
69
+ /** Stable body-free machine reason for recovery/audit decisions. */
70
+ readonly reasonCode?: string | undefined;
71
+ constructor(kind: ControlFailureKind, message: string,
72
+ /** Stable body-free machine reason for recovery/audit decisions. */
73
+ reasonCode?: string | undefined);
68
74
  }
75
+ /**
76
+ * How an explicit cancellation ended. Forced recovery is a SUCCESS: the turn is
77
+ * over and the session is being reclaimed. Reporting it as a failed interrupt is
78
+ * what made owners, the control plane and the web console retry an operation
79
+ * that had already done exactly what was asked.
80
+ */
81
+ export interface InterruptOutcome {
82
+ /** `settled` — the turn (or nothing) ended cooperatively. `forced` — the adapter ignored the cancel and was restarted. */
83
+ state: 'settled' | 'forced';
84
+ /** Stable body-free reason present only for a forced recovery. */
85
+ reasonCode?: string;
86
+ }
87
+ /**
88
+ * What a `SessionHandle.interrupt` implementation may resolve. Before 0.17.1 the
89
+ * contract was `Promise<void>`, and resolving at all meant the cancellation had
90
+ * taken effect cooperatively — so an implementation written against that
91
+ * contract stays valid and keeps its exact meaning. Only in-tree consumers read
92
+ * the richer outcome, and they normalize through `interruptOutcome` first.
93
+ */
94
+ export type InterruptResult = InterruptOutcome | void;
95
+ /** The one place the legacy `void` reply is given its meaning: `settled`. */
96
+ export declare function interruptOutcome(result: InterruptResult): InterruptOutcome;
69
97
  /**
70
98
  * A prompt the live session has taken responsibility for. Interactive callers
71
99
  * stop here: the session has the prompt, and waiting for the turn to finish is
@@ -218,7 +246,16 @@ export interface SessionHandle {
218
246
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
219
247
  /** Monitor-only ACP safe-boundary delivery. Never implies human/control cancellation. */
220
248
  submitPromptAfterTool?(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
221
- interrupt(source?: TurnCancellationSource): Promise<void>;
249
+ /**
250
+ * Cancel the active turn. Resolves when the cancellation has taken effect —
251
+ * cooperatively (`settled`) or through bounded forced recovery (`forced`).
252
+ * It rejects only when the cancellation itself could not be delivered.
253
+ *
254
+ * The return type is widened to `InterruptResult` for one reason: an
255
+ * implementation written against the pre-0.17.1 `Promise<void>` contract must
256
+ * keep compiling. Read it through `interruptOutcome`, never directly.
257
+ */
258
+ interrupt(source?: TurnCancellationSource): Promise<InterruptResult>;
222
259
  respondPermission(permissionId: string, optionId: string): boolean;
223
260
  /** Generation-bound browser decision; stale/settled/invalid all fail closed. */
224
261
  respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -1,11 +1,21 @@
1
+ /** Stable body-free reason shared by ACP recovery and durable ingress. */
2
+ export const ACP_CANCEL_DEADLINE_EXCEEDED = 'ACP_CANCEL_DEADLINE_EXCEEDED';
1
3
  export class SessionControlError extends Error {
2
4
  kind;
3
- constructor(kind, message) {
5
+ reasonCode;
6
+ constructor(kind, message,
7
+ /** Stable body-free machine reason for recovery/audit decisions. */
8
+ reasonCode) {
4
9
  super(message);
5
10
  this.kind = kind;
11
+ this.reasonCode = reasonCode;
6
12
  this.name = 'SessionControlError';
7
13
  }
8
14
  }
15
+ /** The one place the legacy `void` reply is given its meaning: `settled`. */
16
+ export function interruptOutcome(result) {
17
+ return result ?? { state: 'settled' };
18
+ }
9
19
  /**
10
20
  * Classify a shell `$?`. Above 128 the shell is reporting 128+signal — the only
11
21
  * signal evidence a pane wrapper can give us.
package/dist/spawn.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type OpsDeps } from './ops.js';
4
4
  import { type CreationDeps, type CreationProvenance } from './creation.js';
5
5
  import './harness/claude-code.js';
6
6
  import './harness/codex.js';
7
+ import { type SupervisorLauncher } from './temp-lifecycle.js';
7
8
  /**
8
9
  * The provenance record written by the most recent spawn in this process, so
9
10
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -94,7 +95,6 @@ export interface SpawnDryRun {
94
95
  export declare function spawnDryRun(o: SpawnOpts): SpawnDryRun;
95
96
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
96
97
  export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: CreationDeps): Promise<string>;
97
- /** Launches the detached temp supervisor (`_run-temp <name>`). Injectable for tests. */
98
- export type SupervisorLauncher = (binPath: string, args: string[], dir: string) => void;
99
- /** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
98
+ export type { SupervisorLauncher } from './temp-lifecycle.js';
99
+ /** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
100
100
  export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
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 {};