@ours.network/fleet 0.10.0-nightly.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -21
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +43 -13
- package/dist/cli.js +98 -22
- package/dist/config.d.ts +24 -3
- package/dist/config.js +84 -11
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +155 -8
- package/dist/doctor.js +75 -17
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +128 -26
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +58 -11
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +51 -4
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +33 -4
- package/dist/monitor.js +150 -32
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +262 -27
- package/dist/session/acp.d.ts +25 -2
- package/dist/session/acp.js +143 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +9 -2
- package/dist/session/tmux.js +36 -4
- package/dist/session/types.d.ts +99 -2
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- 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,
|
|
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,
|
|
@@ -42,7 +43,12 @@ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = lau
|
|
|
42
43
|
const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
|
|
43
44
|
const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
|
|
44
45
|
const cmd = paneArgv.map(shq).join(' ');
|
|
45
|
-
|
|
46
|
+
// Write a structured record, not a bare number: the wait status alone cannot
|
|
47
|
+
// say whether the file is missing because the program never exited or because
|
|
48
|
+
// nothing ever wrote it. `printf` is POSIX; no shell branching is needed
|
|
49
|
+
// because classification happens in one place, in TypeScript.
|
|
50
|
+
const record = `'{"version":1,"backend":"tmux","status":'"$__ofs"'}'`;
|
|
51
|
+
return `${envPfx} ${cmd}; __ofs=$?; printf %s ${record} > ${shq(exitStatusPath)}`;
|
|
46
52
|
}
|
|
47
53
|
/** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
|
|
48
54
|
function monitorDeps(deps, roleEnv) {
|
|
@@ -59,6 +65,89 @@ function monitorDeps(deps, roleEnv) {
|
|
|
59
65
|
timers: { set: (fn, ms) => setTimeout(fn, ms), clear: t => clearTimeout(t) },
|
|
60
66
|
};
|
|
61
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Read the pane's `.exit-status`. Three shapes are accepted: the structured
|
|
70
|
+
* record written above, a bare number left by a pre-upgrade pane (so an
|
|
71
|
+
* in-place upgrade does not misread a real exit), and anything else — which is
|
|
72
|
+
* `unknown`, never an invented failure. A missing file returns null so the
|
|
73
|
+
* caller can distinguish "no record" from "a record saying unknown".
|
|
74
|
+
*/
|
|
75
|
+
export function readExitRecord(path) {
|
|
76
|
+
if (!existsSync(path))
|
|
77
|
+
return null;
|
|
78
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
79
|
+
if (!raw)
|
|
80
|
+
return { version: 1, class: 'unknown', detail: 'the pane left an empty exit record' };
|
|
81
|
+
if (/^-?\d+$/.test(raw))
|
|
82
|
+
return classifyShellStatus(Number(raw)); // legacy `echo $?`
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.status === 'number')
|
|
86
|
+
return classifyShellStatus(parsed.status);
|
|
87
|
+
}
|
|
88
|
+
catch { /* fall through to unknown */ }
|
|
89
|
+
return { version: 1, class: 'unknown', detail: `unreadable exit record: ${raw.slice(0, 120)}` };
|
|
90
|
+
}
|
|
91
|
+
// ─── Restart-loop containment (3.2) ──────────────────────────────────────────
|
|
92
|
+
//
|
|
93
|
+
// The child-session restart loop used to BE the service manager: systemd's
|
|
94
|
+
// `Restart=always RestartSec=2` and launchd's `KeepAlive`. Neither can count,
|
|
95
|
+
// so a program that dies instantly was relaunched every two seconds forever,
|
|
96
|
+
// and each relaunch was a fresh process with no memory of the previous one.
|
|
97
|
+
// The count now lives with the role, in its state directory, so it survives the
|
|
98
|
+
// runner being restarted and is consistent across both service managers.
|
|
99
|
+
export const RESTART_LEDGER_FILE = '.restart-ledger.json';
|
|
100
|
+
/** Consecutive immediate failures tolerated before the agent is held down. */
|
|
101
|
+
export const RESTART_FAIL_THRESHOLD = 5;
|
|
102
|
+
const RESTART_BACKOFF_BASE_MS = 2_000;
|
|
103
|
+
const RESTART_BACKOFF_MAX_MS = 60_000;
|
|
104
|
+
/** How often a held-down runner re-reads its ledger, so `up` can release it. */
|
|
105
|
+
const HELD_DOWN_POLL_MS = 5_000;
|
|
106
|
+
const emptyLedger = () => ({
|
|
107
|
+
version: 1,
|
|
108
|
+
consecutiveImmediateFailures: 0,
|
|
109
|
+
lastReason: '',
|
|
110
|
+
nextDelayMs: 0,
|
|
111
|
+
resumeDiscarded: false,
|
|
112
|
+
circuit: 'closed',
|
|
113
|
+
updatedAt: new Date(0).toISOString(),
|
|
114
|
+
});
|
|
115
|
+
/** Bounded exponential backoff for the nth consecutive immediate failure. */
|
|
116
|
+
export function backoffFor(consecutiveFailures) {
|
|
117
|
+
if (consecutiveFailures <= 0)
|
|
118
|
+
return 0;
|
|
119
|
+
return Math.min(RESTART_BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), RESTART_BACKOFF_MAX_MS);
|
|
120
|
+
}
|
|
121
|
+
/** Read a role's restart ledger; a missing or corrupt one starts clean. */
|
|
122
|
+
export function readRestartLedger(dir) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(readFileSync(join(dir, RESTART_LEDGER_FILE), 'utf8'));
|
|
125
|
+
if (raw.version !== 1)
|
|
126
|
+
return emptyLedger();
|
|
127
|
+
return { ...emptyLedger(), ...raw, version: 1 };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return emptyLedger();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function writeRestartLedger(dir, ledger) {
|
|
134
|
+
try {
|
|
135
|
+
mkdirSync(dir, { recursive: true });
|
|
136
|
+
writeFileSync(join(dir, RESTART_LEDGER_FILE), JSON.stringify(ledger, null, 2) + '\n');
|
|
137
|
+
}
|
|
138
|
+
catch { /* diagnostics must never take the role down */ }
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Close the circuit and forget the failure streak. Called by an explicit
|
|
142
|
+
* operator `up`/`restart`, which is the only thing that may release a held-down
|
|
143
|
+
* role — a held-down runner polls this file, so a role can be released without
|
|
144
|
+
* bouncing its unit.
|
|
145
|
+
*/
|
|
146
|
+
export function resetRestartLedger(dir) {
|
|
147
|
+
if (!existsSync(dir))
|
|
148
|
+
return;
|
|
149
|
+
writeRestartLedger(dir, { ...emptyLedger(), updatedAt: new Date().toISOString() });
|
|
150
|
+
}
|
|
62
151
|
/** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
|
|
63
152
|
export const START_STAGGER_FILE = '.start-stagger-ms';
|
|
64
153
|
/** Read the start-stagger a temp agent was spawned with (0 if none / unreadable). */
|
|
@@ -131,6 +220,10 @@ export function loadTempRole(name) {
|
|
|
131
220
|
if (!existsSync(p))
|
|
132
221
|
throw new Error(`temp role '${name}' has no snapshot at ${p}`);
|
|
133
222
|
const role = parse(readFileSync(p, 'utf8'));
|
|
223
|
+
// Upgrade snapshots written before monitor.mode/interrupt existed. A snapshot
|
|
224
|
+
// with no monitor block keeps the historical native/no-supervisor behavior.
|
|
225
|
+
if (role.monitor)
|
|
226
|
+
role.monitor = resolveMonitorConfig(undefined, role.monitor);
|
|
134
227
|
role.__temp = true;
|
|
135
228
|
return role;
|
|
136
229
|
}
|
|
@@ -151,7 +244,7 @@ function resolveConfigPath(dir, explicit) {
|
|
|
151
244
|
return undefined;
|
|
152
245
|
return readFileSync(marker, 'utf8').trim() || undefined;
|
|
153
246
|
}
|
|
154
|
-
/** One
|
|
247
|
+
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
155
248
|
export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
156
249
|
const deps = { ...defaultDeps(), ...partialDeps };
|
|
157
250
|
const temp = opts.temp === true;
|
|
@@ -197,12 +290,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
197
290
|
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
198
291
|
let wrappedArgv = launch.argv;
|
|
199
292
|
if (role.isolation) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const ctx = {
|
|
204
|
-
stateDir: dir, runCwd, home: home(), harness: role.harness, additionalWriteDirs: addDirs,
|
|
205
|
-
};
|
|
293
|
+
// The SAME context config validation and doctor judged (5.2): a policy
|
|
294
|
+
// checked against a different mount set than the one that launches is not a
|
|
295
|
+
// check at all.
|
|
296
|
+
const ctx = { ...isolationContextFor(role), stateDir: dir, runCwd };
|
|
206
297
|
const policy = resolveIsolation(role.isolation, ctx);
|
|
207
298
|
const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
|
|
208
299
|
const degradedMarker = join(dir, '.isolation-degraded');
|
|
@@ -239,11 +330,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
239
330
|
}
|
|
240
331
|
// Supervisor mail monitor (design §1): prime the notification cursor at the
|
|
241
332
|
// stream tip BEFORE the session launches so no arrival is missed during boot
|
|
242
|
-
// (backlog before the tip is the SessionStart hook's job).
|
|
243
|
-
//
|
|
244
|
-
// as
|
|
333
|
+
// (backlog before the tip is the SessionStart hook's job). Native-mode roles
|
|
334
|
+
// leave wake ownership to the harness. Temp snapshots predating `monitor:` are
|
|
335
|
+
// treated as native (monitor may be undefined on an old role.yaml).
|
|
245
336
|
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
246
|
-
const monitor = role.monitor?.
|
|
337
|
+
const monitor = role.monitor?.mode === 'fleet' ? deps.createMonitor({
|
|
247
338
|
name, identity: role.identity, agentDir: dir, cfg: role.monitor,
|
|
248
339
|
deps: resolvedMonitorDeps,
|
|
249
340
|
}) : null;
|
|
@@ -254,7 +345,15 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
254
345
|
let sessionHandle;
|
|
255
346
|
let acpSession;
|
|
256
347
|
let control;
|
|
348
|
+
let monitorLoop;
|
|
257
349
|
if (sessionBackend === 'acp') {
|
|
350
|
+
const perms = role.permissions ?? resolvePermissions(undefined, undefined);
|
|
351
|
+
// Say once, at startup, that this role will decide permission requests by
|
|
352
|
+
// itself. Without it the only trace of an auto-denied tool call is a turn
|
|
353
|
+
// that quietly did less than it was asked to.
|
|
354
|
+
if (perms.unattended === 'deny')
|
|
355
|
+
deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
|
|
356
|
+
`permission requests are automatically denied once each (reject_once) and the turn continues`);
|
|
258
357
|
acpSession = await AcpSession.start({
|
|
259
358
|
name,
|
|
260
359
|
argv: wrappedArgv,
|
|
@@ -262,7 +361,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
262
361
|
env: { ...launch.env, ...(role.env ?? {}) },
|
|
263
362
|
stateDir: dir,
|
|
264
363
|
mode,
|
|
265
|
-
permissions:
|
|
364
|
+
permissions: perms,
|
|
266
365
|
log: deps.log,
|
|
267
366
|
});
|
|
268
367
|
pid = acpSession.pid;
|
|
@@ -270,20 +369,37 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
270
369
|
control = new RoleControlServer(dir, acpSession, deps.log);
|
|
271
370
|
await control.start();
|
|
272
371
|
resolvedMonitorDeps.delivery = {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
372
|
+
// A wake is only delivered when its turn TERMINATES successfully. A
|
|
373
|
+
// refusal or a cancellation reached the agent and was not acted on, so
|
|
374
|
+
// the monitor must keep its cursor and try again.
|
|
375
|
+
submit: async (text, options) => {
|
|
376
|
+
const result = await acpSession.submitPrompt(text, { ...options, steer: true });
|
|
377
|
+
const steered = result.accepted
|
|
378
|
+
&& (result.detail === 'injected' || result.detail === 'startedNewTurn');
|
|
379
|
+
return {
|
|
380
|
+
succeeded: result.succeeded || steered,
|
|
381
|
+
outcome: steered ? result.detail : result.outcome,
|
|
382
|
+
detail: result.detail,
|
|
383
|
+
};
|
|
276
384
|
},
|
|
277
385
|
};
|
|
278
386
|
const firstPrompt = mode === 'fresh'
|
|
279
387
|
? `Read and follow ${join(dir, 'briefing.md')} now.`
|
|
280
388
|
: adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
|
|
281
|
-
|
|
282
|
-
|
|
389
|
+
// Wait for the first turn's TERMINAL result. An agent that accepts the
|
|
390
|
+
// startup prompt and then refuses it has not started; logging the role as
|
|
391
|
+
// up would hide a role that never read its briefing.
|
|
392
|
+
const starting = acpSession.submitPrompt(firstPrompt);
|
|
393
|
+
// Start monitoring as soon as the initial prompt has been submitted. ACP
|
|
394
|
+
// steering can deliver a wake into that turn without a boot-time deaf gap.
|
|
395
|
+
monitorLoop = monitor?.run(pid);
|
|
396
|
+
const started = await starting;
|
|
397
|
+
if (!started.succeeded) {
|
|
283
398
|
monitor?.stop();
|
|
284
399
|
await control.close();
|
|
285
400
|
await acpSession.close();
|
|
286
|
-
throw new Error(`[${name}] ACP
|
|
401
|
+
throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
|
|
402
|
+
`${started.detail ? `: ${started.detail}` : ''}`);
|
|
287
403
|
}
|
|
288
404
|
}
|
|
289
405
|
else {
|
|
@@ -303,7 +419,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
303
419
|
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
|
|
304
420
|
// The monitor loop lives exactly as long as the session: it starts once the
|
|
305
421
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
306
|
-
|
|
422
|
+
monitorLoop ??= monitor?.run(pid);
|
|
307
423
|
const start = deps.now();
|
|
308
424
|
while (sessionHandle.isAlive())
|
|
309
425
|
await deps.sleep(2000);
|
|
@@ -316,18 +432,137 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
316
432
|
if (acpSession)
|
|
317
433
|
await acpSession.close();
|
|
318
434
|
const elapsed = (deps.now() - start) / 1000;
|
|
319
|
-
|
|
435
|
+
// Establish what actually happened before deciding anything. Absence of a
|
|
436
|
+
// record is `unknown` — except when the console itself is gone, which is a
|
|
437
|
+
// different event with a different consequence.
|
|
438
|
+
const exitRecord = acpSession
|
|
439
|
+
? acpSession.exitResult()
|
|
440
|
+
?? { version: 1, class: 'unknown', detail: 'the ACP agent stopped without reporting an exit' }
|
|
441
|
+
: readExitRecord(exitFile)
|
|
442
|
+
?? (await deps.tmux.has(name)
|
|
443
|
+
? { version: 1, class: 'unknown', detail: 'the pane process ended without writing an exit record' }
|
|
444
|
+
: { version: 1, class: 'session-destroyed', detail: `the tmux session '${name}' no longer exists` });
|
|
445
|
+
writeFileSync(exitFile, JSON.stringify({
|
|
446
|
+
...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
|
|
447
|
+
}) + '\n');
|
|
448
|
+
let rotated = false;
|
|
320
449
|
const rotate = (why) => {
|
|
321
450
|
writeFileSync(sidFile, randomUUID() + '\n');
|
|
322
451
|
rmSync(bootedFile, { force: true });
|
|
452
|
+
rotated = true;
|
|
323
453
|
deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
|
|
324
454
|
};
|
|
325
|
-
if (
|
|
455
|
+
if (exitRecord.class === 'clean' && adapter.exitPolicy.cleanExitIsFresh)
|
|
326
456
|
rotate(`clean exit (code 0)`);
|
|
327
|
-
else if (
|
|
328
|
-
|
|
457
|
+
else if (exitRecord.class === 'session-destroyed')
|
|
458
|
+
// Someone tore the console down; the agent did not fail. Rotating here
|
|
459
|
+
// would discard a live conversation for an operator action.
|
|
460
|
+
deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
461
|
+
else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs) {
|
|
462
|
+
// Self-heal a poisoned resume — but only once per failure sequence. Rotating
|
|
463
|
+
// on every attempt would discard the conversation again and again while the
|
|
464
|
+
// real cause (a broken command, a missing binary) went unaddressed.
|
|
465
|
+
if (opts.allowResumeRotation === false)
|
|
466
|
+
deps.log(`[${name}] resume failed fast again (${elapsed.toFixed(0)}s, ${exitRecord.detail}) ` +
|
|
467
|
+
`-> resume state was already discarded once; keeping it`);
|
|
468
|
+
else
|
|
469
|
+
rotate(`resume failed fast (${elapsed.toFixed(0)}s, ${exitRecord.detail})`);
|
|
470
|
+
}
|
|
329
471
|
else
|
|
330
|
-
deps.log(`[${name}]
|
|
472
|
+
deps.log(`[${name}] ${exitRecord.detail} (${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
473
|
+
return { elapsedSecs: elapsed, exit: exitRecord, rotated, mode };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The persistent supervisor for one permanent role: run child sessions in a
|
|
477
|
+
* loop, count consecutive immediate failures across them, back off between
|
|
478
|
+
* attempts, and after `RESTART_FAIL_THRESHOLD` hold the agent down while
|
|
479
|
+
* staying alive — so the service manager has nothing to restart and cannot
|
|
480
|
+
* resume the two-second loop behind our back.
|
|
481
|
+
*
|
|
482
|
+
* `attempt` is injectable so the policy can be tested against a fake clock and
|
|
483
|
+
* fake child instead of real sessions.
|
|
484
|
+
*/
|
|
485
|
+
export async function runSupervised(name, opts = {}, partialDeps = {}, attempt = runOnce) {
|
|
486
|
+
const deps = { ...defaultDeps(), ...partialDeps };
|
|
487
|
+
const dir = agentDir(name);
|
|
488
|
+
mkdirSync(dir, { recursive: true });
|
|
489
|
+
const shouldStop = deps.shouldStop ?? (() => false);
|
|
490
|
+
const stamp = () => new Date(deps.now()).toISOString();
|
|
491
|
+
while (!shouldStop()) {
|
|
492
|
+
let ledger = readRestartLedger(dir);
|
|
493
|
+
if (ledger.circuit === 'open') {
|
|
494
|
+
// Held down. Stay alive — exiting would hand the role straight back to
|
|
495
|
+
// the service manager — and watch for an operator reset.
|
|
496
|
+
await deps.sleep(HELD_DOWN_POLL_MS);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
let result;
|
|
500
|
+
try {
|
|
501
|
+
result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
// A session that could not even start is an immediate failure like any
|
|
505
|
+
// other; it must count, or an unstartable role loops forever.
|
|
506
|
+
result = {
|
|
507
|
+
elapsedSecs: 0,
|
|
508
|
+
exit: { version: 1, class: 'unknown', detail: e instanceof Error ? e.message : String(e) },
|
|
509
|
+
rotated: false,
|
|
510
|
+
mode: 'fresh',
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// Re-read: the attempt itself may have taken minutes, and an operator may
|
|
514
|
+
// have reset the ledger meanwhile.
|
|
515
|
+
ledger = readRestartLedger(dir);
|
|
516
|
+
const fastFailSecs = fastFailSecsFor(name, opts.configPath);
|
|
517
|
+
const immediate = result.elapsedSecs < fastFailSecs;
|
|
518
|
+
if (!immediate) {
|
|
519
|
+
// A session that ran for a while is not a restart loop, whatever ended it.
|
|
520
|
+
writeRestartLedger(dir, {
|
|
521
|
+
...emptyLedger(),
|
|
522
|
+
lastReason: result.exit.detail,
|
|
523
|
+
updatedAt: stamp(),
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const failures = ledger.consecutiveImmediateFailures + 1;
|
|
528
|
+
const reason = `${result.exit.detail} after ${result.elapsedSecs.toFixed(1)}s`;
|
|
529
|
+
const next = {
|
|
530
|
+
version: 1,
|
|
531
|
+
consecutiveImmediateFailures: failures,
|
|
532
|
+
lastReason: reason,
|
|
533
|
+
nextDelayMs: backoffFor(failures),
|
|
534
|
+
resumeDiscarded: ledger.resumeDiscarded || result.rotated,
|
|
535
|
+
circuit: failures >= RESTART_FAIL_THRESHOLD ? 'open' : 'closed',
|
|
536
|
+
updatedAt: stamp(),
|
|
537
|
+
};
|
|
538
|
+
if (next.circuit === 'open') {
|
|
539
|
+
next.openedAt = stamp();
|
|
540
|
+
next.nextDelayMs = 0;
|
|
541
|
+
writeRestartLedger(dir, next);
|
|
542
|
+
deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
|
|
543
|
+
`${reason}; the agent will not be restarted until: ours-fleet restart ${name}`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
writeRestartLedger(dir, next);
|
|
547
|
+
deps.log(`[${name}] immediate failure ${failures}/${RESTART_FAIL_THRESHOLD} (${reason}) ` +
|
|
548
|
+
`-> backing off ${next.nextDelayMs}ms`);
|
|
549
|
+
await deps.sleep(next.nextDelayMs);
|
|
550
|
+
}
|
|
551
|
+
return readRestartLedger(dir);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* How short an attempt has to be to count as immediate. The role's harness
|
|
555
|
+
* decides; an unreadable config falls back to the common 20s so a broken config
|
|
556
|
+
* cannot disable the breaker.
|
|
557
|
+
*/
|
|
558
|
+
function fastFailSecsFor(name, configPath) {
|
|
559
|
+
try {
|
|
560
|
+
const role = findRole(loadConfig(configPath), name);
|
|
561
|
+
return getAdapter(role.harness).exitPolicy.fastFailSecs;
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return 20;
|
|
565
|
+
}
|
|
331
566
|
}
|
|
332
567
|
/** Temp-agent entrypoint: run one session, then remove the temp dir. */
|
|
333
568
|
export async function runTemp(name, deps = {}) {
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|