@ours.network/fleet 0.10.0-nightly.4 → 0.10.1

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 (53) hide show
  1. package/README.md +138 -21
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +43 -13
  6. package/dist/cli.js +98 -22
  7. package/dist/config.d.ts +24 -3
  8. package/dist/config.js +84 -11
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +28 -1
  12. package/dist/docs.js +155 -8
  13. package/dist/doctor.js +75 -17
  14. package/dist/harness/claude-code.d.ts +39 -3
  15. package/dist/harness/claude-code.js +128 -26
  16. package/dist/harness/codex.d.ts +7 -1
  17. package/dist/harness/codex.js +58 -11
  18. package/dist/harness/registry.d.ts +2 -0
  19. package/dist/harness/registry.js +19 -0
  20. package/dist/harness/types.d.ts +51 -4
  21. package/dist/isolation/bubblewrap.js +7 -1
  22. package/dist/isolation/policy.d.ts +34 -5
  23. package/dist/isolation/policy.js +114 -7
  24. package/dist/isolation/resources.d.ts +6 -3
  25. package/dist/isolation/resources.js +6 -3
  26. package/dist/isolation/types.d.ts +19 -1
  27. package/dist/monitor.d.ts +44 -7
  28. package/dist/monitor.js +157 -35
  29. package/dist/ops.d.ts +15 -2
  30. package/dist/ops.js +32 -9
  31. package/dist/permissions.d.ts +70 -0
  32. package/dist/permissions.js +97 -0
  33. package/dist/runner.d.ts +72 -2
  34. package/dist/runner.js +291 -28
  35. package/dist/session/acp.d.ts +25 -2
  36. package/dist/session/acp.js +143 -26
  37. package/dist/session/control.d.ts +49 -1
  38. package/dist/session/control.js +116 -12
  39. package/dist/session/tmux.d.ts +9 -2
  40. package/dist/session/tmux.js +36 -4
  41. package/dist/session/types.d.ts +99 -2
  42. package/dist/session/types.js +42 -1
  43. package/dist/spawn.d.ts +27 -2
  44. package/dist/spawn.js +153 -15
  45. package/dist/supervisor/launchd.d.ts +50 -0
  46. package/dist/supervisor/launchd.js +121 -4
  47. package/dist/supervisor/none.js +22 -4
  48. package/dist/supervisor/systemd.d.ts +8 -1
  49. package/dist/supervisor/systemd.js +94 -4
  50. package/dist/supervisor/types.d.ts +36 -3
  51. package/dist/tmux.d.ts +34 -2
  52. package/dist/tmux.js +48 -11
  53. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -2,8 +2,8 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
5
- import { agentDir, home, stateRoot } from './paths.js';
6
- import { loadConfig, findRole, resolvePermissions } from './config.js';
5
+ import { agentDir, stateRoot } from './paths.js';
6
+ import { loadConfig, findRole, isolationContextFor, resolveMonitorConfig, resolvePermissions, } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
9
9
  import { createMonitor } from './monitor.js';
@@ -14,6 +14,7 @@ import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
14
  import { AcpSession } from './session/acp.js';
15
15
  import { RoleControlServer } from './session/control.js';
16
16
  import { TmuxSession } from './session/tmux.js';
17
+ import { classifyShellStatus } from './session/types.js';
17
18
  const defaultDeps = () => ({
18
19
  tmux: new Tmux(),
19
20
  exec: realExec,
@@ -31,6 +32,24 @@ const defaultDeps = () => ({
31
32
  fetch: (url, init) => globalThis.fetch(url, init),
32
33
  createMonitor: opts => createMonitor(opts),
33
34
  });
35
+ const MONITOR_OWNER_FILE = '.monitor-owner';
36
+ /**
37
+ * Record who owns wake delivery for this run. Returning true means a fleet
38
+ * monitor is taking ownership back from a native harness and must start at the
39
+ * current stream tip rather than replay notifications the native owner was
40
+ * responsible for.
41
+ */
42
+ export function recordMonitorOwner(dir, owner) {
43
+ let previous = null;
44
+ try {
45
+ const path = join(dir, MONITOR_OWNER_FILE);
46
+ if (existsSync(path))
47
+ previous = readFileSync(path, 'utf8').trim();
48
+ writeFileSync(path, `${owner}\n`);
49
+ }
50
+ catch { /* ownership diagnostics must never take the role down */ }
51
+ return owner === 'fleet' && previous === 'native';
52
+ }
34
53
  /**
35
54
  * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
36
55
  * `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
@@ -42,7 +61,12 @@ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = lau
42
61
  const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
43
62
  const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
44
63
  const cmd = paneArgv.map(shq).join(' ');
45
- return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
64
+ // Write a structured record, not a bare number: the wait status alone cannot
65
+ // say whether the file is missing because the program never exited or because
66
+ // nothing ever wrote it. `printf` is POSIX; no shell branching is needed
67
+ // because classification happens in one place, in TypeScript.
68
+ const record = `'{"version":1,"backend":"tmux","status":'"$__ofs"'}'`;
69
+ return `${envPfx} ${cmd}; __ofs=$?; printf %s ${record} > ${shq(exitStatusPath)}`;
46
70
  }
47
71
  /** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
48
72
  function monitorDeps(deps, roleEnv) {
@@ -59,6 +83,89 @@ function monitorDeps(deps, roleEnv) {
59
83
  timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
60
84
  };
61
85
  }
86
+ /**
87
+ * Read the pane's `.exit-status`. Three shapes are accepted: the structured
88
+ * record written above, a bare number left by a pre-upgrade pane (so an
89
+ * in-place upgrade does not misread a real exit), and anything else — which is
90
+ * `unknown`, never an invented failure. A missing file returns null so the
91
+ * caller can distinguish "no record" from "a record saying unknown".
92
+ */
93
+ export function readExitRecord(path) {
94
+ if (!existsSync(path))
95
+ return null;
96
+ const raw = readFileSync(path, 'utf8').trim();
97
+ if (!raw)
98
+ return { version: 1, class: 'unknown', detail: 'the pane left an empty exit record' };
99
+ if (/^-?\d+$/.test(raw))
100
+ return classifyShellStatus(Number(raw)); // legacy `echo $?`
101
+ try {
102
+ const parsed = JSON.parse(raw);
103
+ if (typeof parsed.status === 'number')
104
+ return classifyShellStatus(parsed.status);
105
+ }
106
+ catch { /* fall through to unknown */ }
107
+ return { version: 1, class: 'unknown', detail: `unreadable exit record: ${raw.slice(0, 120)}` };
108
+ }
109
+ // ─── Restart-loop containment (3.2) ──────────────────────────────────────────
110
+ //
111
+ // The child-session restart loop used to BE the service manager: systemd's
112
+ // `Restart=always RestartSec=2` and launchd's `KeepAlive`. Neither can count,
113
+ // so a program that dies instantly was relaunched every two seconds forever,
114
+ // and each relaunch was a fresh process with no memory of the previous one.
115
+ // The count now lives with the role, in its state directory, so it survives the
116
+ // runner being restarted and is consistent across both service managers.
117
+ export const RESTART_LEDGER_FILE = '.restart-ledger.json';
118
+ /** Consecutive immediate failures tolerated before the agent is held down. */
119
+ export const RESTART_FAIL_THRESHOLD = 5;
120
+ const RESTART_BACKOFF_BASE_MS = 2_000;
121
+ const RESTART_BACKOFF_MAX_MS = 60_000;
122
+ /** How often a held-down runner re-reads its ledger, so `up` can release it. */
123
+ const HELD_DOWN_POLL_MS = 5_000;
124
+ const emptyLedger = () => ({
125
+ version: 1,
126
+ consecutiveImmediateFailures: 0,
127
+ lastReason: '',
128
+ nextDelayMs: 0,
129
+ resumeDiscarded: false,
130
+ circuit: 'closed',
131
+ updatedAt: new Date(0).toISOString(),
132
+ });
133
+ /** Bounded exponential backoff for the nth consecutive immediate failure. */
134
+ export function backoffFor(consecutiveFailures) {
135
+ if (consecutiveFailures <= 0)
136
+ return 0;
137
+ return Math.min(RESTART_BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), RESTART_BACKOFF_MAX_MS);
138
+ }
139
+ /** Read a role's restart ledger; a missing or corrupt one starts clean. */
140
+ export function readRestartLedger(dir) {
141
+ try {
142
+ const raw = JSON.parse(readFileSync(join(dir, RESTART_LEDGER_FILE), 'utf8'));
143
+ if (raw.version !== 1)
144
+ return emptyLedger();
145
+ return { ...emptyLedger(), ...raw, version: 1 };
146
+ }
147
+ catch {
148
+ return emptyLedger();
149
+ }
150
+ }
151
+ export function writeRestartLedger(dir, ledger) {
152
+ try {
153
+ mkdirSync(dir, { recursive: true });
154
+ writeFileSync(join(dir, RESTART_LEDGER_FILE), JSON.stringify(ledger, null, 2) + '\n');
155
+ }
156
+ catch { /* diagnostics must never take the role down */ }
157
+ }
158
+ /**
159
+ * Close the circuit and forget the failure streak. Called by an explicit
160
+ * operator `up`/`restart`, which is the only thing that may release a held-down
161
+ * role — a held-down runner polls this file, so a role can be released without
162
+ * bouncing its unit.
163
+ */
164
+ export function resetRestartLedger(dir) {
165
+ if (!existsSync(dir))
166
+ return;
167
+ writeRestartLedger(dir, { ...emptyLedger(), updatedAt: new Date().toISOString() });
168
+ }
62
169
  /** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
63
170
  export const START_STAGGER_FILE = '.start-stagger-ms';
64
171
  /** Read the start-stagger a temp agent was spawned with (0 if none / unreadable). */
@@ -131,6 +238,10 @@ export function loadTempRole(name) {
131
238
  if (!existsSync(p))
132
239
  throw new Error(`temp role '${name}' has no snapshot at ${p}`);
133
240
  const role = parse(readFileSync(p, 'utf8'));
241
+ // Upgrade snapshots written before monitor.mode/interrupt existed. A snapshot
242
+ // with no monitor block keeps the historical native/no-supervisor behavior.
243
+ if (role.monitor)
244
+ role.monitor = resolveMonitorConfig(undefined, role.monitor);
134
245
  role.__temp = true;
135
246
  return role;
136
247
  }
@@ -151,7 +262,7 @@ function resolveConfigPath(dir, explicit) {
151
262
  return undefined;
152
263
  return readFileSync(marker, 'utf8').trim() || undefined;
153
264
  }
154
- /** One supervised session lifecycle. The supervisor re-invokes us after we return. */
265
+ /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
155
266
  export async function runOnce(name, opts = {}, partialDeps = {}) {
156
267
  const deps = { ...defaultDeps(), ...partialDeps };
157
268
  const temp = opts.temp === true;
@@ -197,12 +308,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
197
308
  // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
198
309
  let wrappedArgv = launch.argv;
199
310
  if (role.isolation) {
200
- const addDirs = role.harness === 'codex'
201
- ? (role.harness_options?.add_dirs ?? [])
202
- : [];
203
- const ctx = {
204
- stateDir: dir, runCwd, home: home(), harness: role.harness, additionalWriteDirs: addDirs,
205
- };
311
+ // The SAME context config validation and doctor judged (5.2): a policy
312
+ // checked against a different mount set than the one that launches is not a
313
+ // check at all.
314
+ const ctx = { ...isolationContextFor(role), stateDir: dir, runCwd };
206
315
  const policy = resolveIsolation(role.isolation, ctx);
207
316
  const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
208
317
  const degradedMarker = join(dir, '.isolation-degraded');
@@ -239,22 +348,33 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
239
348
  }
240
349
  // Supervisor mail monitor (design §1): prime the notification cursor at the
241
350
  // stream tip BEFORE the session launches so no arrival is missed during boot
242
- // (backlog before the tip is the SessionStart hook's job). Disabled roles keep
243
- // the legacy in-session watch. Temp snapshots predating `monitor:` are treated
244
- // as disabled (monitor may be undefined on an old role.yaml).
351
+ // (backlog before the tip is the SessionStart hook's job). Native-mode roles
352
+ // leave wake ownership to the harness. Temp snapshots predating `monitor:` are
353
+ // treated as native (monitor may be undefined on an old role.yaml).
245
354
  const resolvedMonitorDeps = monitorDeps(deps, role.env);
246
- const monitor = role.monitor?.enabled ? deps.createMonitor({
355
+ const monitorOwner = role.monitor?.mode === 'fleet' ? 'fleet' : 'native';
356
+ const resetMonitorCursor = recordMonitorOwner(dir, monitorOwner);
357
+ const monitor = monitorOwner === 'fleet' ? deps.createMonitor({
247
358
  name, identity: role.identity, agentDir: dir, cfg: role.monitor,
248
359
  deps: resolvedMonitorDeps,
249
360
  }) : null;
250
361
  if (monitor)
251
- await monitor.prime();
362
+ await monitor.prime({ resetCursor: resetMonitorCursor });
252
363
  rmSync(exitFile, { force: true });
253
364
  let pid;
254
365
  let sessionHandle;
255
366
  let acpSession;
256
367
  let control;
368
+ let monitorLoop;
369
+ let acpStartupComplete = false;
257
370
  if (sessionBackend === 'acp') {
371
+ const perms = role.permissions ?? resolvePermissions(undefined, undefined);
372
+ // Say once, at startup, that this role will decide permission requests by
373
+ // itself. Without it the only trace of an auto-denied tool call is a turn
374
+ // that quietly did less than it was asked to.
375
+ if (perms.unattended === 'deny')
376
+ deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
377
+ `permission requests are automatically denied once each (reject_once) and the turn continues`);
258
378
  acpSession = await AcpSession.start({
259
379
  name,
260
380
  argv: wrappedArgv,
@@ -262,7 +382,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
262
382
  env: { ...launch.env, ...(role.env ?? {}) },
263
383
  stateDir: dir,
264
384
  mode,
265
- permissions: role.permissions ?? resolvePermissions(undefined, undefined),
385
+ permissions: perms,
266
386
  log: deps.log,
267
387
  });
268
388
  pid = acpSession.pid;
@@ -270,21 +390,45 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
270
390
  control = new RoleControlServer(dir, acpSession, deps.log);
271
391
  await control.start();
272
392
  resolvedMonitorDeps.delivery = {
273
- submit: async (text) => {
274
- const result = await acpSession.submitPrompt(text);
275
- return { accepted: result.accepted, detail: result.detail };
393
+ // A wake is only delivered when its turn TERMINATES successfully. A
394
+ // refusal or a cancellation reached the agent and was not acted on, so
395
+ // the monitor must keep its cursor and try again.
396
+ submit: async (text, options) => {
397
+ // Cancelling the runner-owned startup prompt makes startup look failed
398
+ // and closes the session before the wake turn can run. During startup,
399
+ // steer into the live turn instead; after it completes, honor the
400
+ // configured interrupt policy normally.
401
+ const interrupt = options?.interrupt === true && acpStartupComplete;
402
+ const result = await acpSession.submitPrompt(text, { ...options, interrupt, steer: true });
403
+ const steered = result.accepted
404
+ && (result.detail === 'injected' || result.detail === 'startedNewTurn');
405
+ return {
406
+ succeeded: result.succeeded || steered,
407
+ outcome: steered ? result.detail : result.outcome,
408
+ detail: result.detail,
409
+ };
276
410
  },
277
411
  };
278
412
  const firstPrompt = mode === 'fresh'
279
413
  ? `Read and follow ${join(dir, 'briefing.md')} now.`
280
414
  : adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
281
- const started = await acpSession.submitPrompt(firstPrompt);
282
- if (!started.accepted) {
415
+ // Wait for the first turn's TERMINAL result. An agent that accepts the
416
+ // startup prompt and then refuses it has not started; logging the role as
417
+ // up would hide a role that never read its briefing.
418
+ const starting = acpSession.submitPrompt(firstPrompt);
419
+ // Monitoring starts immediately. The delivery adapter above downgrades
420
+ // interruption to steering until this startup turn reaches a terminal
421
+ // success, so there is neither a deaf gap nor a boot-cancellation loop.
422
+ monitorLoop = monitor?.run(pid);
423
+ const started = await starting;
424
+ if (!started.succeeded) {
283
425
  monitor?.stop();
284
426
  await control.close();
285
427
  await acpSession.close();
286
- throw new Error(`[${name}] ACP session rejected startup prompt: ${started.detail ?? started.outcome}`);
428
+ throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
429
+ `${started.detail ? `: ${started.detail}` : ''}`);
287
430
  }
431
+ acpStartupComplete = true;
288
432
  }
289
433
  else {
290
434
  await deps.tmux.kill(name);
@@ -303,7 +447,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
303
447
  deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
304
448
  // The monitor loop lives exactly as long as the session: it starts once the
305
449
  // pane pid is known and is stopped when that pid dies (task dies with runner).
306
- const monitorLoop = monitor?.run(pid);
450
+ monitorLoop ??= monitor?.run(pid);
307
451
  const start = deps.now();
308
452
  while (sessionHandle.isAlive())
309
453
  await deps.sleep(2000);
@@ -316,18 +460,137 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
316
460
  if (acpSession)
317
461
  await acpSession.close();
318
462
  const elapsed = (deps.now() - start) / 1000;
319
- const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
463
+ // Establish what actually happened before deciding anything. Absence of a
464
+ // record is `unknown` — except when the console itself is gone, which is a
465
+ // different event with a different consequence.
466
+ const exitRecord = acpSession
467
+ ? acpSession.exitResult()
468
+ ?? { version: 1, class: 'unknown', detail: 'the ACP agent stopped without reporting an exit' }
469
+ : readExitRecord(exitFile)
470
+ ?? (await deps.tmux.has(name)
471
+ ? { version: 1, class: 'unknown', detail: 'the pane process ended without writing an exit record' }
472
+ : { version: 1, class: 'session-destroyed', detail: `the tmux session '${name}' no longer exists` });
473
+ writeFileSync(exitFile, JSON.stringify({
474
+ ...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
475
+ }) + '\n');
476
+ let rotated = false;
320
477
  const rotate = (why) => {
321
478
  writeFileSync(sidFile, randomUUID() + '\n');
322
479
  rmSync(bootedFile, { force: true });
480
+ rotated = true;
323
481
  deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
324
482
  };
325
- if (code === '0' && adapter.exitPolicy.cleanExitIsFresh)
483
+ if (exitRecord.class === 'clean' && adapter.exitPolicy.cleanExitIsFresh)
326
484
  rotate(`clean exit (code 0)`);
327
- else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs)
328
- rotate(`resume failed fast (${elapsed.toFixed(0)}s, code ${code})`);
485
+ else if (exitRecord.class === 'session-destroyed')
486
+ // Someone tore the console down; the agent did not fail. Rotating here
487
+ // would discard a live conversation for an operator action.
488
+ deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
489
+ else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs) {
490
+ // Self-heal a poisoned resume — but only once per failure sequence. Rotating
491
+ // on every attempt would discard the conversation again and again while the
492
+ // real cause (a broken command, a missing binary) went unaddressed.
493
+ if (opts.allowResumeRotation === false)
494
+ deps.log(`[${name}] resume failed fast again (${elapsed.toFixed(0)}s, ${exitRecord.detail}) ` +
495
+ `-> resume state was already discarded once; keeping it`);
496
+ else
497
+ rotate(`resume failed fast (${elapsed.toFixed(0)}s, ${exitRecord.detail})`);
498
+ }
329
499
  else
330
- deps.log(`[${name}] exited (code ${code}, ${elapsed.toFixed(0)}s) -> next start RESUMES context`);
500
+ deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
501
+ return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode };
502
+ }
503
+ /**
504
+ * The persistent supervisor for one permanent role: run child sessions in a
505
+ * loop, count consecutive immediate failures across them, back off between
506
+ * attempts, and after `RESTART_FAIL_THRESHOLD` hold the agent down while
507
+ * staying alive — so the service manager has nothing to restart and cannot
508
+ * resume the two-second loop behind our back.
509
+ *
510
+ * `attempt` is injectable so the policy can be tested against a fake clock and
511
+ * fake child instead of real sessions.
512
+ */
513
+ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt = runOnce) {
514
+ const deps = { ...defaultDeps(), ...partialDeps };
515
+ const dir = agentDir(name);
516
+ mkdirSync(dir, { recursive: true });
517
+ const shouldStop = deps.shouldStop ?? (() => false);
518
+ const stamp = () => new Date(deps.now()).toISOString();
519
+ while (!shouldStop()) {
520
+ let ledger = readRestartLedger(dir);
521
+ if (ledger.circuit === 'open') {
522
+ // Held down. Stay alive — exiting would hand the role straight back to
523
+ // the service manager — and watch for an operator reset.
524
+ await deps.sleep(HELD_DOWN_POLL_MS);
525
+ continue;
526
+ }
527
+ let result;
528
+ try {
529
+ result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
530
+ }
531
+ catch (e) {
532
+ // A session that could not even start is an immediate failure like any
533
+ // other; it must count, or an unstartable role loops forever.
534
+ result = {
535
+ elapsedSecs: 0,
536
+ exit: { version: 1, class: 'unknown', detail: e instanceof Error ? e.message : String(e) },
537
+ rotated: false,
538
+ mode: 'fresh',
539
+ };
540
+ }
541
+ // Re-read: the attempt itself may have taken minutes, and an operator may
542
+ // have reset the ledger meanwhile.
543
+ ledger = readRestartLedger(dir);
544
+ const fastFailSecs = fastFailSecsFor(name, opts.configPath);
545
+ const immediate = result.elapsedSecs < fastFailSecs;
546
+ if (!immediate) {
547
+ // A session that ran for a while is not a restart loop, whatever ended it.
548
+ writeRestartLedger(dir, {
549
+ ...emptyLedger(),
550
+ lastReason: result.exit.detail,
551
+ updatedAt: stamp(),
552
+ });
553
+ continue;
554
+ }
555
+ const failures = ledger.consecutiveImmediateFailures + 1;
556
+ const reason = `${result.exit.detail} after ${result.elapsedSecs.toFixed(1)}s`;
557
+ const next = {
558
+ version: 1,
559
+ consecutiveImmediateFailures: failures,
560
+ lastReason: reason,
561
+ nextDelayMs: backoffFor(failures),
562
+ resumeDiscarded: ledger.resumeDiscarded || result.rotated,
563
+ circuit: failures >= RESTART_FAIL_THRESHOLD ? 'open' : 'closed',
564
+ updatedAt: stamp(),
565
+ };
566
+ if (next.circuit === 'open') {
567
+ next.openedAt = stamp();
568
+ next.nextDelayMs = 0;
569
+ writeRestartLedger(dir, next);
570
+ deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
571
+ `${reason}; the agent will not be restarted until: ours-fleet restart ${name}`);
572
+ continue;
573
+ }
574
+ writeRestartLedger(dir, next);
575
+ deps.log(`[${name}] immediate failure ${failures}/${RESTART_FAIL_THRESHOLD} (${reason}) ` +
576
+ `-> backing off ${next.nextDelayMs}ms`);
577
+ await deps.sleep(next.nextDelayMs);
578
+ }
579
+ return readRestartLedger(dir);
580
+ }
581
+ /**
582
+ * How short an attempt has to be to count as immediate. The role's harness
583
+ * decides; an unreadable config falls back to the common 20s so a broken config
584
+ * cannot disable the breaker.
585
+ */
586
+ function fastFailSecsFor(name, configPath) {
587
+ try {
588
+ const role = findRole(loadConfig(configPath), name);
589
+ return getAdapter(role.harness).exitPolicy.fastFailSecs;
590
+ }
591
+ catch {
592
+ return 20;
593
+ }
331
594
  }
332
595
  /** Temp-agent entrypoint: run one session, then remove the temp dir. */
333
596
  export async function runTemp(name, deps = {}) {
@@ -1,5 +1,5 @@
1
1
  import type { CommonPermissions } from '../config.js';
2
- import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
2
+ import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnOutcome, TurnResult } from './types.js';
3
3
  export interface AcpSessionOptions {
4
4
  name: string;
5
5
  argv: string[];
@@ -10,6 +10,12 @@ export interface AcpSessionOptions {
10
10
  permissions: CommonPermissions;
11
11
  log(line: string): void;
12
12
  }
13
+ /**
14
+ * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
15
+ * cancellation are the two ways a delivered prompt ends without being carried
16
+ * out; every other stop reason ran the turn to an end the agent chose.
17
+ */
18
+ export declare function classifyStopReason(stopReason: string | undefined): TurnOutcome;
13
19
  /**
14
20
  * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
15
21
  * human/automation attachment happens through the fleet role-control protocol.
@@ -27,22 +33,39 @@ export declare class AcpSession implements SessionHandle {
27
33
  private readiness;
28
34
  private lastError?;
29
35
  private promptTail;
36
+ private queueDepth;
37
+ private exit;
38
+ private steeringSupported;
30
39
  private capabilities?;
31
40
  private controllerCount;
32
41
  private constructor();
33
42
  static start(options: AcpSessionOptions): Promise<AcpSession>;
34
43
  isAlive(): boolean;
35
44
  snapshot(): SessionSnapshot;
36
- submitPrompt(text: string): Promise<TurnResult>;
45
+ /**
46
+ * Accept responsibility for a prompt, then return. The turn itself may run
47
+ * for minutes behind other queued turns; making an interactive caller wait
48
+ * for it is what turned a busy agent into a timeout and then into "dead".
49
+ */
50
+ queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
51
+ submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
37
52
  interrupt(): Promise<void>;
38
53
  respondPermission(permissionId: string, optionId: string): boolean;
39
54
  eventsSince(seq: number): SessionEvent[];
40
55
  subscribe(listener: (event: SessionEvent) => void): () => void;
41
56
  setControllerAttached(attached: boolean): void;
57
+ exitResult(): ExitRecord | null;
42
58
  close(): Promise<void>;
43
59
  private initialize;
44
60
  private runPrompt;
61
+ private steerPrompt;
45
62
  private requestPermission;
63
+ /**
64
+ * Resolve a permission request from policy alone and leave a record of it.
65
+ * Nothing else in the system can observe an automatic decision, so an
66
+ * unrecorded one is indistinguishable from a request that was never made.
67
+ */
68
+ private settleAutomatically;
46
69
  private withinAutomaticBoundary;
47
70
  private recordUpdate;
48
71
  private fail;