@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
@@ -1,10 +1,11 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { agentDir, home } from '../paths.js';
4
5
  import { realExec } from '../exec.js';
5
6
  import { registerAdapter } from './registry.js';
6
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
7
- import { bundledAcpAgent } from './acp-agent.js';
8
+ import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
8
9
  const OPTION_KEYS = [
9
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
10
11
  'monitor',
@@ -14,6 +15,12 @@ const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
14
15
  const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
15
16
  /** Codex CLI's accepted `--ask-for-approval` values. */
16
17
  const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
18
+ const BUNDLED_CODEX_ACP_VERSION = '1.1.7';
19
+ const CODEX_ACP_PACKAGE = '@agentclientprotocol/codex-acp';
20
+ const CODEX_PROXY_APPROVAL_ENV = 'OURS_FLEET_CODEX_APPROVAL';
21
+ const CODEX_PROXY_SANDBOX_ENV = 'OURS_FLEET_CODEX_SANDBOX';
22
+ const CODEX_PROXY_REAL_PATH_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
23
+ const CODEX_PROXY_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
17
24
  /**
18
25
  * What an unattended role can actually do under Codex's native settings.
19
26
  * `on-request` and `untrusted` stop to ask, and with no console attached that
@@ -55,6 +62,22 @@ function acpAgentMode(role) {
55
62
  return 'agent-full-access';
56
63
  return undefined;
57
64
  }
65
+ function acpModePermissions(mode) {
66
+ if (mode === 'read-only')
67
+ return { approval: 'on-request', sandbox: 'read-only' };
68
+ if (mode === 'agent-full-access')
69
+ return { approval: 'never', sandbox: 'danger-full-access' };
70
+ return { approval: 'on-request', sandbox: 'workspace-write' };
71
+ }
72
+ function fleetModeForApproval(nativeMode) {
73
+ if (nativeMode === 'never')
74
+ return 'allow';
75
+ if (nativeMode === 'on-request')
76
+ return 'auto';
77
+ if (nativeMode === 'untrusted')
78
+ return 'ask';
79
+ throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
80
+ }
58
81
  /** Resolve & validate the per-role approval policy, throwing on an unknown value. */
59
82
  function approvalPolicy(role) {
60
83
  const o = role.harness_options;
@@ -76,6 +99,59 @@ function approvalPolicy(role) {
76
99
  function launcherMode(role) {
77
100
  return role.harness_options?.launcher ?? 'auto';
78
101
  }
102
+ function bundledCodexAcp() {
103
+ return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
+ }
105
+ function canOverrideBundledAcpApproval() {
106
+ const resolution = bundledCodexAcp();
107
+ return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
108
+ && resolution.manifestPath !== undefined && compiledProxyModule() !== undefined;
109
+ }
110
+ function compiledProxyModule() {
111
+ const adjacent = fileURLToPath(new URL('./codex-app-server-proxy.js', import.meta.url));
112
+ if (existsSync(adjacent))
113
+ return adjacent;
114
+ // Vitest imports src/ directly; globalSetup builds the executable module in dist/.
115
+ const fromSource = resolve(dirname(fileURLToPath(import.meta.url)), '../../dist/harness', 'codex-app-server-proxy.js');
116
+ if (existsSync(fromSource))
117
+ return fromSource;
118
+ return undefined;
119
+ }
120
+ const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
121
+ /**
122
+ * Materialize the tiny CODEX_PATH executable inside the role's own state dir.
123
+ * Keeping it there makes the proxy available under both ordinary and isolated
124
+ * launches without adding another host path to the filesystem boundary.
125
+ */
126
+ function codexAcpEnvironment(role, dirs) {
127
+ if (role.session !== 'acp' || role.session_options?.acp?.command != null)
128
+ return {};
129
+ const resolution = bundledCodexAcp();
130
+ if (!resolution.bundled || resolution.version !== BUNDLED_CODEX_ACP_VERSION
131
+ || !resolution.manifestPath)
132
+ return {};
133
+ const runtimeDir = harnessRuntimeDir(dirs.stateDir, 'codex');
134
+ mkdirSync(runtimeDir, { recursive: true });
135
+ const proxyModule = join(runtimeDir, 'app-server-proxy.mjs');
136
+ const source = compiledProxyModule();
137
+ if (!source)
138
+ throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
139
+ writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
140
+ const windows = process.platform === 'win32';
141
+ const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
142
+ const script = windows
143
+ ? `@echo off\r\n"${process.execPath.replaceAll('"', '""')}" "${proxyModule.replaceAll('"', '""')}" %*\r\n`
144
+ : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(proxyModule)} "$@"\n`;
145
+ writeFileSync(command, script, { mode: 0o700 });
146
+ chmodSync(command, 0o700);
147
+ return {
148
+ CODEX_PATH: command,
149
+ [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
+ [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
151
+ [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
+ ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
+ };
154
+ }
79
155
  function encodeTomlValue(value) {
80
156
  if (typeof value === 'string')
81
157
  return JSON.stringify(value);
@@ -209,7 +285,7 @@ export function makeCodexAdapter(exec = realExec) {
209
285
  if (requested === 'ours-codex' && !hasOursCodex)
210
286
  throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
211
287
  const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
212
- return { argv: [], env: {}, command };
288
+ return { argv: [], env: codexAcpEnvironment(role, dirs), command };
213
289
  },
214
290
  buildLaunch(role, mode, _s, prep) {
215
291
  const stateDir = roleStateDir(role);
@@ -227,7 +303,7 @@ export function makeCodexAdapter(exec = realExec) {
227
303
  ? [...configured]
228
304
  : typeof configured === 'string'
229
305
  ? ['sh', '-c', configured]
230
- : bundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp');
306
+ : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
231
307
  const initialMode = acpAgentMode(role);
232
308
  return {
233
309
  argv,
@@ -282,6 +358,27 @@ export function makeCodexAdapter(exec = realExec) {
282
358
  return translated;
283
359
  const approval = approvalPolicy(role) ?? 'on-request';
284
360
  const sandbox = sandboxMode(role) ?? 'workspace-write';
361
+ if (role.session === 'acp') {
362
+ const mode = acpAgentMode(role) ?? 'agent';
363
+ const configured = role.session_options?.acp?.command;
364
+ const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
+ const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
366
+ const exact = actual.approval === approval && actual.sandbox === sandbox;
367
+ return {
368
+ ...translated,
369
+ native: { mode, ...actual },
370
+ exact,
371
+ warnings: exact ? [] : [configured != null
372
+ ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
+ + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
+ + `sandbox=${actual.sandbox}`
375
+ : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
+ + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
+ + `app-server override is unavailable; this does not exactly represent `
378
+ + `approval=${approval} sandbox=${sandbox}`],
379
+ capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
+ };
381
+ }
285
382
  return {
286
383
  ...translated,
287
384
  native: { approval, sandbox },
@@ -289,13 +386,21 @@ export function makeCodexAdapter(exec = realExec) {
289
386
  };
290
387
  },
291
388
  effectivePermissionMode(role) {
389
+ if (role.session === 'acp') {
390
+ const nativeMode = acpAgentMode(role) ?? 'agent';
391
+ if (role.session_options?.acp?.command == null && canOverrideBundledAcpApproval()) {
392
+ const approval = approvalPolicy(role) ?? 'on-request';
393
+ return { fleetMode: fleetModeForApproval(approval), nativeMode };
394
+ }
395
+ return {
396
+ fleetMode: fleetModeForApproval(acpModePermissions(nativeMode).approval), nativeMode,
397
+ };
398
+ }
292
399
  const nativeMode = approvalPolicy(role) ?? 'untrusted';
293
- const fleetMode = nativeMode === 'never' ? 'allow'
294
- : nativeMode === 'on-request' ? 'auto'
295
- : nativeMode === 'untrusted' ? 'ask' : undefined;
296
- if (!fleetMode)
297
- throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
298
- return { fleetMode, nativeMode };
400
+ return { fleetMode: fleetModeForApproval(nativeMode), nativeMode };
401
+ },
402
+ inheritedPermissionMode(role) {
403
+ return fleetModeForApproval(approvalPolicy(role) ?? 'untrusted');
299
404
  },
300
405
  vocabulary: {
301
406
  bindTool: 'choose_identity',
@@ -111,6 +111,8 @@ export interface HarnessAdapter {
111
111
  fleetMode: FleetPermissionMode;
112
112
  nativeMode: string;
113
113
  };
114
+ /** Configured portable intent to inherit when a live runtime preset is narrower. */
115
+ inheritedPermissionMode?(role: ResolvedRole): FleetPermissionMode;
114
116
  /**
115
117
  * REQUIRED. Every adapter must either translate neutral permissions or
116
118
  * explicitly declare that it cannot. Enforced at registration.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
2
  export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, FleetPermissionMode, ApprovalMode, SessionOptions, OwnerChannelConfig, } from './config.js';
3
3
  export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
4
- export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
4
+ export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, InterruptOutcome, InterruptResult, TurnCancellationSource, QueuedPrompt, ExitRecord, } from './session/types.js';
5
+ export { interruptOutcome } from './session/types.js';
5
6
  export { AcpSession } from './session/acp.js';
6
7
  export { OwnerChannel } from './owner-channel/channel.js';
7
8
  export { TmuxSession } from './session/tmux.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
+ export { interruptOutcome } from './session/types.js';
2
3
  export { AcpSession } from './session/acp.js';
3
4
  export { OwnerChannel } from './owner-channel/channel.js';
4
5
  export { TmuxSession } from './session/tmux.js';
@@ -1,12 +1,22 @@
1
1
  import type { ResolvedRoleLoop } from './config.js';
2
- import { type ScheduledLoopsFile } from './state.js';
2
+ import { type ScheduledLoopsFile, type StateWriter } from './state.js';
3
3
  import { RoleTurnArbiter } from '../session/arbiter.js';
4
4
  export interface LoopManagerDeps {
5
5
  now(): number;
6
6
  setTimer(callback: () => void, ms: number): unknown;
7
7
  clearTimer(timer: unknown): void;
8
8
  log(line: string): void;
9
+ /** Persistence seam; defaults to the atomic replace. Tests inject write faults here. */
10
+ writeState?: StateWriter;
11
+ /** Test seam for the post-cancellation abandon bound; production uses the default. */
12
+ cancelAbandonMs?: number;
9
13
  }
14
+ /**
15
+ * Recovery delay for the nth consecutive failure: 1s doubling to the poll
16
+ * ceiling. Bounded at both ends — never a busy retry, never longer than the
17
+ * normal cadence, so recovery is noticed within a minute of the fault clearing.
18
+ */
19
+ export declare function backoffMs(consecutiveFailures: number): number;
10
20
  export interface LoopActionResult {
11
21
  state: 'started' | 'skipped_busy' | 'disabled' | 'unavailable';
12
22
  runId?: string;
@@ -29,6 +39,8 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
29
39
  private timer?;
30
40
  private readonly runTimeouts;
31
41
  private stopping;
42
+ /** Consecutive unforeseen poll failures; drives the recovery backoff. */
43
+ private pollFailures;
32
44
  constructor(role: string, definitions: ResolvedRoleLoop[], stateDir: string, arbiter: RoleTurnArbiter, deps: LoopManagerDeps);
33
45
  start(): void;
34
46
  stop(): Promise<void>;
@@ -40,10 +52,39 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
40
52
  /** Public fake-clock seam; timer callbacks call the same transition. */
41
53
  poll(): Promise<void>;
42
54
  private attempt;
55
+ /**
56
+ * Last resort for a run whose cancellation never settles. Without it a single
57
+ * unsettled turn keeps `activeRunId` set for the life of the process, and
58
+ * every later tick reports `skipped_busy` forever — the loop looks scheduled
59
+ * while nothing has run since. Releasing the slot is honest, and it is safe
60
+ * because `finish` still refuses to double-report a run it no longer owns.
61
+ *
62
+ * Releasing the loop's own slot is only half of it. The cancellation that
63
+ * never settled is still holding the arbiter's admission boundary, so the
64
+ * generation it belongs to has to be retired in the same step — otherwise the
65
+ * loop believes it is free while every later producer, scheduled or owner,
66
+ * queues behind a promise that will never resolve.
67
+ */
68
+ private armAbandon;
43
69
  private finish;
44
70
  private advance;
45
71
  private skipMissed;
46
72
  private skipRestartMisses;
73
+ /**
74
+ * A run the store could not record is dropped, not retried: the cursor has
75
+ * already moved, so this can never become a busy loop, and the outage is
76
+ * visible as a skip with a failed health rather than as silence.
77
+ */
78
+ private skipUnpersisted;
47
79
  private schedule;
80
+ private arm;
81
+ /**
82
+ * The residual safety net. Persistence failures are absorbed by the store, so
83
+ * reaching here means something unforeseen threw — and whatever it was, the
84
+ * manager must stay armed. The previous behaviour left no timer at all, which
85
+ * turned one transient ENOSPC into every loop on the role being silently gone
86
+ * until the process was restarted.
87
+ */
88
+ private recover;
48
89
  private envelope;
49
90
  }
@@ -1,5 +1,23 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { ScheduledLoopStateStore, deterministicJitter, increment, } from './state.js';
3
+ /** The manager never sleeps longer than this, so one poll per minute is the floor rate. */
4
+ const POLL_CEILING_MS = 60_000;
5
+ /**
6
+ * Recovery delay for the nth consecutive failure: 1s doubling to the poll
7
+ * ceiling. Bounded at both ends — never a busy retry, never longer than the
8
+ * normal cadence, so recovery is noticed within a minute of the fault clearing.
9
+ */
10
+ export function backoffMs(consecutiveFailures) {
11
+ const exponent = Math.min(Math.max(consecutiveFailures, 1) - 1, 6);
12
+ return Math.min(POLL_CEILING_MS, 1_000 * 2 ** exponent);
13
+ }
14
+ /**
15
+ * How long a cancelled run may stay `active` before the loop stops believing it
16
+ * will ever settle. Deliberately longer than the session layer's own escalation
17
+ * (cancel grace + SIGTERM->SIGKILL grace), so this fires only when that recovery
18
+ * did not, and never races a run that is about to report honestly.
19
+ */
20
+ const LOOP_CANCEL_ABANDON_MS = 60_000;
3
21
  export class ScheduledLoopManager {
4
22
  role;
5
23
  arbiter;
@@ -9,13 +27,15 @@ export class ScheduledLoopManager {
9
27
  timer;
10
28
  runTimeouts = new Set();
11
29
  stopping = false;
30
+ /** Consecutive unforeseen poll failures; drives the recovery backoff. */
31
+ pollFailures = 0;
12
32
  constructor(role, definitions, stateDir, arbiter, deps) {
13
33
  this.role = role;
14
34
  this.arbiter = arbiter;
15
35
  this.deps = deps;
16
36
  for (const definition of definitions)
17
37
  this.definitions.set(definition.name, definition);
18
- this.store = new ScheduledLoopStateStore(stateDir, role, definitions, deps.now(), deps.log);
38
+ this.store = new ScheduledLoopStateStore(stateDir, role, definitions, deps.now(), deps.log, deps.writeState);
19
39
  }
20
40
  start() {
21
41
  if (!this.store.fresh)
@@ -102,10 +122,21 @@ export class ScheduledLoopManager {
102
122
  }
103
123
  const scheduledAt = Date.parse(state.nextScheduledAt);
104
124
  this.advance(definition, state);
105
- this.store.persist();
125
+ // The claim has to be durable before the turn is submitted: a run that is
126
+ // not on disk is a run a restart cannot see, and the occurrence would be
127
+ // submitted a second time. At-most-once outranks running this occurrence.
128
+ if (!this.store.persist()) {
129
+ this.skipUnpersisted(definition, state, now);
130
+ continue;
131
+ }
106
132
  await this.attempt(definition, state, scheduledAt);
107
133
  }
108
134
  this.store.state.clock.lastWallMs = now;
135
+ if (this.pollFailures) {
136
+ this.store.clearAnomaly('manager_task_failed');
137
+ this.deps.log(`[${this.role}] scheduled loop manager recovered after ${this.pollFailures} failed poll(s)`);
138
+ this.pollFailures = 0;
139
+ }
109
140
  this.store.persist();
110
141
  this.schedule();
111
142
  }
@@ -153,9 +184,16 @@ export class ScheduledLoopManager {
153
184
  if (state.activeRunId !== runId || this.stopping)
154
185
  return;
155
186
  this.deps.log(`[${this.role}] loop ${definition.name} timed out run=${runId.slice(0, 11)} after ${timeoutMs}ms; cancelling`);
156
- void this.arbiter.interrupt('scheduled-loop').catch(error => {
187
+ void this.arbiter.interrupt('scheduled-loop').then(outcome => {
188
+ // Forced recovery IS a successful cancellation; record how it ended
189
+ // rather than reporting the interrupt itself as a failure.
190
+ if (outcome?.state === 'forced')
191
+ this.deps.log(`[${this.role}] loop ${definition.name} cancellation enforced `
192
+ + `run=${runId.slice(0, 11)} reason=${outcome.reasonCode ?? 'forced'}`);
193
+ }).catch(error => {
157
194
  this.deps.log(`[${this.role}] loop ${definition.name} timeout cancellation failed: ${error?.name ?? 'Error'}`);
158
195
  });
196
+ this.armAbandon(definition, state, runId);
159
197
  }, timeoutMs);
160
198
  this.runTimeouts.add(timeout);
161
199
  void result.queued.completion.then(turn => {
@@ -165,6 +203,38 @@ export class ScheduledLoopManager {
165
203
  });
166
204
  return { state: 'started', runId };
167
205
  }
206
+ /**
207
+ * Last resort for a run whose cancellation never settles. Without it a single
208
+ * unsettled turn keeps `activeRunId` set for the life of the process, and
209
+ * every later tick reports `skipped_busy` forever — the loop looks scheduled
210
+ * while nothing has run since. Releasing the slot is honest, and it is safe
211
+ * because `finish` still refuses to double-report a run it no longer owns.
212
+ *
213
+ * Releasing the loop's own slot is only half of it. The cancellation that
214
+ * never settled is still holding the arbiter's admission boundary, so the
215
+ * generation it belongs to has to be retired in the same step — otherwise the
216
+ * loop believes it is free while every later producer, scheduled or owner,
217
+ * queues behind a promise that will never resolve.
218
+ */
219
+ armAbandon(definition, state, runId) {
220
+ const abandon = this.deps.setTimer(() => {
221
+ this.runTimeouts.delete(abandon);
222
+ if (state.activeRunId !== runId || this.stopping)
223
+ return;
224
+ state.activeRunId = null;
225
+ state.lastFinishedAt = new Date(this.deps.now()).toISOString();
226
+ state.counts.failed = increment(state.counts.failed);
227
+ state.lastOutcome = 'abandoned_unsettled';
228
+ state.lastError = { kind: 'abandoned_unsettled', at: state.lastFinishedAt };
229
+ this.store.state.health = 'degraded';
230
+ this.store.state.anomaly = 'abandoned_unsettled';
231
+ this.store.persist();
232
+ this.arbiter.retireStalledAdmission();
233
+ this.deps.log(`[${this.role}] loop ${definition.name} abandoned run=${runId.slice(0, 11)}: `
234
+ + 'cancellation never settled; admission released');
235
+ }, this.deps.cancelAbandonMs ?? LOOP_CANCEL_ABANDON_MS);
236
+ this.runTimeouts.add(abandon);
237
+ }
168
238
  finish(definition, state, runId, result) {
169
239
  if (state.activeRunId !== runId)
170
240
  return;
@@ -216,6 +286,19 @@ export class ScheduledLoopManager {
216
286
  this.skipMissed(definition, state, now);
217
287
  }
218
288
  }
289
+ /**
290
+ * A run the store could not record is dropped, not retried: the cursor has
291
+ * already moved, so this can never become a busy loop, and the outage is
292
+ * visible as a skip with a failed health rather than as silence.
293
+ */
294
+ skipUnpersisted(definition, state, now) {
295
+ state.counts.skipped = increment(state.counts.skipped);
296
+ state.lastOutcome = 'skipped_unpersisted';
297
+ state.lastFinishedAt = new Date(now).toISOString();
298
+ state.lastError = { kind: 'persist_failed', at: state.lastFinishedAt };
299
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_unpersisted `
300
+ + `(${this.store.lastPersistError ?? 'state write failed'})`);
301
+ }
219
302
  schedule() {
220
303
  if (this.timer !== undefined)
221
304
  this.deps.clearTimer(this.timer);
@@ -226,20 +309,36 @@ export class ScheduledLoopManager {
226
309
  const state = this.store.state.loops[definition.name];
227
310
  return definition.enabled && !state.operatorDisabled;
228
311
  }).map(definition => Date.parse(this.store.state.loops[definition.name].nextDueAt));
229
- if (!due.length)
312
+ // While writes are failing the manager keeps a bounded probe armed even with
313
+ // nothing due, so health stops lying the moment the disk comes back.
314
+ const probe = this.store.persistFailures ? backoffMs(this.store.persistFailures) : undefined;
315
+ if (!due.length && probe === undefined)
230
316
  return;
231
- const delay = Math.max(0, Math.min(...due) - this.deps.now());
232
- this.timer = this.deps.setTimer(() => {
233
- void this.poll().catch(error => {
234
- this.store.state.health = 'failed';
235
- this.store.state.anomaly = 'manager_task_failed';
236
- try {
237
- this.store.persist();
238
- }
239
- catch { }
240
- this.deps.log(`[${this.role}] scheduled loop manager failed: ${error?.name ?? 'Error'}`);
241
- });
242
- }, Math.min(delay, 60_000));
317
+ const delay = due.length ? Math.max(0, Math.min(...due) - this.deps.now()) : POLL_CEILING_MS;
318
+ this.arm(Math.min(delay, POLL_CEILING_MS, probe ?? POLL_CEILING_MS));
319
+ }
320
+ arm(delayMs) {
321
+ this.timer = this.deps.setTimer(() => { void this.poll().catch(error => this.recover(error)); }, delayMs);
322
+ }
323
+ /**
324
+ * The residual safety net. Persistence failures are absorbed by the store, so
325
+ * reaching here means something unforeseen threw — and whatever it was, the
326
+ * manager must stay armed. The previous behaviour left no timer at all, which
327
+ * turned one transient ENOSPC into every loop on the role being silently gone
328
+ * until the process was restarted.
329
+ */
330
+ recover(error) {
331
+ this.pollFailures = increment(this.pollFailures);
332
+ this.store.state.health = 'failed';
333
+ this.store.state.anomaly = 'manager_task_failed';
334
+ this.store.persist();
335
+ this.deps.log(`[${this.role}] scheduled loop manager failed: ${error?.name ?? 'Error'}`
336
+ + ` (attempt ${this.pollFailures}, retrying in ${backoffMs(this.pollFailures)}ms)`);
337
+ if (this.stopping)
338
+ return;
339
+ if (this.timer !== undefined)
340
+ this.deps.clearTimer(this.timer);
341
+ this.arm(backoffMs(this.pollFailures));
243
342
  }
244
343
  envelope(definition, runId, scheduledAt) {
245
344
  return [
@@ -42,13 +42,57 @@ export interface ScheduledLoopsFile {
42
42
  export declare const increment: (value: number, amount?: number) => number;
43
43
  export declare function deterministicJitter(role: string, loop: string, nominalMs: number, maximumMs: number): number;
44
44
  export declare function scheduledLoopsPath(stateDir: string): string;
45
+ /**
46
+ * A live manager rewrites the checkpoint at least once per poll ceiling (60s).
47
+ * Past this bound the recorded `health` describes a manager that is no longer
48
+ * updating it — a dead scheduler, or one whose writes are failing, which is
49
+ * exactly the case where the field still reads `healthy` because the flip to
50
+ * `failed` could not be written. Readers must not repeat a stale field as fact.
51
+ */
52
+ export declare const STORED_STATE_STALE_MS: number;
53
+ export interface StoredLoopVerdict {
54
+ health: ScheduledLoopsFile['health'] | 'stale';
55
+ recorded: ScheduledLoopsFile['health'];
56
+ stale: boolean;
57
+ ageMs: number;
58
+ /** Whether any loop still owes a run — nothing is expected of the file if not. */
59
+ scheduled: boolean;
60
+ }
61
+ /** Truthful health for a reader that only has the stored file to go on. */
62
+ export declare function storedLoopHealth(file: ScheduledLoopsFile, now: number): StoredLoopVerdict;
45
63
  export declare function readScheduledLoops(stateDir: string): ScheduledLoopsFile | undefined;
64
+ /** Persistence seam. Production uses the atomic replace; faults are injected here. */
65
+ export type StateWriter = (path: string, contents: string, mode: number) => void;
46
66
  export declare class ScheduledLoopStateStore {
47
67
  private readonly log;
68
+ private readonly write;
48
69
  readonly path: string;
49
70
  readonly fresh: boolean;
50
71
  state: ScheduledLoopsFile;
51
- constructor(stateDir: string, role: string, definitions: ResolvedRoleLoop[], now: number, log: (line: string) => void);
72
+ /** Consecutive failed checkpoints; 0 whenever the stored file is current. */
73
+ persistFailures: number;
74
+ lastPersistError: string | null;
75
+ /** Health displaced by the persist_failed marker, restored when a write lands. */
76
+ private suppressed;
77
+ constructor(stateDir: string, role: string, definitions: ResolvedRoleLoop[], now: number, log: (line: string) => void, write?: StateWriter);
52
78
  reconcile(definitions: ResolvedRoleLoop[], now: number, recoverActive?: boolean): void;
53
- persist(): void;
79
+ /**
80
+ * Drop a transient anomaly once its cause is over — including one currently
81
+ * displaced by `persist_failed`, which would otherwise come back the moment a
82
+ * write finally lands.
83
+ */
84
+ clearAnomaly(kind: string): void;
85
+ /**
86
+ * Checkpoint the state. A write failure — ENOSPC is the one seen in the field —
87
+ * must never propagate: every caller sits under a timer callback, and an
88
+ * exception there kills the scheduling chain for the life of the process while
89
+ * the last-written file goes on claiming the loops are healthy. So the failure
90
+ * is recorded in memory instead, where `status()` and the live control socket
91
+ * report it immediately, and the caller decides what to do with `false`.
92
+ *
93
+ * The `failed` marker is applied optimistically-in-reverse: it is rolled back
94
+ * just before each attempt, so that the write which finally lands records the
95
+ * real health rather than the outage that is now over.
96
+ */
97
+ persist(): boolean;
54
98
  }
@@ -17,6 +17,25 @@ export function deterministicJitter(role, loop, nominalMs, maximumMs) {
17
17
  export function scheduledLoopsPath(stateDir) {
18
18
  return join(stateDir, '.scheduled-loops.json');
19
19
  }
20
+ /**
21
+ * A live manager rewrites the checkpoint at least once per poll ceiling (60s).
22
+ * Past this bound the recorded `health` describes a manager that is no longer
23
+ * updating it — a dead scheduler, or one whose writes are failing, which is
24
+ * exactly the case where the field still reads `healthy` because the flip to
25
+ * `failed` could not be written. Readers must not repeat a stale field as fact.
26
+ */
27
+ export const STORED_STATE_STALE_MS = 5 * 60_000;
28
+ /** Truthful health for a reader that only has the stored file to go on. */
29
+ export function storedLoopHealth(file, now) {
30
+ const ageMs = Math.max(0, now - file.clock.lastWallMs);
31
+ // A role with nothing left to schedule stops polling on purpose, so its
32
+ // checkpoint stops advancing on purpose too. Only a role that still owes
33
+ // someone a run can be stale; calling a deliberately idle one stale would
34
+ // teach operators to ignore the word.
35
+ const scheduled = Object.values(file.loops).some(loop => loop.enabled && !loop.operatorDisabled);
36
+ const stale = scheduled && ageMs > STORED_STATE_STALE_MS;
37
+ return { health: stale ? 'stale' : file.health, recorded: file.health, stale, ageMs, scheduled };
38
+ }
20
39
  export function readScheduledLoops(stateDir) {
21
40
  try {
22
41
  const path = scheduledLoopsPath(stateDir);
@@ -31,11 +50,18 @@ export function readScheduledLoops(stateDir) {
31
50
  }
32
51
  export class ScheduledLoopStateStore {
33
52
  log;
53
+ write;
34
54
  path;
35
55
  fresh;
36
56
  state;
37
- constructor(stateDir, role, definitions, now, log) {
57
+ /** Consecutive failed checkpoints; 0 whenever the stored file is current. */
58
+ persistFailures = 0;
59
+ lastPersistError = null;
60
+ /** Health displaced by the persist_failed marker, restored when a write lands. */
61
+ suppressed = null;
62
+ constructor(stateDir, role, definitions, now, log, write = replaceFileAtomically) {
38
63
  this.log = log;
64
+ this.write = write;
39
65
  this.path = scheduledLoopsPath(stateDir);
40
66
  let restored;
41
67
  let corrupt = false;
@@ -113,9 +139,61 @@ export class ScheduledLoopStateStore {
113
139
  this.state.clock.lastWallMs = now;
114
140
  this.persist();
115
141
  }
142
+ /**
143
+ * Drop a transient anomaly once its cause is over — including one currently
144
+ * displaced by `persist_failed`, which would otherwise come back the moment a
145
+ * write finally lands.
146
+ */
147
+ clearAnomaly(kind) {
148
+ if (this.suppressed?.anomaly === kind)
149
+ this.suppressed = { health: 'healthy', anomaly: null };
150
+ if (this.state.anomaly === kind) {
151
+ this.state.health = 'healthy';
152
+ this.state.anomaly = null;
153
+ }
154
+ }
155
+ /**
156
+ * Checkpoint the state. A write failure — ENOSPC is the one seen in the field —
157
+ * must never propagate: every caller sits under a timer callback, and an
158
+ * exception there kills the scheduling chain for the life of the process while
159
+ * the last-written file goes on claiming the loops are healthy. So the failure
160
+ * is recorded in memory instead, where `status()` and the live control socket
161
+ * report it immediately, and the caller decides what to do with `false`.
162
+ *
163
+ * The `failed` marker is applied optimistically-in-reverse: it is rolled back
164
+ * just before each attempt, so that the write which finally lands records the
165
+ * real health rather than the outage that is now over.
166
+ */
116
167
  persist() {
117
- replaceFileAtomically(this.path, JSON.stringify(this.state, null, 2) + '\n', 0o600);
118
- chmodSync(this.path, 0o600);
168
+ if (this.suppressed && this.state.anomaly === 'persist_failed') {
169
+ this.state.health = this.suppressed.health;
170
+ this.state.anomaly = this.suppressed.anomaly;
171
+ }
172
+ try {
173
+ this.write(this.path, JSON.stringify(this.state, null, 2) + '\n', 0o600);
174
+ chmodSync(this.path, 0o600);
175
+ }
176
+ catch (error) {
177
+ const code = error?.code ?? error?.name ?? 'Error';
178
+ if (this.state.anomaly !== 'persist_failed')
179
+ this.suppressed = { health: this.state.health, anomaly: this.state.anomaly };
180
+ this.state.health = 'failed';
181
+ this.state.anomaly = 'persist_failed';
182
+ this.lastPersistError = code;
183
+ this.persistFailures = increment(this.persistFailures);
184
+ if (this.persistFailures === 1)
185
+ this.log(`[${this.state.role}] scheduled loop state write failing (${code}); `
186
+ + 'cadence held and health reported failed until it lands');
187
+ return false;
188
+ }
189
+ if (this.persistFailures) {
190
+ this.log(`[${this.state.role}] scheduled loop persistence recovered after `
191
+ + `${this.persistFailures} failed write(s) (${this.lastPersistError})`);
192
+ this.persistFailures = 0;
193
+ this.lastPersistError = null;
194
+ }
195
+ this.suppressed = null;
196
+ return true;
119
197
  }
120
198
  }
121
199
  function validFile(value) {