@ours.network/fleet 0.9.5 → 0.9.7
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 +101 -0
- 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 +41 -11
- package/dist/cli.js +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- 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 +30 -3
- package/dist/monitor.js +63 -25
- 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 +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- 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/monitor.js
CHANGED
|
@@ -260,6 +260,8 @@ export class Monitor {
|
|
|
260
260
|
// ended in an API error with no completed turn in between.
|
|
261
261
|
apiErrorStreak = 0;
|
|
262
262
|
turnFailThreshold;
|
|
263
|
+
/** Active degradations, keyed by cause. Empty means armed. */
|
|
264
|
+
causes = new Map();
|
|
263
265
|
constructor(o) {
|
|
264
266
|
this.name = o.name;
|
|
265
267
|
this.identity = o.identity ?? o.name;
|
|
@@ -278,23 +280,23 @@ export class Monitor {
|
|
|
278
280
|
if (persisted !== null) {
|
|
279
281
|
this.cursor = persisted;
|
|
280
282
|
this.deliveredCursor = persisted;
|
|
281
|
-
this.
|
|
283
|
+
this.writeStatus();
|
|
282
284
|
return;
|
|
283
285
|
}
|
|
284
286
|
try {
|
|
285
287
|
const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
|
|
286
288
|
this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
|
|
287
289
|
this.persistCursor();
|
|
288
|
-
this.
|
|
290
|
+
this.writeStatus();
|
|
289
291
|
}
|
|
290
292
|
catch (e) {
|
|
291
293
|
if (e instanceof AuthError) {
|
|
292
294
|
this.fatal = true;
|
|
293
|
-
this.
|
|
295
|
+
this.degrade('auth', e.message, 'failed');
|
|
294
296
|
}
|
|
295
297
|
else {
|
|
296
298
|
this.cursor = null;
|
|
297
|
-
this.
|
|
299
|
+
this.degrade('connectivity', `prime failed (${msg(e)})`);
|
|
298
300
|
}
|
|
299
301
|
}
|
|
300
302
|
}
|
|
@@ -307,24 +309,26 @@ export class Monitor {
|
|
|
307
309
|
const pending = [];
|
|
308
310
|
while (!this.stopped) {
|
|
309
311
|
if (!this.deps.isAlive(pid)) {
|
|
310
|
-
this.
|
|
312
|
+
this.degrade('offline', 'session offline');
|
|
311
313
|
return;
|
|
312
314
|
}
|
|
313
315
|
let body;
|
|
314
316
|
try {
|
|
315
317
|
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_TIMEOUT_MS);
|
|
316
318
|
backoff = 0;
|
|
319
|
+
// A poll that worked proves the stream is healthy — and only that.
|
|
320
|
+
this.recover('connectivity');
|
|
317
321
|
}
|
|
318
322
|
catch (e) {
|
|
319
323
|
if (this.stopped)
|
|
320
324
|
return;
|
|
321
325
|
if (e instanceof AuthError) {
|
|
322
326
|
this.fatal = true;
|
|
323
|
-
this.
|
|
327
|
+
this.degrade('auth', e.message, 'failed');
|
|
324
328
|
return;
|
|
325
329
|
}
|
|
326
330
|
backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
|
|
327
|
-
this.
|
|
331
|
+
this.degrade('connectivity', `stream hiccup (${msg(e)})`);
|
|
328
332
|
await this.deps.sleep(backoff);
|
|
329
333
|
continue;
|
|
330
334
|
}
|
|
@@ -350,7 +354,7 @@ export class Monitor {
|
|
|
350
354
|
accepted = await this.deliver(pid, pending);
|
|
351
355
|
}
|
|
352
356
|
catch (e) {
|
|
353
|
-
this.
|
|
357
|
+
this.degrade('delivery', `delivery failed (${msg(e)})`);
|
|
354
358
|
}
|
|
355
359
|
if (accepted) {
|
|
356
360
|
pending.length = 0;
|
|
@@ -382,19 +386,23 @@ export class Monitor {
|
|
|
382
386
|
const line = formatNotificationLine(batch);
|
|
383
387
|
if (this.deps.delivery) {
|
|
384
388
|
const result = await this.deps.delivery.submit(line);
|
|
385
|
-
if (!result.
|
|
386
|
-
|
|
389
|
+
if (!result.succeeded) {
|
|
390
|
+
// Name the reason: "refused" and "cancelled" are the agent's answer,
|
|
391
|
+
// not a transport problem, and an operator has to be able to tell them
|
|
392
|
+
// apart from a dead socket.
|
|
393
|
+
this.degrade('delivery', `wake ${result.outcome}${result.detail ? ` (${result.detail})` : ''}`);
|
|
387
394
|
return false;
|
|
388
395
|
}
|
|
396
|
+
this.recover('delivery', 'modal');
|
|
389
397
|
this.recordTurn('completed');
|
|
390
398
|
return true;
|
|
391
399
|
}
|
|
392
400
|
const state = await this.awaitInjectable(pid);
|
|
393
401
|
if (state !== 'ready') {
|
|
394
402
|
if (state === 'offline')
|
|
395
|
-
this.
|
|
403
|
+
this.degrade('offline', 'offline during delivery');
|
|
396
404
|
else if (state === 'modal')
|
|
397
|
-
this.
|
|
405
|
+
this.degrade('modal', `modal wedge — pane held a dialog for ` +
|
|
398
406
|
`${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
|
|
399
407
|
return false;
|
|
400
408
|
}
|
|
@@ -408,7 +416,7 @@ export class Monitor {
|
|
|
408
416
|
await this.deps.sleep(POST_VERIFY_MS);
|
|
409
417
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
410
418
|
if (!capture.ok) {
|
|
411
|
-
this.
|
|
419
|
+
this.degrade('delivery', 'capture failed during injection verification');
|
|
412
420
|
return false;
|
|
413
421
|
}
|
|
414
422
|
if (!stillInComposer(capture.pane, line)) {
|
|
@@ -418,9 +426,10 @@ export class Monitor {
|
|
|
418
426
|
await this.deps.tmux.sendKey(this.name, 'Enter');
|
|
419
427
|
}
|
|
420
428
|
if (!delivered) {
|
|
421
|
-
this.
|
|
429
|
+
this.degrade('delivery', 'injection unverified');
|
|
422
430
|
return false;
|
|
423
431
|
}
|
|
432
|
+
this.recover('delivery', 'modal');
|
|
424
433
|
// The wake landed and a turn started; observe how that turn terminates so a
|
|
425
434
|
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
426
435
|
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
@@ -442,7 +451,7 @@ export class Monitor {
|
|
|
442
451
|
return; // loop marks offline
|
|
443
452
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
444
453
|
if (!capture.ok) {
|
|
445
|
-
this.
|
|
454
|
+
this.degrade('delivery', 'capture failed during turn observation');
|
|
446
455
|
return;
|
|
447
456
|
}
|
|
448
457
|
if (looksApiError(capture.pane)) {
|
|
@@ -459,14 +468,17 @@ export class Monitor {
|
|
|
459
468
|
}
|
|
460
469
|
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
461
470
|
recordTurn(outcome) {
|
|
471
|
+
if (outcome === 'inconclusive')
|
|
472
|
+
return; // no evidence either way; leave the streak
|
|
462
473
|
if (outcome === 'api-error')
|
|
463
474
|
this.apiErrorStreak++;
|
|
464
|
-
else
|
|
475
|
+
else
|
|
465
476
|
this.apiErrorStreak = 0;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
477
|
+
if (this.apiErrorStreak >= this.turnFailThreshold)
|
|
478
|
+
this.degrade('turns-failing', 'turns failing (api error)');
|
|
479
|
+
else if (outcome === 'completed')
|
|
480
|
+
// A turn that ran to the end is the ONLY thing that clears this.
|
|
481
|
+
this.recover('turns-failing');
|
|
470
482
|
}
|
|
471
483
|
/**
|
|
472
484
|
* Reset the composer to empty before typing a wake. Without this, any
|
|
@@ -500,7 +512,7 @@ export class Monitor {
|
|
|
500
512
|
}
|
|
501
513
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
502
514
|
if (!capture.ok) {
|
|
503
|
-
this.
|
|
515
|
+
this.degrade('delivery', 'capture failed while checking session readiness');
|
|
504
516
|
await this.deps.sleep(MODAL_RETRY_MS);
|
|
505
517
|
continue;
|
|
506
518
|
}
|
|
@@ -592,15 +604,41 @@ export class Monitor {
|
|
|
592
604
|
this.deps.log(`[${this.name}] monitor: failed to persist state: ${msg(e)}`);
|
|
593
605
|
}
|
|
594
606
|
}
|
|
595
|
-
|
|
607
|
+
/** Record a degradation under its own cause and republish the status. */
|
|
608
|
+
degrade(cause, detail, level = 'degraded') {
|
|
609
|
+
const previous = this.causes.get(cause);
|
|
610
|
+
this.causes.set(cause, { level, detail, at: new Date(this.deps.now()).toISOString() });
|
|
611
|
+
this.writeStatus();
|
|
612
|
+
if (previous?.detail !== detail)
|
|
613
|
+
this.deps.log(`[${this.name}] monitor ${level}: ${cause} — ${detail}`);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Clear exactly the causes this recovery signal speaks to. Anything else
|
|
617
|
+
* stays: one successful poll must never be able to erase `turns failing`.
|
|
618
|
+
*/
|
|
619
|
+
recover(...causes) {
|
|
620
|
+
let changed = false;
|
|
621
|
+
for (const cause of causes)
|
|
622
|
+
changed = this.causes.delete(cause) || changed;
|
|
623
|
+
if (changed)
|
|
624
|
+
this.deps.log(`[${this.name}] monitor recovered: ${causes.join(', ')}`);
|
|
625
|
+
this.writeStatus();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* One line per active cause, each dated; `armed` when there are none. Every
|
|
629
|
+
* line carries an ISO timestamp so an operator can tell a live status from a
|
|
630
|
+
* stale one left behind by a monitor that stopped writing.
|
|
631
|
+
*/
|
|
632
|
+
writeStatus() {
|
|
633
|
+
const lines = this.causes.size
|
|
634
|
+
? [...this.causes.entries()].map(([cause, e]) => `${e.level}: ${cause} at ${e.at} — ${e.detail}`)
|
|
635
|
+
: [`armed at ${new Date(this.deps.now()).toISOString()}`];
|
|
596
636
|
try {
|
|
597
|
-
writeFileSync(this.statusPath,
|
|
637
|
+
writeFileSync(this.statusPath, lines.join('\n') + '\n');
|
|
598
638
|
}
|
|
599
639
|
catch (e) {
|
|
600
640
|
this.deps.log(`[${this.name}] monitor: failed to write status: ${msg(e)}`);
|
|
601
641
|
}
|
|
602
|
-
if (!s.startsWith('armed'))
|
|
603
|
-
this.deps.log(`[${this.name}] monitor ${s}`);
|
|
604
642
|
}
|
|
605
643
|
}
|
|
606
644
|
export function createMonitor(o) {
|
package/dist/ops.d.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import type { FleetConfig, ResolvedRole } from './config.js';
|
|
2
|
-
import type { SupervisorBackend } from './supervisor/types.js';
|
|
2
|
+
import type { InstallOutcome as BackendInstallOutcome, SupervisorBackend } from './supervisor/types.js';
|
|
3
|
+
/** An install outcome tagged with the role it belongs to. */
|
|
4
|
+
export interface InstallOutcome extends BackendInstallOutcome {
|
|
5
|
+
role: string;
|
|
6
|
+
}
|
|
3
7
|
export interface OpsDeps {
|
|
4
8
|
backend: SupervisorBackend;
|
|
5
9
|
binPath: string;
|
|
6
10
|
log(line: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Called the INSTANT a registration is created, before anything else can
|
|
13
|
+
* fail. A creation transaction that learns about registrations only from
|
|
14
|
+
* `up()`'s return value learns nothing when `up()` throws — and the service
|
|
15
|
+
* it just registered is then invisible to rollback (6.2). Optional: plain
|
|
16
|
+
* `ours-fleet up` has no transaction to tell.
|
|
17
|
+
*/
|
|
18
|
+
onInstalled?(outcome: InstallOutcome): void;
|
|
7
19
|
}
|
|
8
20
|
/** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
|
|
9
21
|
export declare function applyRole(role: ResolvedRole, opts?: {
|
|
10
22
|
fresh?: boolean;
|
|
11
23
|
temp?: boolean;
|
|
12
24
|
configPath?: string;
|
|
25
|
+
identityGuarantee?: 'verified' | 'created' | 'unverified';
|
|
13
26
|
}): string;
|
|
14
27
|
/** Create/start roles declaratively. Idempotent; active roles keep their context. */
|
|
15
|
-
export declare function up(cfg: FleetConfig, names: string[], deps: OpsDeps, configPath?: string): Promise<
|
|
28
|
+
export declare function up(cfg: FleetConfig, names: string[], deps: OpsDeps, configPath?: string, identityGuarantee?: 'verified' | 'created' | 'unverified'): Promise<InstallOutcome[]>;
|
|
16
29
|
export declare function down(cfg: FleetConfig, names: string[], deps: OpsDeps): Promise<void>;
|
|
17
30
|
/** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
|
|
18
31
|
export declare function restartRoles(cfg: FleetConfig, names: string[], deps: OpsDeps, mode: 'keep' | 'fresh', configPath?: string): Promise<void>;
|
package/dist/ops.js
CHANGED
|
@@ -5,6 +5,7 @@ import { agentDir, fleetDDir } from './paths.js';
|
|
|
5
5
|
import { findRole } from './config.js';
|
|
6
6
|
import { getAdapter } from './harness/registry.js';
|
|
7
7
|
import { generateBriefing } from './briefing.js';
|
|
8
|
+
import { resetRestartLedger } from './runner.js';
|
|
8
9
|
// Launch staggering now lives at the harness-launch point (the runner's start
|
|
9
10
|
// gate, driven by `start_stagger_ms`), so it covers systemd host-boot too — not
|
|
10
11
|
// just the `up`/`restart` command loop below. The old in-loop FLEET_START_STAGGER
|
|
@@ -36,6 +37,7 @@ export function applyRole(role, opts = {}) {
|
|
|
36
37
|
writeFileSync(join(dir, 'briefing.md'), generateBriefing(role, adapter.vocabulary, {
|
|
37
38
|
stateDir: dir, worklogPath: join(dir, 'WORKLOG.md'),
|
|
38
39
|
routinesPath: join(dir, 'ROUTINES.md'), briefingBody,
|
|
40
|
+
identityGuarantee: opts.identityGuarantee,
|
|
39
41
|
}));
|
|
40
42
|
if (opts.fresh)
|
|
41
43
|
for (const f of ['.booted', '.session-id', '.exit-status'])
|
|
@@ -46,32 +48,53 @@ function selectRoles(cfg, names) {
|
|
|
46
48
|
return names.length ? names.map(n => findRole(cfg, n)) : cfg.roles;
|
|
47
49
|
}
|
|
48
50
|
/** Create/start roles declaratively. Idempotent; active roles keep their context. */
|
|
49
|
-
export async function up(cfg, names, deps, configPath) {
|
|
51
|
+
export async function up(cfg, names, deps, configPath, identityGuarantee) {
|
|
52
|
+
const outcomes = [];
|
|
50
53
|
for (const role of selectRoles(cfg, names)) {
|
|
51
|
-
const dir = applyRole(role, { configPath });
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const dir = applyRole(role, { configPath, identityGuarantee });
|
|
55
|
+
// Only a *definite* stop boots fresh so the role reads the briefing we just
|
|
56
|
+
// wrote. A running, restarting, or unprobeable role keeps its context —
|
|
57
|
+
// guessing "stopped" from an unanswered probe silently discards a live
|
|
58
|
+
// conversation.
|
|
59
|
+
// An explicit operator `up` is the sanctioned way to release a held-down
|
|
60
|
+
// role: the still-alive runner polls this file and resumes (3.2).
|
|
61
|
+
resetRestartLedger(dir);
|
|
62
|
+
const live = await deps.backend.liveness(role.name)
|
|
63
|
+
.catch(e => ({ state: 'unknown', detail: e instanceof Error ? e.message : String(e) }));
|
|
64
|
+
if (live.state === 'stopped')
|
|
55
65
|
rmSync(join(dir, '.booted'), { force: true });
|
|
56
|
-
|
|
66
|
+
else if (live.state === 'unknown')
|
|
67
|
+
deps.log(` ! ${role.name}: liveness unknown, keeping session context — ${live.detail}`);
|
|
68
|
+
// Report what each install actually did, so a creation transaction can undo
|
|
69
|
+
// only the registrations IT made (6.2). Announced immediately as well as
|
|
70
|
+
// returned: a later role in this same loop can throw, and the registrations
|
|
71
|
+
// already made must still be undoable.
|
|
72
|
+
const outcome = { ...await deps.backend.install(role.name, deps.binPath), role: role.name };
|
|
73
|
+
if (outcome.created)
|
|
74
|
+
deps.onInstalled?.(outcome);
|
|
75
|
+
outcomes.push(outcome);
|
|
57
76
|
deps.log(`↑ up: ${role.name} (harness: ${role.harness}, identity: ${role.identity}${role.cwd ? `, cwd: ${role.cwd}` : ''})`);
|
|
58
77
|
}
|
|
78
|
+
return outcomes;
|
|
59
79
|
}
|
|
60
80
|
export async function down(cfg, names, deps) {
|
|
61
81
|
for (const role of selectRoles(cfg, names)) {
|
|
82
|
+
// Never swallow the backend's reason. "maybe not running" hid real stop
|
|
83
|
+
// failures — a wedged unit, an unreachable user bus — behind a guess.
|
|
62
84
|
try {
|
|
63
85
|
await deps.backend.stop(role.name);
|
|
64
86
|
deps.log(`■ stopped ${role.name}`);
|
|
65
87
|
}
|
|
66
|
-
catch {
|
|
67
|
-
deps.log(`
|
|
88
|
+
catch (e) {
|
|
89
|
+
deps.log(` ! could not stop ${role.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
}
|
|
71
93
|
/** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
|
|
72
94
|
export async function restartRoles(cfg, names, deps, mode, configPath) {
|
|
73
95
|
for (const role of selectRoles(cfg, names)) {
|
|
74
|
-
applyRole(role, { fresh: mode === 'fresh', configPath });
|
|
96
|
+
const dir = applyRole(role, { fresh: mode === 'fresh', configPath });
|
|
97
|
+
resetRestartLedger(dir); // explicit restart closes the circuit
|
|
75
98
|
await deps.backend.restart(role.name);
|
|
76
99
|
deps.log(mode === 'fresh'
|
|
77
100
|
? `↻ ${role.name} — force-restarted (FRESH — context cleared, briefing reloaded)`
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CommonPermissions, ResolvedRole } from './config.js';
|
|
2
|
+
import type { UnattendedCapability } from './harness/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The capability floor every unattended role must clear. These are not
|
|
5
|
+
* nice-to-haves: an agent that cannot read its briefing, append its worklog,
|
|
6
|
+
* bind its identity, arm its monitor, edit its workspace, or run the status
|
|
7
|
+
* commands its briefing prescribes cannot carry out the job it was spawned for
|
|
8
|
+
* — and, being unattended, will report no error while failing to.
|
|
9
|
+
*/
|
|
10
|
+
export declare const UNATTENDED_FLOOR: readonly UnattendedCapability[];
|
|
11
|
+
export interface FloorResult {
|
|
12
|
+
meets: boolean;
|
|
13
|
+
missing: UnattendedCapability[];
|
|
14
|
+
}
|
|
15
|
+
/** Which floor capabilities a set of granted capabilities fails to cover. */
|
|
16
|
+
export declare function checkUnattendedFloor(granted: readonly UnattendedCapability[]): FloorResult;
|
|
17
|
+
/**
|
|
18
|
+
* One role's neutral permissions, resolved through its harness adapter.
|
|
19
|
+
*
|
|
20
|
+
* Both `ours-fleet config` and `ours-fleet doctor` render this same object, so
|
|
21
|
+
* the two commands cannot disagree about what a configuration actually means.
|
|
22
|
+
* Before this existed, `translatePermissions()` was implemented by every
|
|
23
|
+
* adapter and called by nobody: the warnings it produced — including "this
|
|
24
|
+
* combination is not represented exactly" — were unreachable.
|
|
25
|
+
*/
|
|
26
|
+
export interface RolePermissionAnalysis {
|
|
27
|
+
role: string;
|
|
28
|
+
harness: string;
|
|
29
|
+
permissions: CommonPermissions;
|
|
30
|
+
/** Whether the harness can express neutral permissions at all. */
|
|
31
|
+
supported: boolean;
|
|
32
|
+
/** The harness's own settings, when it can. */
|
|
33
|
+
native?: Record<string, unknown>;
|
|
34
|
+
/** Whether those settings represent the neutral intent exactly. */
|
|
35
|
+
exact?: boolean;
|
|
36
|
+
/** What the native settings actually permit an unattended agent to do. */
|
|
37
|
+
capabilities?: UnattendedCapability[];
|
|
38
|
+
/** Whether those capabilities clear the unattended floor. */
|
|
39
|
+
floor?: FloorResult;
|
|
40
|
+
/**
|
|
41
|
+
* How hard a floor shortfall is. A role that auto-denies (`unattended: deny`)
|
|
42
|
+
* silently does less than asked, so that is a failure; one that waits can at
|
|
43
|
+
* least be rescued by a human attaching a console, so that is a warning.
|
|
44
|
+
*/
|
|
45
|
+
floorSeverity?: 'fail' | 'warn';
|
|
46
|
+
/** Native settings that contradict the neutral block; empty when they agree. */
|
|
47
|
+
conflicts?: PermissionConflict[];
|
|
48
|
+
/** A role-named line when the floor is not met; absent when it is. */
|
|
49
|
+
floorWarning?: string;
|
|
50
|
+
/** Role-named translation warnings, ready to print verbatim by any command. */
|
|
51
|
+
warnings: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface PermissionConflict {
|
|
54
|
+
/** The native setting both sources speak to, e.g. `permission_mode`. */
|
|
55
|
+
key: string;
|
|
56
|
+
/** What the neutral `permissions:` block translates to. */
|
|
57
|
+
fromNeutral: string;
|
|
58
|
+
/** What `harness_options` states directly. */
|
|
59
|
+
fromNative: string;
|
|
60
|
+
/** The role-named line commands print. */
|
|
61
|
+
warning: string;
|
|
62
|
+
}
|
|
63
|
+
/** Resolve one role's permissions through its adapter. Never throws. */
|
|
64
|
+
export declare function analyzeRolePermissions(role: ResolvedRole): RolePermissionAnalysis;
|
|
65
|
+
/** Every line a command should show for a role: translation, conflicts, floor. */
|
|
66
|
+
export declare function allWarnings(a: RolePermissionAnalysis): string[];
|
|
67
|
+
/** Resolve every role's permissions, in config order. */
|
|
68
|
+
export declare function analyzeFleetPermissions(roles: ResolvedRole[]): RolePermissionAnalysis[];
|
|
69
|
+
/** Render an analysis's native settings compactly, for one-line reporting. */
|
|
70
|
+
export declare function formatNative(native: Record<string, unknown> | undefined): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getAdapter } from './harness/registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* The capability floor every unattended role must clear. These are not
|
|
4
|
+
* nice-to-haves: an agent that cannot read its briefing, append its worklog,
|
|
5
|
+
* bind its identity, arm its monitor, edit its workspace, or run the status
|
|
6
|
+
* commands its briefing prescribes cannot carry out the job it was spawned for
|
|
7
|
+
* — and, being unattended, will report no error while failing to.
|
|
8
|
+
*/
|
|
9
|
+
export const UNATTENDED_FLOOR = [
|
|
10
|
+
'read-state', 'write-state', 'messaging', 'monitor', 'workspace-edit', 'status-commands',
|
|
11
|
+
];
|
|
12
|
+
/** Which floor capabilities a set of granted capabilities fails to cover. */
|
|
13
|
+
export function checkUnattendedFloor(granted) {
|
|
14
|
+
const missing = UNATTENDED_FLOOR.filter(c => !granted.includes(c));
|
|
15
|
+
return { meets: missing.length === 0, missing };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Find native settings that contradict the neutral block. Only fires when the
|
|
19
|
+
* operator wrote BOTH — a role that states its intent once, neutrally or
|
|
20
|
+
* natively, has nothing to contradict and stays quiet. `harness_options` wins
|
|
21
|
+
* at launch, which is precisely why a silent disagreement is dangerous: the
|
|
22
|
+
* neutral block reads like the source of truth and is not.
|
|
23
|
+
*/
|
|
24
|
+
function findConflicts(role, fromNeutral, fromNative) {
|
|
25
|
+
if (!role.permissionsDeclared)
|
|
26
|
+
return [];
|
|
27
|
+
const conflicts = [];
|
|
28
|
+
for (const [key, nativeValue] of Object.entries(fromNative)) {
|
|
29
|
+
const neutralValue = fromNeutral[key];
|
|
30
|
+
if (neutralValue === undefined || String(neutralValue) === String(nativeValue))
|
|
31
|
+
continue;
|
|
32
|
+
conflicts.push({
|
|
33
|
+
key,
|
|
34
|
+
fromNeutral: String(neutralValue),
|
|
35
|
+
fromNative: String(nativeValue),
|
|
36
|
+
warning: `role '${role.name}': harness_options.${key}=${String(nativeValue)} contradicts the `
|
|
37
|
+
+ `permissions block, which translates to ${key}=${String(neutralValue)} — `
|
|
38
|
+
+ `harness_options.${key}=${String(nativeValue)} wins`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return conflicts;
|
|
42
|
+
}
|
|
43
|
+
/** Resolve one role's permissions through its adapter. Never throws. */
|
|
44
|
+
export function analyzeRolePermissions(role) {
|
|
45
|
+
const base = { role: role.name, harness: role.harness, permissions: role.permissions };
|
|
46
|
+
let adapter;
|
|
47
|
+
try {
|
|
48
|
+
adapter = getAdapter(role.harness);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { ...base, supported: false, warnings: [`role '${role.name}': ${e.message}`] };
|
|
52
|
+
}
|
|
53
|
+
const translation = adapter.translatePermissions(role.permissions);
|
|
54
|
+
if (!translation.supported) {
|
|
55
|
+
return {
|
|
56
|
+
...base, supported: false,
|
|
57
|
+
warnings: [`role '${role.name}': harness '${role.harness}' cannot express neutral ` +
|
|
58
|
+
`permissions — ${translation.reason}`],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const conflicts = findConflicts(role, translation.native, adapter.nativePermissionOverrides(role.harness_options));
|
|
62
|
+
const floor = checkUnattendedFloor(translation.capabilities);
|
|
63
|
+
const floorSeverity = role.permissions.unattended === 'deny' ? 'fail' : 'warn';
|
|
64
|
+
return {
|
|
65
|
+
...base,
|
|
66
|
+
supported: true,
|
|
67
|
+
native: translation.native,
|
|
68
|
+
exact: translation.exact,
|
|
69
|
+
capabilities: translation.capabilities,
|
|
70
|
+
floor,
|
|
71
|
+
floorSeverity,
|
|
72
|
+
conflicts,
|
|
73
|
+
floorWarning: floor.meets ? undefined : (`role '${role.name}': resolved ${role.harness} permissions do not meet the unattended ` +
|
|
74
|
+
`capability floor — missing ${floor.missing.join(', ')} ` +
|
|
75
|
+
`(${formatNative(translation.native)}; unattended=${role.permissions.unattended} means these ` +
|
|
76
|
+
`requests will ${role.permissions.unattended === 'deny' ? 'be denied silently' : 'block the turn'})`),
|
|
77
|
+
warnings: translation.warnings.map(w => `role '${role.name}': ${w}`),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Every line a command should show for a role: translation, conflicts, floor. */
|
|
81
|
+
export function allWarnings(a) {
|
|
82
|
+
return [
|
|
83
|
+
...a.warnings,
|
|
84
|
+
...(a.conflicts ?? []).map(c => c.warning),
|
|
85
|
+
...(a.floorWarning ? [a.floorWarning] : []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
/** Resolve every role's permissions, in config order. */
|
|
89
|
+
export function analyzeFleetPermissions(roles) {
|
|
90
|
+
return roles.map(analyzeRolePermissions);
|
|
91
|
+
}
|
|
92
|
+
/** Render an analysis's native settings compactly, for one-line reporting. */
|
|
93
|
+
export function formatNative(native) {
|
|
94
|
+
if (!native || !Object.keys(native).length)
|
|
95
|
+
return '(none)';
|
|
96
|
+
return Object.entries(native).map(([k, v]) => `${k}=${String(v)}`).join(' ');
|
|
97
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Launch } from './harness/types.js';
|
|
|
3
3
|
import { Tmux } from './tmux.js';
|
|
4
4
|
import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
|
|
5
5
|
import { type Exec } from './exec.js';
|
|
6
|
+
import type { ExitRecord } from './session/types.js';
|
|
6
7
|
export interface RunnerDeps {
|
|
7
8
|
tmux: Tmux;
|
|
8
9
|
exec: Exec;
|
|
@@ -15,6 +16,8 @@ export interface RunnerDeps {
|
|
|
15
16
|
fetch: FetchLike;
|
|
16
17
|
/** Construct the supervisor mail monitor (injectable so tests stub it out). */
|
|
17
18
|
createMonitor(opts: MonitorOpts): MonitorHandle;
|
|
19
|
+
/** Lets a test (or a shutdown path) end the supervised restart loop. */
|
|
20
|
+
shouldStop?(): boolean;
|
|
18
21
|
}
|
|
19
22
|
/**
|
|
20
23
|
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
@@ -24,6 +27,41 @@ export interface RunnerDeps {
|
|
|
24
27
|
* still sees the real exit code.
|
|
25
28
|
*/
|
|
26
29
|
export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
|
|
30
|
+
/**
|
|
31
|
+
* Read the pane's `.exit-status`. Three shapes are accepted: the structured
|
|
32
|
+
* record written above, a bare number left by a pre-upgrade pane (so an
|
|
33
|
+
* in-place upgrade does not misread a real exit), and anything else — which is
|
|
34
|
+
* `unknown`, never an invented failure. A missing file returns null so the
|
|
35
|
+
* caller can distinguish "no record" from "a record saying unknown".
|
|
36
|
+
*/
|
|
37
|
+
export declare function readExitRecord(path: string): ExitRecord | null;
|
|
38
|
+
export declare const RESTART_LEDGER_FILE = ".restart-ledger.json";
|
|
39
|
+
/** Consecutive immediate failures tolerated before the agent is held down. */
|
|
40
|
+
export declare const RESTART_FAIL_THRESHOLD = 5;
|
|
41
|
+
export interface RestartLedger {
|
|
42
|
+
version: 1;
|
|
43
|
+
consecutiveImmediateFailures: number;
|
|
44
|
+
lastReason: string;
|
|
45
|
+
nextDelayMs: number;
|
|
46
|
+
/** Whether this failure sequence has already thrown away resume state. */
|
|
47
|
+
resumeDiscarded: boolean;
|
|
48
|
+
circuit: 'closed' | 'open';
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
/** When the circuit opened, for the held-down status line. */
|
|
51
|
+
openedAt?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Bounded exponential backoff for the nth consecutive immediate failure. */
|
|
54
|
+
export declare function backoffFor(consecutiveFailures: number): number;
|
|
55
|
+
/** Read a role's restart ledger; a missing or corrupt one starts clean. */
|
|
56
|
+
export declare function readRestartLedger(dir: string): RestartLedger;
|
|
57
|
+
export declare function writeRestartLedger(dir: string, ledger: RestartLedger): void;
|
|
58
|
+
/**
|
|
59
|
+
* Close the circuit and forget the failure streak. Called by an explicit
|
|
60
|
+
* operator `up`/`restart`, which is the only thing that may release a held-down
|
|
61
|
+
* role — a held-down runner polls this file, so a role can be released without
|
|
62
|
+
* bouncing its unit.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resetRestartLedger(dir: string): void;
|
|
27
65
|
/** Filename spawnTemp writes into a temp agent dir to carry the fleet start-stagger. */
|
|
28
66
|
export declare const START_STAGGER_FILE = ".start-stagger-ms";
|
|
29
67
|
/**
|
|
@@ -37,10 +75,35 @@ export declare const START_STAGGER_FILE = ".start-stagger-ms";
|
|
|
37
75
|
export declare function reserveLaunchSlot(root: string, staggerMs: number, deps: Pick<RunnerDeps, 'now' | 'sleep' | 'log'>): Promise<number>;
|
|
38
76
|
/** Read a temp role's config snapshot written by spawnTemp. */
|
|
39
77
|
export declare function loadTempRole(name: string): ResolvedRole;
|
|
40
|
-
/**
|
|
78
|
+
/** What one child session did, so the supervising loop can decide what follows. */
|
|
79
|
+
export interface AttemptResult {
|
|
80
|
+
elapsedSecs: number;
|
|
81
|
+
exit: ExitRecord;
|
|
82
|
+
/** Whether this attempt threw away resume state to start fresh. */
|
|
83
|
+
rotated: boolean;
|
|
84
|
+
mode: 'fresh' | 'resume';
|
|
85
|
+
}
|
|
86
|
+
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
41
87
|
export declare function runOnce(name: string, opts?: {
|
|
42
88
|
temp?: boolean;
|
|
43
89
|
configPath?: string;
|
|
44
|
-
|
|
90
|
+
allowResumeRotation?: boolean;
|
|
91
|
+
}, partialDeps?: Partial<RunnerDeps>): Promise<AttemptResult>;
|
|
92
|
+
/**
|
|
93
|
+
* The persistent supervisor for one permanent role: run child sessions in a
|
|
94
|
+
* loop, count consecutive immediate failures across them, back off between
|
|
95
|
+
* attempts, and after `RESTART_FAIL_THRESHOLD` hold the agent down while
|
|
96
|
+
* staying alive — so the service manager has nothing to restart and cannot
|
|
97
|
+
* resume the two-second loop behind our back.
|
|
98
|
+
*
|
|
99
|
+
* `attempt` is injectable so the policy can be tested against a fake clock and
|
|
100
|
+
* fake child instead of real sessions.
|
|
101
|
+
*/
|
|
102
|
+
export declare function runSupervised(name: string, opts?: {
|
|
103
|
+
configPath?: string;
|
|
104
|
+
}, partialDeps?: Partial<RunnerDeps>, attempt?: (n: string, o: {
|
|
105
|
+
configPath?: string;
|
|
106
|
+
allowResumeRotation?: boolean;
|
|
107
|
+
}, d: Partial<RunnerDeps>) => Promise<AttemptResult>): Promise<RestartLedger>;
|
|
45
108
|
/** Temp-agent entrypoint: run one session, then remove the temp dir. */
|
|
46
109
|
export declare function runTemp(name: string, deps?: Partial<RunnerDeps>): Promise<void>;
|