@ours.network/fleet 1.2.0-nightly.66 → 1.2.0-nightly.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -299,6 +299,7 @@ ours-fleet loops reload <Role>
299
299
  ours-fleet loops run-now|disable|enable <Role> <Loop>
300
300
  ours-fleet rm <Name>
301
301
  ours-fleet doctor [--harness H]
302
+ ours-fleet service check|refresh [--json] service definitions vs this release (refresh never starts or restarts)
302
303
  ours-fleet managed-cli setup [--enable <Agent>]... | status [--json] native harness policy for the task workflow
303
304
  ours-fleet version [--json] build identity, capabilities, installs on PATH
304
305
  ours-fleet init
@@ -326,6 +327,28 @@ is prepared the same way but not yet qualified. Scope, boundaries, unsupported
326
327
  combinations and the evidence are in
327
328
  [docs/validation/managed-cli-permissions.md](docs/validation/managed-cli-permissions.md).
328
329
 
330
+ ### Service definitions after an update
331
+
332
+ `ours-fleet init`, `web install` and the watchdog installer write user service
333
+ definitions (systemd units on Linux, LaunchAgents on macOS) that start the exact
334
+ release tree they were written from. An update installs a new tree, but a running
335
+ service keeps its code, and an unrefreshed definition starts the previous release
336
+ again after the next reboot while agents and the CLI use the new one.
337
+
338
+ ```sh
339
+ ours-fleet service check # read-only; exit 1 when a definition or running service uses another release
340
+ ours-fleet service refresh # re-point existing definitions at this release
341
+ ```
342
+
343
+ `refresh` changes only the release paths in definitions Fleet generated. It keeps
344
+ environment, profile, ports and drop-ins, runs `daemon-reload`, and never creates,
345
+ enables, starts, stops or restarts a service. Definitions that Fleet did not
346
+ generate, or whose `ExecStart` a drop-in overrides, are reported and left alone.
347
+ A running supervisor still uses its old code until you restart it
348
+ (`systemctl --user restart ours-fleet.service`); its agents resume through it.
349
+ `ours-install` runs `refresh` after updating Fleet, and `doctor` shows the same
350
+ rows as `check`.
351
+
329
352
  ### Which build am I running?
330
353
 
331
354
  `--version` prints a semver, and a semver does not identify an artifact: version
@@ -1,3 +1,4 @@
1
+ import { type IncarnationProbe } from './process-incarnation.js';
1
2
  /**
2
3
  * Cross-process file protection: a lock, and a replace that cannot leave a
3
4
  * partial file behind.
@@ -12,6 +13,22 @@ export interface LockDeps {
12
13
  sleep?(ms: number): Promise<void>;
13
14
  /** Test hook after a complete private claim is prepared, before atomic publication. */
14
15
  beforePublish?(): void | Promise<void>;
16
+ /** Test seam for PID liveness; defaults to signal 0. */
17
+ alive?(pid: number): boolean;
18
+ /** Test seam for boot id and process start markers. */
19
+ incarnation?: IncarnationProbe;
20
+ /** Test hook inside the reclaim section, before the dead holder is re-read. */
21
+ beforeReclaim?(): void;
22
+ }
23
+ /**
24
+ * A live holder recorded without process-incarnation markers by a release that
25
+ * predates them. Its PID may be reused, so the lock can be neither trusted nor
26
+ * removed; the caller fails with the path instead of waiting forever.
27
+ */
28
+ export declare class FileLockOwnerUnverifiableError extends Error {
29
+ readonly lockPath: string;
30
+ readonly pid: number;
31
+ constructor(lockPath: string, pid: number);
15
32
  }
16
33
  /** The syscalls a replace depends on, injectable so faults can be forced deterministically. */
17
34
  export interface WriteDeps {
@@ -1,6 +1,24 @@
1
1
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { incarnationEnded, procIncarnationProbe, processIncarnation, } from './process-incarnation.js';
5
+ /**
6
+ * A live holder recorded without process-incarnation markers by a release that
7
+ * predates them. Its PID may be reused, so the lock can be neither trusted nor
8
+ * removed; the caller fails with the path instead of waiting forever.
9
+ */
10
+ export class FileLockOwnerUnverifiableError extends Error {
11
+ lockPath;
12
+ pid;
13
+ constructor(lockPath, pid) {
14
+ super(`lock ${lockPath} is held by PID ${pid}, recorded without a boot or start marker by an older `
15
+ + 'release, so it can be neither trusted nor proved stale; it was left in place. Inspect PID '
16
+ + `${pid}: the lock may be removed only once that process is proved not to be the Fleet process `
17
+ + 'that wrote it.');
18
+ this.lockPath = lockPath;
19
+ this.pid = pid;
20
+ }
21
+ }
4
22
  function fsyncDirectory(dir) {
5
23
  const dirFd = openSync(dir, 'r');
6
24
  try {
@@ -11,6 +29,7 @@ function fsyncDirectory(dir) {
11
29
  }
12
30
  }
13
31
  const DEFAULT_STALE_MS = 10_000;
32
+ const SYNCHRONOUS_LOCK_TIMEOUT = 'Timed out acquiring synchronous journal lock';
14
33
  const POLL_MS = 25;
15
34
  /**
16
35
  * Run `fn` holding a cross-process lock. A fully populated private claim directory
@@ -23,16 +42,40 @@ const POLL_MS = 25;
23
42
  export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_STALE_MS) {
24
43
  const now = deps.now ?? (() => Date.now());
25
44
  const sleep = deps.sleep ?? ((ms) => new Promise(r => { setTimeout(r, ms); }));
45
+ const probe = deps.incarnation ?? procIncarnationProbe;
46
+ const alive = deps.alive ?? ((pid) => { try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ } });
26
53
  const stampPath = join(lockPath, 'ts');
27
54
  const ownerPath = join(lockPath, 'owner.json');
55
+ const reclaimPath = `${lockPath}.reclaim`;
28
56
  const token = randomUUID();
29
57
  mkdirSync(dirname(lockPath), { recursive: true });
58
+ /** The exact bytes that identify one holder, so a reclaim never removes its successor. */
59
+ const holder = () => {
60
+ let owner = '', stamp = '';
61
+ try {
62
+ owner = readFileSync(ownerPath, 'utf8');
63
+ }
64
+ catch { /* legacy or incomplete lock */ }
65
+ try {
66
+ stamp = readFileSync(stampPath, 'utf8');
67
+ }
68
+ catch { /* holder died between mkdir and stamp */ }
69
+ return owner || stamp ? `${stamp}\0${owner}` : null;
70
+ };
30
71
  for (let waited = 0;;) {
31
72
  const claimPath = `${lockPath}.claim.${process.pid}.${randomUUID()}`;
32
73
  try {
33
74
  mkdirSync(claimPath);
34
75
  writeFileSync(join(claimPath, 'ts'), String(now()));
35
- writeFileSync(join(claimPath, 'owner.json'), JSON.stringify({ token, pid: process.pid }));
76
+ writeFileSync(join(claimPath, 'owner.json'), JSON.stringify({
77
+ token, pid: process.pid, ...processIncarnation(process.pid, probe),
78
+ }));
36
79
  await deps.beforePublish?.();
37
80
  renameSync(claimPath, lockPath);
38
81
  break;
@@ -41,6 +84,7 @@ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_ST
41
84
  rmSync(claimPath, { recursive: true, force: true });
42
85
  if (!['EEXIST', 'ENOTEMPTY'].includes(e.code ?? ''))
43
86
  throw e;
87
+ const observed = holder();
44
88
  let held = null;
45
89
  try {
46
90
  const n = parseInt(readFileSync(stampPath, 'utf8').trim(), 10);
@@ -48,21 +92,23 @@ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_ST
48
92
  }
49
93
  catch { /* holder died between mkdir and stamp */ }
50
94
  let ownerAlive = false;
95
+ let unverifiable;
51
96
  try {
52
97
  const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
98
+ // A reused PID is not the holder: a lock written before a reboot, or by
99
+ // a process that has since exited, must not block every later caller.
53
100
  if (typeof owner.pid === 'number') {
54
- try {
55
- process.kill(owner.pid, 0);
56
- ownerAlive = true;
57
- }
58
- catch { /* dead owner */ }
101
+ ownerAlive = alive(owner.pid) && !incarnationEnded(owner.pid, owner, probe);
102
+ if (ownerAlive && owner.boot === undefined && owner.start === undefined && probe.bootId())
103
+ unverifiable = owner.pid;
59
104
  }
60
105
  }
61
106
  catch { /* legacy or incomplete lock */ }
62
- if (!ownerAlive && ((held !== null && now() - held > staleMs) || waited > staleMs * 2)) {
63
- rmSync(lockPath, { recursive: true, force: true }); // break a dead holder's lock
107
+ const expired = (held !== null && now() - held > staleMs) || waited > staleMs * 2;
108
+ if (!ownerAlive && expired && reclaimDeadHolder(reclaimPath, lockPath, observed, holder, deps))
64
109
  continue;
65
- }
110
+ if (unverifiable !== undefined && expired && waited > staleMs * 2)
111
+ throw new FileLockOwnerUnverifiableError(lockPath, unverifiable);
66
112
  await sleep(POLL_MS);
67
113
  waited += POLL_MS;
68
114
  }
@@ -79,6 +125,30 @@ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_ST
79
125
  catch { /* never remove a lock whose ownership cannot be proved */ }
80
126
  }
81
127
  }
128
+ /**
129
+ * Remove a dead holder's lock only while it is still that holder's. Contenders
130
+ * that observed the same dead holder serialize through a bakery lock, whose
131
+ * claims are per-process files removed only when their process is provably
132
+ * gone, never by age. Inside it the holder is re-read, so a reclaimer that was
133
+ * descheduled after observing the dead holder cannot delete the lock a faster
134
+ * contender has since claimed.
135
+ */
136
+ function reclaimDeadHolder(reclaimPath, lockPath, observed, holder, deps) {
137
+ try {
138
+ withSynchronousFileLock(reclaimPath, () => {
139
+ deps.beforeReclaim?.();
140
+ if (holder() === observed)
141
+ rmSync(lockPath, { recursive: true, force: true });
142
+ });
143
+ return true;
144
+ }
145
+ catch (error) {
146
+ // A live contender stalled inside the section: wait like any other poll.
147
+ if (!(error instanceof Error) || error.message !== SYNCHRONOUS_LOCK_TIMEOUT)
148
+ throw error;
149
+ return false;
150
+ }
151
+ }
82
152
  /**
83
153
  * Replace a file's contents atomically: write a temp file in the SAME directory
84
154
  * (so the rename cannot cross a filesystem boundary), fsync it, then rename over
@@ -152,6 +222,7 @@ export function withSynchronousFileLock(path, work) {
152
222
  const token = `${process.pid}-${randomUUID()}.json`;
153
223
  const own = join(path, token);
154
224
  const deadline = Date.now() + 10_000;
225
+ const incarnation = processIncarnation(process.pid);
155
226
  const claims = () => {
156
227
  const result = [];
157
228
  for (const name of readdirSync(path).filter(name => /^[0-9]+-[a-f0-9-]{36}\.json$/.test(name))) {
@@ -168,35 +239,39 @@ export function withSynchronousFileLock(path, work) {
168
239
  if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || !Number.isSafeInteger(value.ticket) || value.ticket < 0)
169
240
  throw new Error('Invalid synchronous lock claim');
170
241
  if (name !== token) {
242
+ let dead = false;
171
243
  try {
172
244
  process.kill(value.pid, 0);
173
245
  }
174
246
  catch (error) {
175
- if (error.code === 'ESRCH') {
176
- // This dead contender alone owned this UUID path. Never remove
177
- // the namespace or a successor's differently named claim.
178
- rmSync(file, { force: true });
179
- continue;
180
- }
247
+ dead = error.code === 'ESRCH';
248
+ }
249
+ // A claim from before a reboot, or whose PID now names a later process,
250
+ // is as dead as an exited one.
251
+ if (dead || incarnationEnded(value.pid, value)) {
252
+ // This dead contender alone owned this UUID path. Never remove
253
+ // the namespace or a successor's differently named claim.
254
+ rmSync(file, { force: true });
255
+ continue;
181
256
  }
182
257
  }
183
258
  result.push({ name, value });
184
259
  }
185
260
  return result;
186
261
  };
187
- replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: true, ticket: 0 }));
262
+ replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: true, ticket: 0, ...incarnation }));
188
263
  try {
189
264
  const ticket = Math.max(0, ...claims().map(claim => claim.value.ticket)) + 1;
190
265
  if (!Number.isSafeInteger(ticket))
191
266
  throw new Error('Synchronous lock ticket overflow');
192
- replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: false, ticket }));
267
+ replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: false, ticket, ...incarnation }));
193
268
  for (;;) {
194
269
  const preceding = claims().some(claim => claim.name !== token && (claim.value.choosing
195
270
  || claim.value.ticket < ticket || (claim.value.ticket === ticket && claim.name < token)));
196
271
  if (!preceding)
197
272
  return work();
198
273
  if (Date.now() >= deadline)
199
- throw new Error('Timed out acquiring synchronous journal lock');
274
+ throw new Error(SYNCHRONOUS_LOCK_TIMEOUT);
200
275
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
201
276
  }
202
277
  }
@@ -1,13 +1,15 @@
1
1
  {
2
- "version": "1.2.0-nightly.66",
3
- "buildId": "b8fdde9dbcd0",
4
- "commit": "268a18be4c8f3921b8e432b0025ffe3052903fda",
2
+ "version": "1.2.0-nightly.68",
3
+ "buildId": "ec9481a2cd81",
4
+ "commit": "7c3095b817127cff76cac375f21e8124558e8f38",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-16T07:07:30.528Z",
6
+ "builtAt": "2026-10-16T12:16:06.490Z",
7
7
  "capabilities": [
8
+ "service.refresh-v1",
8
9
  "managed-cli.codex-reasoning-effort-v1",
9
10
  "managed-cli.setup-v1",
10
11
  "workspace.enroll.preserve-profile-v1",
12
+ "workspace.enroll.optional-profile-v1",
11
13
  "cowork.http-management-v1",
12
14
  "monitor.interrupt.after_tool"
13
15
  ]
@@ -15,12 +15,15 @@
15
15
  /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
16
  export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
17
17
  export declare const CAP_HTTP_COWORK = "cowork.http-management-v1";
18
+ export declare const CAP_WORKSPACE_OPTIONAL_PROFILE = "workspace.enroll.optional-profile-v1";
18
19
  export declare const CAP_WORKSPACE_PRESERVE_PROFILE = "workspace.enroll.preserve-profile-v1";
19
20
  /** `ours-fleet managed-cli setup|status` and the `managed_cli` Agent key (#233). */
20
21
  export declare const CAP_MANAGED_CLI_SETUP = "managed-cli.setup-v1";
21
22
  /** Managed CLI accepts validated Codex Brain reasoning effort without dropping it (#235). */
22
23
  export declare const CAP_MANAGED_CLI_CODEX_EFFORT = "managed-cli.codex-reasoning-effort-v1";
23
- export declare const CAPABILITIES: readonly ["managed-cli.codex-reasoning-effort-v1", "managed-cli.setup-v1", "workspace.enroll.preserve-profile-v1", "cowork.http-management-v1", "monitor.interrupt.after_tool"];
24
+ /** `ours-fleet service check|refresh`: re-point service definitions after an update. */
25
+ export declare const CAP_SERVICE_REFRESH = "service.refresh-v1";
26
+ export declare const CAPABILITIES: readonly ["service.refresh-v1", "managed-cli.codex-reasoning-effort-v1", "managed-cli.setup-v1", "workspace.enroll.preserve-profile-v1", "workspace.enroll.optional-profile-v1", "cowork.http-management-v1", "monitor.interrupt.after_tool"];
24
27
  export type Capability = (typeof CAPABILITIES)[number];
25
28
  /** Does this build declare `token`? */
26
29
  export declare const hasCapability: (token: string) => boolean;
@@ -15,15 +15,20 @@
15
15
  /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
16
  export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
17
17
  export const CAP_HTTP_COWORK = 'cowork.http-management-v1';
18
+ export const CAP_WORKSPACE_OPTIONAL_PROFILE = 'workspace.enroll.optional-profile-v1';
18
19
  export const CAP_WORKSPACE_PRESERVE_PROFILE = 'workspace.enroll.preserve-profile-v1';
19
20
  /** `ours-fleet managed-cli setup|status` and the `managed_cli` Agent key (#233). */
20
21
  export const CAP_MANAGED_CLI_SETUP = 'managed-cli.setup-v1';
21
22
  /** Managed CLI accepts validated Codex Brain reasoning effort without dropping it (#235). */
22
23
  export const CAP_MANAGED_CLI_CODEX_EFFORT = 'managed-cli.codex-reasoning-effort-v1';
24
+ /** `ours-fleet service check|refresh`: re-point service definitions after an update. */
25
+ export const CAP_SERVICE_REFRESH = 'service.refresh-v1';
23
26
  export const CAPABILITIES = [
27
+ CAP_SERVICE_REFRESH,
24
28
  CAP_MANAGED_CLI_CODEX_EFFORT,
25
29
  CAP_MANAGED_CLI_SETUP,
26
30
  CAP_WORKSPACE_PRESERVE_PROFILE,
31
+ CAP_WORKSPACE_OPTIONAL_PROFILE,
27
32
  CAP_HTTP_COWORK,
28
33
  CAP_MONITOR_INTERRUPT_AFTER_TOOL,
29
34
  ];
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { spawn as spawnChild } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs';
17
17
  import { realpathSync } from 'node:fs';
18
+ import { describeRegistration, refreshServiceRegistrations, registrationDrift, } from './supervisor/registration-refresh.js';
18
19
  import { join as joinPath, resolve as resolvePath } from 'node:path';
19
20
  import { createInterface } from 'node:readline';
20
21
  import { Command } from 'commander';
@@ -1400,12 +1401,41 @@ cOpt(program.command('doctor').description('prerequisite report'))
1400
1401
  harness: opts.harness,
1401
1402
  configPath: opts.configuration,
1402
1403
  yamlMode: opts.yamlMode,
1404
+ serviceRegistration: { binPath },
1403
1405
  });
1404
1406
  for (const c of rep.checks)
1405
1407
  console.log(`${c.ok ? 'ok ' : 'MISS'} ${c.name.padEnd(22)} ${c.detail}`);
1406
1408
  if (!rep.ok)
1407
1409
  throw new FleetCliExit(1);
1408
1410
  });
1411
+ const serviceCommand = program.command('service')
1412
+ .description('installed Fleet service definitions (supervisor, web, watchdogs)');
1413
+ const printRegistrations = (reports, json) => {
1414
+ if (json)
1415
+ console.log(JSON.stringify({ binPath, services: reports }, null, 2));
1416
+ else
1417
+ for (const report of reports)
1418
+ console.log(describeRegistration(report));
1419
+ };
1420
+ serviceCommand.command('check')
1421
+ .description('report whether installed definitions and running services use this release; changes nothing')
1422
+ .option('--json', 'machine-readable report')
1423
+ .action(async (opts) => {
1424
+ const reports = await refreshServiceRegistrations({ binPath, write: false });
1425
+ printRegistrations(reports, opts.json);
1426
+ if (registrationDrift(reports).length)
1427
+ throw new FleetCliExit(1);
1428
+ });
1429
+ serviceCommand.command('refresh')
1430
+ .description('point installed definitions at this release; never creates, enables, starts, stops or restarts a service')
1431
+ .option('--json', 'machine-readable report')
1432
+ .action(async (opts) => {
1433
+ const reports = await refreshServiceRegistrations({ binPath, write: true });
1434
+ printRegistrations(reports, opts.json);
1435
+ if (!opts.json && reports.some(report => report.running === 'stale'))
1436
+ console.log('A running service still uses another release. Restart it when convenient, '
1437
+ + 'e.g. systemctl --user restart ours-fleet.service; agents resume through their supervisor.');
1438
+ });
1409
1439
  cOpt(program.command('init').description('add missing Fleet defaults while preserving existing configuration')
1410
1440
  .option('--settings <file>', 'validated InitAnswers JSON; runs without interactive questions'))
1411
1441
  .action(async (opts) => {