@ours.network/fleet 0.17.1 → 0.17.3

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 (64) 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 +109 -16
  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/dist/web-app/assets/{TerminalView-B3rnVWbo.js → TerminalView-BAVk1Bot.js} +1 -1
  62. package/dist/web-app/assets/{index-CliHATFt.js → index-C3S-xFRU.js} +5 -5
  63. package/dist/web-app/index.html +1 -1
  64. package/package.json +5 -3
package/README.md CHANGED
@@ -302,9 +302,27 @@ ours-fleet loops reload <Role>
302
302
  ours-fleet loops run-now|disable|enable <Role> <Loop>
303
303
  ours-fleet rm <Name>
304
304
  ours-fleet doctor [--harness H]
305
+ ours-fleet version [--json] build identity, capabilities, installs on PATH
305
306
  ours-fleet init
306
307
  ```
307
308
 
309
+ ### Which build am I running?
310
+
311
+ `--version` prints a semver, and a semver does not identify an artifact: version
312
+ bumps land in their own release commit, so a build cut between two releases
313
+ carries the previous version while already containing new behaviour. Two installs
314
+ on one host once both reported `0.16.0` while disagreeing about whether
315
+ `monitor.interrupt: after_tool` was valid.
316
+
317
+ Every build therefore stamps `dist/build-info.json` with a content-derived build
318
+ id, its commit, and the capability tokens the shipped code declares.
319
+ `ours-fleet version` prints that identity plus every `ours-fleet` it can see on
320
+ `PATH`; `ours-fleet doctor` fails when two of them share a semver but are
321
+ different builds, or when the executable on `PATH` is a different artifact from
322
+ the one running. Two prefixes holding identical content are not a conflict.
323
+ Installs predating the stamp are compared by hashing their `dist/`, so two of
324
+ those are still told apart.
325
+
308
326
  A permanent role brought up via `-c custom.yaml` remembers that file (`.config-path`
309
327
  in its state dir) across supervisor-triggered restarts — systemd/launchd re-invoke the
310
328
  agent process with no arguments, so without this the role would silently fall back to
@@ -437,6 +455,20 @@ schedule or selector changes reset that loop to its configured initial delay.
437
455
  `run-now` still obeys idle-only admission and returns exit 3 when busy; an
438
456
  unavailable or uncertain control plane returns exit 2 and is never retried.
439
457
 
458
+ A failed state write — a full disk is the case seen in practice — never stops the
459
+ manager. The failure is held in memory as `health: failed`, `anomaly:
460
+ persist_failed`; the occurrence that could not be checkpointed is recorded as
461
+ `skipped_unpersisted` rather than started, since a run that is not on disk is one
462
+ a restart would submit twice; and the manager keeps a bounded retry armed (1s
463
+ doubling to 60s) so it resumes on its own once writes land. Because that health
464
+ flip is itself unwritable, readers do not trust a stored file that has stopped
465
+ advancing: `status`, `loops status`, and `doctor` report `stale` — never the
466
+ recorded health — once the checkpoint of a role that still has a loop to run is
467
+ more than 5 minutes old. A role whose loops are all disabled stops checkpointing
468
+ by design and is never called stale. `status` asks the live control socket
469
+ whenever it is there, including when the first checkpoint never got written, and
470
+ `doctor` fails a running role that has no stored loop state at all.
471
+
440
472
  A scheduled turn has typed internal provenance and no owner authority. It cannot
441
473
  cancel owner work and its ordinary completion is local only. Material proactive
442
474
  owner reporting remains possible solely through an already-open authenticated
@@ -501,8 +533,12 @@ mode can cross.
501
533
 
502
534
  ACP exposes agent-specific session mode IDs and `session/set_mode`, but no
503
535
  portable permission-policy capability. Fleet uses that primitive where an
504
- adapter has a corresponding mode and otherwise translates at the adapter;
505
- live session metadata reports both normalized and harness-native modes.
536
+ adapter has a corresponding mode and otherwise translates at the adapter. The
537
+ bundled Codex ACP adapter couples approval and sandboxing in its advertised
538
+ mode IDs, so fleet preserves the selected sandbox preset and enforces approval
539
+ independently on the app-server turn request. Thus `allow` + `workspace` is
540
+ actually `never` + `workspace-write`, never `danger-full-access`. Live session
541
+ metadata reports the normalized policy and the ACP sandbox-preset ID.
506
542
 
507
543
  ### Isolation at creation time
508
544
 
@@ -74,7 +74,7 @@ export class RoleRemovalService {
74
74
  cpSync(sourceState, join(recoveryPath, 'state'), { recursive: true });
75
75
  if (role?.sourceFile.includes('/fleet.d/') && existsSync(role.sourceFile))
76
76
  cpSync(role.sourceFile, join(recoveryPath, basename(role.sourceFile)));
77
- if (role)
77
+ if (role || preview.lifetime === 'temporary')
78
78
  await rmRole(cfg, preview.role, this.options.ops);
79
79
  else {
80
80
  await this.options.ops.backend.uninstall(preview.role);
@@ -13,6 +13,16 @@ export interface ConversationPageView {
13
13
  export interface ConversationFollowHandle {
14
14
  close(): void;
15
15
  }
16
+ /**
17
+ * An interrupt that reached the session. `state` says whether the turn stopped
18
+ * cooperatively or through bounded forced recovery — both are accepted, so no
19
+ * consumer may render `forced` as a failed interrupt.
20
+ */
21
+ export interface InterruptReceipt {
22
+ accepted: true;
23
+ state: 'settled' | 'forced';
24
+ reasonCode?: string;
25
+ }
16
26
  export interface RoleSessionControl {
17
27
  describe(): Promise<SessionDescriptor>;
18
28
  snapshot(): Promise<SessionSnapshot>;
@@ -21,9 +31,7 @@ export interface RoleSessionControl {
21
31
  limit?: number;
22
32
  }): Promise<OutputPage>;
23
33
  sendText(text: string): Promise<SendReceipt>;
24
- interrupt?(): Promise<{
25
- accepted: true;
26
- }>;
34
+ interrupt?(): Promise<InterruptReceipt>;
27
35
  respondPermission?(request: {
28
36
  permissionId: string;
29
37
  optionId: string;
@@ -40,8 +48,7 @@ export interface RoleSessionControl {
40
48
  actorBrowserSession: string;
41
49
  source: 'owner_admin_console';
42
50
  }): Promise<PromptReceipt>;
43
- interruptV2?(commandId: string): Promise<{
44
- accepted: true;
51
+ interruptV2?(commandId: string): Promise<InterruptReceipt & {
45
52
  commandId: string;
46
53
  }>;
47
54
  respondPermissionV2?(request: {
@@ -76,9 +83,7 @@ export declare class AcpRoleSessionAdapter implements RoleSessionControl {
76
83
  limit?: number;
77
84
  }): Promise<OutputPage>;
78
85
  sendText(text: string): Promise<SendReceipt>;
79
- interrupt(): Promise<{
80
- accepted: true;
81
- }>;
86
+ interrupt(): Promise<InterruptReceipt>;
82
87
  conversationPage(request?: {
83
88
  after?: string;
84
89
  limit?: number;
@@ -89,8 +94,7 @@ export declare class AcpRoleSessionAdapter implements RoleSessionControl {
89
94
  actorBrowserSession: string;
90
95
  source: 'owner_admin_console';
91
96
  }): Promise<PromptReceipt>;
92
- interruptV2(commandId: string): Promise<{
93
- accepted: true;
97
+ interruptV2(commandId: string): Promise<InterruptReceipt & {
94
98
  commandId: string;
95
99
  }>;
96
100
  respondPermissionV2(request: {
@@ -51,8 +51,13 @@ export class AcpRoleSessionAdapter {
51
51
  };
52
52
  }
53
53
  async interrupt() {
54
- await this.call('interrupt');
55
- return { accepted: true };
54
+ const result = await this.call('interrupt');
55
+ // A pre-0.17.1 role control server answers `interrupt` with no result body.
56
+ return {
57
+ accepted: true,
58
+ state: result?.state === 'forced' ? 'forced' : 'settled',
59
+ ...(typeof result?.reasonCode === 'string' ? { reasonCode: result.reasonCode } : {}),
60
+ };
56
61
  }
57
62
  async conversationPage(request = {}) {
58
63
  return await this.call('conversation_page', {
@@ -78,7 +83,13 @@ export class AcpRoleSessionAdapter {
78
83
  }
79
84
  }
80
85
  async interruptV2(commandId) {
81
- return await this.call('interrupt_v2', { commandId });
86
+ const receipt = await this.call('interrupt_v2', { commandId });
87
+ return {
88
+ ...receipt,
89
+ accepted: true,
90
+ state: receipt.state === 'forced' ? 'forced' : 'settled',
91
+ ...(typeof receipt.reasonCode === 'string' ? { reasonCode: receipt.reasonCode } : {}),
92
+ };
82
93
  }
83
94
  async respondPermissionV2(request) {
84
95
  try {
@@ -11,6 +11,12 @@ export interface LockDeps {
11
11
  now?(): number;
12
12
  sleep?(ms: number): Promise<void>;
13
13
  }
14
+ /** The syscalls a replace depends on, injectable so faults can be forced deterministically. */
15
+ export interface WriteDeps {
16
+ writeSync?(fd: number, buffer: Buffer, offset: number, length: number): number;
17
+ fsyncSync?(fd: number): void;
18
+ closeSync?(fd: number): void;
19
+ }
14
20
  /**
15
21
  * Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
16
22
  * support, so the directory's existence IS the lock; the timestamp inside lets a
@@ -27,4 +33,4 @@ export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<
27
33
  * half-written one — and an interrupted write leaves the previous contents
28
34
  * intact.
29
35
  */
30
- export declare function replaceFileAtomically(path: string, contents: string, mode?: number): void;
36
+ export declare function replaceFileAtomically(path: string, contents: string, mode?: number, deps?: WriteDeps): void;
@@ -53,17 +53,45 @@ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_ST
53
53
  * half-written one — and an interrupted write leaves the previous contents
54
54
  * intact.
55
55
  */
56
- export function replaceFileAtomically(path, contents, mode = 0o600) {
56
+ export function replaceFileAtomically(path, contents, mode = 0o600, deps = {}) {
57
+ const write = deps.writeSync ?? writeSync;
58
+ const fsync = deps.fsyncSync ?? fsyncSync;
59
+ const close = deps.closeSync ?? closeSync;
57
60
  const dir = dirname(path);
58
61
  mkdirSync(dir, { recursive: true });
59
62
  const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
60
63
  const fd = openSync(tmp, 'w', mode);
64
+ let closed = false;
61
65
  try {
62
- writeSync(fd, contents);
63
- fsyncSync(fd); // the bytes are on disk before anything points at them
66
+ const buffer = Buffer.from(contents, 'utf8');
67
+ // A single writeSync is not a guarantee: the kernel may stop short, and a
68
+ // truncated temp renamed over the target is silent data loss.
69
+ for (let written = 0; written < buffer.length;) {
70
+ const advanced = write(fd, buffer, written, buffer.length - written);
71
+ // A write that accepts nothing would spin forever; fail instead.
72
+ if (advanced <= 0)
73
+ throw new Error(`write made no progress at byte ${written} of ${buffer.length}`);
74
+ written += advanced;
75
+ }
76
+ fsync(fd); // the bytes are on disk before anything points at them
77
+ close(fd); // a temp we could not close cleanly is not publishable
78
+ closed = true;
64
79
  }
65
- finally {
66
- closeSync(fd);
80
+ catch (e) {
81
+ // Cleanup must not mask why we are here: the original failure is what the
82
+ // caller needs, and a temp we could not finish must never be renamed over
83
+ // the last good file nor left occupying space on a disk that may be full.
84
+ if (!closed) {
85
+ try {
86
+ close(fd);
87
+ }
88
+ catch { /* already failing */ }
89
+ }
90
+ try {
91
+ rmSync(tmp, { force: true });
92
+ }
93
+ catch { /* best effort */ }
94
+ throw e;
67
95
  }
68
96
  try {
69
97
  renameSync(tmp, path);
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": "0.17.3",
3
+ "buildId": "789acfed6bbd",
4
+ "commit": "47f1efd0f90321cf233edc694a9af5fb5ba05647",
5
+ "dirty": false,
6
+ "builtAt": "2026-08-14T20:15:30.780Z",
7
+ "capabilities": [
8
+ "monitor.interrupt.after_tool"
9
+ ]
10
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Capability tokens declared by THIS source tree.
3
+ *
4
+ * Semver cannot answer "does this artifact support X": version bumps land in a
5
+ * separate release commit, so every build cut between two releases carries the
6
+ * previous version while already containing new behaviour. A token is added in
7
+ * the same commit as the feature it names, travels inside the built artifact
8
+ * (dist/build-info.json), and is therefore the only honest answer to that
9
+ * question — for this process and for any other install on the host.
10
+ *
11
+ * Add a token when a change makes previously-invalid configuration valid, or
12
+ * otherwise changes what an operator's fleet.yaml is allowed to say. Never
13
+ * remove one without also rejecting the configuration it admitted.
14
+ */
15
+ /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
+ export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
17
+ export declare const CAPABILITIES: readonly ["monitor.interrupt.after_tool"];
18
+ export type Capability = (typeof CAPABILITIES)[number];
19
+ /** Does this build declare `token`? */
20
+ export declare const hasCapability: (token: string) => boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Capability tokens declared by THIS source tree.
3
+ *
4
+ * Semver cannot answer "does this artifact support X": version bumps land in a
5
+ * separate release commit, so every build cut between two releases carries the
6
+ * previous version while already containing new behaviour. A token is added in
7
+ * the same commit as the feature it names, travels inside the built artifact
8
+ * (dist/build-info.json), and is therefore the only honest answer to that
9
+ * question — for this process and for any other install on the host.
10
+ *
11
+ * Add a token when a change makes previously-invalid configuration valid, or
12
+ * otherwise changes what an operator's fleet.yaml is allowed to say. Never
13
+ * remove one without also rejecting the configuration it admitted.
14
+ */
15
+ /** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
16
+ export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
17
+ export const CAPABILITIES = [
18
+ CAP_MONITOR_INTERRUPT_AFTER_TOOL,
19
+ ];
20
+ /** Does this build declare `token`? */
21
+ export const hasCapability = (token) => CAPABILITIES.includes(token);
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { join as joinPath, resolve as resolvePath } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
+ import { analyzeInstalls, buildInfo, buildLabel, discoverInstalls, runningLabel, } from './provenance.js';
9
10
  import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
10
11
  import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
11
12
  import { formatDuration } from './duration.js';
@@ -22,13 +23,13 @@ import { acquireRunLock, latestReport, listRuns, readReport, releaseRunLock, rep
22
23
  import { lastProvenance, spawnDryRun, spawnPermanent, spawnTemp, } from './spawn.js';
23
24
  import { stringify } from 'yaml';
24
25
  import { resolvedRolePlan } from './resolved-plan.js';
25
- import { formatProvenance } from './creation.js';
26
+ import { creationBuildNote, formatProvenance, readProvenance } from './creation.js';
26
27
  import { doctor } from './doctor.js';
27
28
  import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
28
29
  import { AI_DOCS } from './docs.js';
29
30
  import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
30
31
  import { SessionControlError } from './session/types.js';
31
- import { readScheduledLoops } from './loops/state.js';
32
+ import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
32
33
  import { startWebConsole } from './web/runtime.js';
33
34
  import { requestWebControl } from './web/control.js';
34
35
  import { WebServiceManager } from './web/service.js';
@@ -68,6 +69,52 @@ program.command('docs')
68
69
  .alias('man')
69
70
  .description('print the complete AI-friendly command and configuration reference')
70
71
  .action(() => { process.stdout.write(AI_DOCS); });
72
+ // `--version` prints the semver alone, because scripts parse it. Semver cannot
73
+ // identify an artifact — two installs on one host once reported 0.16.0 and
74
+ // disagreed about `monitor.interrupt: after_tool` — so the full identity of the
75
+ // executable answering, and of every other install it can see, lives here.
76
+ program.command('version')
77
+ .description('build identity, capabilities, and every ours-fleet install on this host')
78
+ .option('--json', 'emit the machine-readable build report')
79
+ .action(opts => {
80
+ const info = buildInfo();
81
+ const installs = discoverInstalls();
82
+ const running = installs.find(i => i.running);
83
+ const skew = analyzeInstalls(installs);
84
+ if (opts.json) {
85
+ process.stdout.write(`${JSON.stringify({
86
+ ...info,
87
+ packageRoot: running?.packageRoot,
88
+ executable: binPath,
89
+ node: process.versions.node,
90
+ platform: process.platform,
91
+ installs: installs.map(i => ({
92
+ packageRoot: i.packageRoot,
93
+ version: i.version,
94
+ buildId: i.build?.buildId ?? null,
95
+ capabilities: i.build?.capabilities ?? null,
96
+ bin: i.bin ?? null,
97
+ running: i.running,
98
+ })),
99
+ skew,
100
+ }, null, 2)}\n`);
101
+ return;
102
+ }
103
+ console.log(runningLabel());
104
+ if (info.commit)
105
+ console.log(` commit: ${info.commit.slice(0, 12)}${info.dirty ? ' (dirty tree)' : ''}`);
106
+ if (info.builtAt)
107
+ console.log(` built: ${info.builtAt}`);
108
+ console.log(` executable: ${binPath}`);
109
+ if (running)
110
+ console.log(` package root: ${running.packageRoot}`);
111
+ console.log(` node: v${process.versions.node} on ${process.platform}`);
112
+ console.log(` capabilities: ${info.capabilities.join(', ') || '(none declared)'}`);
113
+ for (const i of installs.filter(i => i.pathIndex !== undefined))
114
+ console.log(` on PATH: ${i.bin} -> ${buildLabel(i)}`);
115
+ for (const s of skew)
116
+ console.error(`${s.severity}: ${s.message}`);
117
+ });
71
118
  function acpStateDir(name) {
72
119
  const permanent = agentDir(name);
73
120
  if (existsSync(controlSocketPath(permanent)))
@@ -171,6 +218,9 @@ cOpt(program.command('config').description('validate + print the merged plan (no
171
218
  process.stdout.write(`${JSON.stringify(resolvedPlan(cfg), null, 2)}\n`);
172
219
  return;
173
220
  }
221
+ // Which artifact resolved this plan. Two installs can share a semver and
222
+ // disagree about what fleet.yaml means; the plan below is only true of this one.
223
+ console.log(`build: ${runningLabel()}`);
174
224
  console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
175
225
  for (const diagnostic of cfg.diagnostics)
176
226
  console.log(`warning: ${diagnostic.message}`);
@@ -256,7 +306,7 @@ cOpt(program.command('up [names...]').description('create/start every role (or j
256
306
  die(e);
257
307
  }
258
308
  });
259
- cOpt(program.command('down [names...]').description('stop roles'))
309
+ cOpt(program.command('down [names...]').description('stop configured roles or exact named temporary roles'))
260
310
  .action(async (names, opts) => {
261
311
  try {
262
312
  await down(loadConfig(opts.configuration), names, deps());
@@ -439,6 +489,12 @@ program.command('logs <name>').description('show the role log').option('-f, --fo
439
489
  program.command('status <name>').description('unit/agent state')
440
490
  .action(async (name) => {
441
491
  console.log(await pickBackend().status(name));
492
+ // A role outlives the artifact that created it. If a different build is
493
+ // reporting now, its settings may resolve differently than at creation.
494
+ const created = readProvenance(agentDir(name));
495
+ const buildNote = created && creationBuildNote(created);
496
+ if (buildNote)
497
+ console.log(`build: ${buildNote}`);
442
498
  // A held-down role looks like a healthy running unit from the outside — the
443
499
  // runner is alive on purpose. Say so, with the reason and when (3.2).
444
500
  const ledger = readRestartLedger(agentDir(name));
@@ -474,13 +530,37 @@ program.command('status <name>').description('unit/agent state')
474
530
  console.log('session: acp control unavailable');
475
531
  }
476
532
  }
477
- const loopState = readScheduledLoops(agentDir(name));
533
+ // Loop health is asked of the live manager first: when its state writes are
534
+ // failing it is the ONLY source that can say so — the stored file is frozen
535
+ // at whatever it last managed to record, which is why a role with every loop
536
+ // dead still reported `healthy` for 2h51m. The probe cannot be conditional on
537
+ // there being a stored file either: a manager whose very first checkpoint hit
538
+ // ENOSPC never wrote one, and it is precisely the one worth asking.
539
+ let loopState = readScheduledLoops(agentDir(name));
540
+ let loopEvidence = 'stored';
541
+ const loopControl = permanentLoopControlDir(name);
542
+ if (loopControl) {
543
+ try {
544
+ const response = await controlRequest(loopControl, { command: 'loop_status' }, 2_000);
545
+ if (response.ok) {
546
+ loopState = response.result;
547
+ loopEvidence = 'live';
548
+ }
549
+ }
550
+ catch { /* stored evidence stays honest */ }
551
+ }
478
552
  if (loopState) {
479
553
  const values = Object.values(loopState.loops);
480
554
  const enabled = values.filter(loop => loop.enabled && !loop.operatorDisabled).length;
481
555
  const next = values.filter(loop => loop.enabled && !loop.operatorDisabled)
482
556
  .map(loop => Date.parse(loop.nextDueAt)).filter(Number.isFinite).sort((a, b) => a - b)[0];
483
- console.log(`loops: ${enabled} enabled${next ? `, next ${formatDuration(Math.max(0, next - Date.now()))}` : ''}, ${loopState.health}`);
557
+ const verdict = storedLoopHealth(loopState, Date.now());
558
+ const health = loopEvidence === 'live' ? loopState.health : verdict.health;
559
+ console.log(`loops: ${enabled} enabled${next ? `, next ${formatDuration(Math.max(0, next - Date.now()))}` : ''}, ${health}`
560
+ + (loopEvidence === 'stored' && verdict.stale
561
+ ? ` (no state update for ${formatDuration(verdict.ageMs)}; last recorded ${verdict.recorded})` : '')
562
+ + (loopState.anomaly ? ` anomaly=${loopState.anomaly}` : '')
563
+ + ` evidence=${loopEvidence}`);
484
564
  }
485
565
  });
486
566
  const loopsCommand = program.command('loops')
@@ -579,13 +659,20 @@ cOpt(loopsCommand.command('status [role] [loop]').description('show live or stor
579
659
  }
580
660
  }
581
661
  catch { /* stored evidence remains honest; timeout is not offline */ }
582
- results.push({ role: role.name, evidence, state });
662
+ // Stored evidence is only as current as the last write that landed.
663
+ const verdict = state && evidence === 'stored' ? storedLoopHealth(state, Date.now()) : undefined;
664
+ results.push({
665
+ role: role.name, evidence, state,
666
+ health: verdict ? verdict.health : state?.health ?? null,
667
+ staleMs: verdict?.stale ? verdict.ageMs : null,
668
+ });
583
669
  }
584
670
  if (opts.json)
585
671
  console.log(JSON.stringify({ schemaVersion: 1, roles: results }));
586
672
  else {
587
673
  const rows = results.flatMap(result => renderLoopRows(result.role, result.state, loopName)
588
- .map(row => `${row} evidence=${result.evidence}`));
674
+ .map(row => `${row} evidence=${result.evidence} health=${result.health}`
675
+ + (result.staleMs === null ? '' : ` stale=${formatDuration(result.staleMs)}`)));
589
676
  console.log(rows.join('\n') || '(no scheduled loop state)');
590
677
  }
591
678
  }
@@ -981,7 +1068,7 @@ cOpt(program.command('watchdog-report <name> [runId]')
981
1068
  die(e);
982
1069
  }
983
1070
  });
984
- cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fleet.d file if spawned)'))
1071
+ cOpt(program.command('rm <name>').description('stop + remove a role (temporary evidence is archived)'))
985
1072
  .action(async (name, opts) => {
986
1073
  try {
987
1074
  await rmRole(loadConfig(opts.configuration), name, deps());
@@ -992,7 +1079,7 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
992
1079
  });
993
1080
  cOpt(program.command('spawn [name]').description('spawn a new agent (permanent by default)'))
994
1081
  .option('--role <name>', 'role name (alternative to the positional name)')
995
- .option('--temp', 'temporary: detached supervisor, auto-cleaned, gone on reboot')
1082
+ .option('--temp', 'temporary: independent transient supervisor, archived on retirement, gone on reboot')
996
1083
  .option('--harness <id>', 'harness adapter (default: defaults.harness)')
997
1084
  .option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
998
1085
  .option('--mission <text>', 'one-line mission')
@@ -1092,7 +1179,8 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1092
1179
  // The same provenance that was persisted, so what the operator reads now
1093
1180
  // and what a reviewer reads later cannot disagree (6.6).
1094
1181
  if (lastProvenance) {
1095
- console.log(` created by ${lastProvenance.command} v${lastProvenance.fleetVersion} `
1182
+ console.log(` created by ${lastProvenance.command} `
1183
+ + `v${lastProvenance.fleetVersion}+${lastProvenance.fleetBuild} `
1096
1184
  + `at ${lastProvenance.createdAt} (${lastProvenance.lifetime})`);
1097
1185
  for (const line of formatProvenance(lastProvenance))
1098
1186
  console.log(line);
package/dist/config.d.ts CHANGED
@@ -98,8 +98,15 @@ export type OwnerChannelConfigInput = Omit<Partial<OwnerChannelConfig>, 'attachm
98
98
  export declare const DEFAULT_OWNER_ATTACHMENT_MIME: readonly ["application/pdf", "application/json", "text/plain", "image/png", "image/jpeg", "image/gif", "image/webp", "audio/ogg", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/webm", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation"];
99
99
  /** Default wake sources when a role does not list its own (design §2). */
100
100
  export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
101
- /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
102
- export declare function validateMonitorConfig(raw: unknown): string[];
101
+ /**
102
+ * Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems.
103
+ *
104
+ * `capabilities` defaults to what this build declares. A value is rejected in two
105
+ * distinct ways: unknown to every build (a typo), or known but absent from the
106
+ * artifact doing the validating — the second names the capability and the build,
107
+ * because the same fleet.yaml may well be accepted by another install on the host.
108
+ */
109
+ export declare function validateMonitorConfig(raw: unknown, capabilities?: readonly string[]): string[];
103
110
  export interface RoleConfig {
104
111
  harness?: string;
105
112
  session?: SessionBackendId;
package/dist/config.js CHANGED
@@ -6,6 +6,8 @@ import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from '.
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  import { resolveWatchdogs } from './watchdog/config.js';
8
8
  import { resolveLoops } from './loops/config.js';
9
+ import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
10
+ import { runningLabel } from './provenance.js';
9
11
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
10
12
  export const NOTIFY_EVENT_TYPES = [
11
13
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
@@ -30,8 +32,15 @@ const MONITOR_MODES = ['fleet', 'native'];
30
32
  const MONITOR_DEFAULT_BATCH_MS = 2000;
31
33
  const MONITOR_DEFAULT_TURN_FAIL_THRESHOLD = 3;
32
34
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
33
- /** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
34
- export function validateMonitorConfig(raw) {
35
+ /**
36
+ * Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems.
37
+ *
38
+ * `capabilities` defaults to what this build declares. A value is rejected in two
39
+ * distinct ways: unknown to every build (a typo), or known but absent from the
40
+ * artifact doing the validating — the second names the capability and the build,
41
+ * because the same fleet.yaml may well be accepted by another install on the host.
42
+ */
43
+ export function validateMonitorConfig(raw, capabilities = CAPABILITIES) {
35
44
  const problems = [];
36
45
  if (!isPlainObject(raw))
37
46
  return ['monitor: must be a mapping'];
@@ -54,6 +63,11 @@ export function validateMonitorConfig(raw) {
54
63
  if (m.interrupt !== undefined
55
64
  && typeof m.interrupt !== 'boolean' && m.interrupt !== 'after_tool')
56
65
  problems.push("monitor.interrupt: must be true, false, or 'after_tool'");
66
+ else if (m.interrupt === 'after_tool' && !capabilities.includes(CAP_MONITOR_INTERRUPT_AFTER_TOOL))
67
+ problems.push(`monitor.interrupt: 'after_tool' needs capability ${CAP_MONITOR_INTERRUPT_AFTER_TOOL}, `
68
+ + `which the build validating this config (${runningLabel()}) does not declare. `
69
+ + 'Another install on this host may accept the same file — run `ours-fleet doctor` '
70
+ + 'to see which artifact serves which path.');
57
71
  if (m.turn_fail_threshold !== undefined
58
72
  && (typeof m.turn_fail_threshold !== 'number' || !Number.isInteger(m.turn_fail_threshold)
59
73
  || m.turn_fail_threshold < 1))
@@ -151,6 +151,12 @@ export interface CreationProvenance {
151
151
  /** The command that created the role, without its arguments. */
152
152
  command: string;
153
153
  fleetVersion: string;
154
+ /**
155
+ * Build id of the artifact that created the role. Two installs can report the
156
+ * same fleetVersion and resolve the same fleet.yaml differently, so the semver
157
+ * alone does not identify what actually ran. `unknown` for a pre-provenance build.
158
+ */
159
+ fleetBuild: string;
154
160
  createdAt: string;
155
161
  lifetime: 'permanent' | 'temporary';
156
162
  role: string;
@@ -187,6 +193,16 @@ export declare function buildProvenance(o: {
187
193
  }): CreationProvenance;
188
194
  /** Write the provenance record atomically, before the role is started. */
189
195
  export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
196
+ /** Read the provenance a role was created with, if it has one. */
197
+ export declare function readProvenance(stateDir: string): CreationProvenance | undefined;
198
+ /**
199
+ * One line when the artifact reporting on a role is not the one that created it.
200
+ *
201
+ * A role carries its creating build in creation.json. If a different build now
202
+ * manages it, the role's fleet.yaml may mean something different than it did at
203
+ * creation — and because both can report the same semver, nothing else says so.
204
+ */
205
+ export declare function creationBuildNote(p: CreationProvenance): string | undefined;
190
206
  /** One concise line per non-built-in setting, for the post-creation summary. */
191
207
  export declare function formatProvenance(p: CreationProvenance): string[];
192
208
  /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
package/dist/creation.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
4
  import { stateRoot } from './paths.js';
5
5
  import { resolveEndpoint } from './monitor.js';
6
+ import { buildInfo, UNKNOWN_BUILD } from './provenance.js';
6
7
  /**
7
8
  * One creation transaction: role name and ours identity reserved together,
8
9
  * every artifact journalled, and everything undone in reverse on failure.
@@ -242,6 +243,7 @@ export function buildProvenance(o) {
242
243
  version: 1,
243
244
  command: 'ours-fleet spawn',
244
245
  fleetVersion: o.fleetVersion,
246
+ fleetBuild: buildInfo().buildId,
245
247
  createdAt: (o.now ?? new Date()).toISOString(),
246
248
  lifetime: o.lifetime,
247
249
  role: o.role,
@@ -255,6 +257,32 @@ export function buildProvenance(o) {
255
257
  export function writeProvenance(stateDir, p) {
256
258
  replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
257
259
  }
260
+ /** Read the provenance a role was created with, if it has one. */
261
+ export function readProvenance(stateDir) {
262
+ try {
263
+ return JSON.parse(readFileSync(join(stateDir, CREATION_PROVENANCE_FILE), 'utf8'));
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ /**
270
+ * One line when the artifact reporting on a role is not the one that created it.
271
+ *
272
+ * A role carries its creating build in creation.json. If a different build now
273
+ * manages it, the role's fleet.yaml may mean something different than it did at
274
+ * creation — and because both can report the same semver, nothing else says so.
275
+ */
276
+ export function creationBuildNote(p) {
277
+ const now = buildInfo();
278
+ const created = p.fleetBuild ?? UNKNOWN_BUILD;
279
+ if (created === now.buildId && p.fleetVersion === now.version)
280
+ return undefined;
281
+ return `created by ours-fleet ${p.fleetVersion}+${created}, reported by ${now.version}+${now.buildId}`
282
+ + (created === UNKNOWN_BUILD
283
+ ? ' — the creating build predates build provenance and cannot be identified'
284
+ : ' — different artifact, so its resolved settings may differ');
285
+ }
258
286
  /** One concise line per non-built-in setting, for the post-creation summary. */
259
287
  export function formatProvenance(p) {
260
288
  const mark = {