@ours.network/fleet 0.9.5 → 0.9.8
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 +145 -30
- 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
|
@@ -234,11 +234,73 @@ export function looksRunning(pane) {
|
|
|
234
234
|
return true; // "(12s · … tokens)" elapsed meter
|
|
235
235
|
return false;
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
const COMPOSER_TOP = /^[^\S\n]*[╭┌][─━]/;
|
|
238
|
+
const COMPOSER_BOTTOM = /^[^\S\n]*[╰└][─━]/;
|
|
239
|
+
const COMPOSER_PROMPT = /^[^\S\n]*(?:[│┃|][^\S\n]*)?[❯›>][^\S\n]*$/;
|
|
240
|
+
/**
|
|
241
|
+
* Remove wrapping-only whitespace and box chrome from composer rows. Notification
|
|
242
|
+
* lines contain no meaningful whitespace distinction, so this lets a fragment
|
|
243
|
+
* cross an arbitrary terminal wrap without matching unrelated transcript text.
|
|
244
|
+
*/
|
|
245
|
+
function normalizeComposerRows(rows) {
|
|
246
|
+
return rows.map((raw, i) => {
|
|
247
|
+
let row = raw;
|
|
248
|
+
if (i > 0)
|
|
249
|
+
row = row.replace(/^[^\S\n]*(?:[│┃|][^\S\n]*)?/, '');
|
|
250
|
+
row = row.replace(/[^\S\n]*(?:[│┃|])?[^\S\n]*$/, '');
|
|
251
|
+
return row;
|
|
252
|
+
}).join('').replace(/\s+/g, '');
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Is the injected line still sitting unsubmitted in the composer?
|
|
256
|
+
*
|
|
257
|
+
* The footer has variable height and the line may wrap across any number of
|
|
258
|
+
* rows, so a fixed tail window cannot identify the composer. Prefer the final
|
|
259
|
+
* bordered composer region; for a borderless/truncated capture, require the
|
|
260
|
+
* notification prefix to follow a composer prompt. This keeps old submitted
|
|
261
|
+
* wake lines in the transcript from causing stray Enters.
|
|
262
|
+
*/
|
|
238
263
|
function stillInComposer(pane, line) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
264
|
+
if (!pane)
|
|
265
|
+
return false; // dead pane: do not waste Enters
|
|
266
|
+
const lines = pane.split('\n');
|
|
267
|
+
let bottom = -1;
|
|
268
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
269
|
+
if (COMPOSER_BOTTOM.test(lines[i])) {
|
|
270
|
+
bottom = i;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let top = -1;
|
|
275
|
+
if (bottom >= 0) {
|
|
276
|
+
for (let i = bottom - 1; i >= 0; i--) {
|
|
277
|
+
if (COMPOSER_TOP.test(lines[i])) {
|
|
278
|
+
top = i;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const from = top >= 0 ? top + 1 : 0;
|
|
284
|
+
const to = bottom >= 0 ? bottom : lines.length;
|
|
285
|
+
let wakeRow = -1;
|
|
286
|
+
let wakeColumn = -1;
|
|
287
|
+
for (let i = to - 1; i >= from; i--) {
|
|
288
|
+
const column = lines[i].lastIndexOf(PREFIX);
|
|
289
|
+
if (column >= 0) {
|
|
290
|
+
wakeRow = i;
|
|
291
|
+
wakeColumn = column;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (wakeRow < 0)
|
|
296
|
+
return false;
|
|
297
|
+
// Without both box boundaries, only trust text visibly in a composer prompt.
|
|
298
|
+
// This is the safe fallback for borderless TUIs and truncated captures.
|
|
299
|
+
if (top < 0 && !COMPOSER_PROMPT.test(lines[wakeRow].slice(0, wakeColumn)))
|
|
300
|
+
return false;
|
|
301
|
+
const rows = lines.slice(wakeRow, to);
|
|
302
|
+
rows[0] = rows[0].slice(wakeColumn);
|
|
303
|
+
return normalizeComposerRows(rows).includes(line.replace(/\s+/g, ''));
|
|
242
304
|
}
|
|
243
305
|
export class Monitor {
|
|
244
306
|
name;
|
|
@@ -260,6 +322,8 @@ export class Monitor {
|
|
|
260
322
|
// ended in an API error with no completed turn in between.
|
|
261
323
|
apiErrorStreak = 0;
|
|
262
324
|
turnFailThreshold;
|
|
325
|
+
/** Active degradations, keyed by cause. Empty means armed. */
|
|
326
|
+
causes = new Map();
|
|
263
327
|
constructor(o) {
|
|
264
328
|
this.name = o.name;
|
|
265
329
|
this.identity = o.identity ?? o.name;
|
|
@@ -278,23 +342,23 @@ export class Monitor {
|
|
|
278
342
|
if (persisted !== null) {
|
|
279
343
|
this.cursor = persisted;
|
|
280
344
|
this.deliveredCursor = persisted;
|
|
281
|
-
this.
|
|
345
|
+
this.writeStatus();
|
|
282
346
|
return;
|
|
283
347
|
}
|
|
284
348
|
try {
|
|
285
349
|
const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
|
|
286
350
|
this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
|
|
287
351
|
this.persistCursor();
|
|
288
|
-
this.
|
|
352
|
+
this.writeStatus();
|
|
289
353
|
}
|
|
290
354
|
catch (e) {
|
|
291
355
|
if (e instanceof AuthError) {
|
|
292
356
|
this.fatal = true;
|
|
293
|
-
this.
|
|
357
|
+
this.degrade('auth', e.message, 'failed');
|
|
294
358
|
}
|
|
295
359
|
else {
|
|
296
360
|
this.cursor = null;
|
|
297
|
-
this.
|
|
361
|
+
this.degrade('connectivity', `prime failed (${msg(e)})`);
|
|
298
362
|
}
|
|
299
363
|
}
|
|
300
364
|
}
|
|
@@ -307,24 +371,26 @@ export class Monitor {
|
|
|
307
371
|
const pending = [];
|
|
308
372
|
while (!this.stopped) {
|
|
309
373
|
if (!this.deps.isAlive(pid)) {
|
|
310
|
-
this.
|
|
374
|
+
this.degrade('offline', 'session offline');
|
|
311
375
|
return;
|
|
312
376
|
}
|
|
313
377
|
let body;
|
|
314
378
|
try {
|
|
315
379
|
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_TIMEOUT_MS);
|
|
316
380
|
backoff = 0;
|
|
381
|
+
// A poll that worked proves the stream is healthy — and only that.
|
|
382
|
+
this.recover('connectivity');
|
|
317
383
|
}
|
|
318
384
|
catch (e) {
|
|
319
385
|
if (this.stopped)
|
|
320
386
|
return;
|
|
321
387
|
if (e instanceof AuthError) {
|
|
322
388
|
this.fatal = true;
|
|
323
|
-
this.
|
|
389
|
+
this.degrade('auth', e.message, 'failed');
|
|
324
390
|
return;
|
|
325
391
|
}
|
|
326
392
|
backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
|
|
327
|
-
this.
|
|
393
|
+
this.degrade('connectivity', `stream hiccup (${msg(e)})`);
|
|
328
394
|
await this.deps.sleep(backoff);
|
|
329
395
|
continue;
|
|
330
396
|
}
|
|
@@ -350,7 +416,7 @@ export class Monitor {
|
|
|
350
416
|
accepted = await this.deliver(pid, pending);
|
|
351
417
|
}
|
|
352
418
|
catch (e) {
|
|
353
|
-
this.
|
|
419
|
+
this.degrade('delivery', `delivery failed (${msg(e)})`);
|
|
354
420
|
}
|
|
355
421
|
if (accepted) {
|
|
356
422
|
pending.length = 0;
|
|
@@ -382,19 +448,23 @@ export class Monitor {
|
|
|
382
448
|
const line = formatNotificationLine(batch);
|
|
383
449
|
if (this.deps.delivery) {
|
|
384
450
|
const result = await this.deps.delivery.submit(line);
|
|
385
|
-
if (!result.
|
|
386
|
-
|
|
451
|
+
if (!result.succeeded) {
|
|
452
|
+
// Name the reason: "refused" and "cancelled" are the agent's answer,
|
|
453
|
+
// not a transport problem, and an operator has to be able to tell them
|
|
454
|
+
// apart from a dead socket.
|
|
455
|
+
this.degrade('delivery', `wake ${result.outcome}${result.detail ? ` (${result.detail})` : ''}`);
|
|
387
456
|
return false;
|
|
388
457
|
}
|
|
458
|
+
this.recover('delivery', 'modal');
|
|
389
459
|
this.recordTurn('completed');
|
|
390
460
|
return true;
|
|
391
461
|
}
|
|
392
462
|
const state = await this.awaitInjectable(pid);
|
|
393
463
|
if (state !== 'ready') {
|
|
394
464
|
if (state === 'offline')
|
|
395
|
-
this.
|
|
465
|
+
this.degrade('offline', 'offline during delivery');
|
|
396
466
|
else if (state === 'modal')
|
|
397
|
-
this.
|
|
467
|
+
this.degrade('modal', `modal wedge — pane held a dialog for ` +
|
|
398
468
|
`${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
|
|
399
469
|
return false;
|
|
400
470
|
}
|
|
@@ -404,11 +474,25 @@ export class Monitor {
|
|
|
404
474
|
// Verify submission for THIS line even if stop() arrives mid-flight: the text
|
|
405
475
|
// is already in the composer and we want it submitted (at-least-once). A truly
|
|
406
476
|
// dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
|
|
407
|
-
for (let i = 0; i < MAX_ENTER_RETRIES;
|
|
477
|
+
for (let i = 0; i < MAX_ENTER_RETRIES;) {
|
|
408
478
|
await this.deps.sleep(POST_VERIFY_MS);
|
|
409
479
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
410
480
|
if (!capture.ok) {
|
|
411
|
-
this.
|
|
481
|
+
this.degrade('delivery', 'capture failed during injection verification');
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
// A dialog can appear after the initial send. Never let a verification
|
|
485
|
+
// retry confirm it. Wait under the same bounded modal policy as initial
|
|
486
|
+
// injection, then re-capture immediately before considering Enter.
|
|
487
|
+
if (looksModal(capture.pane)) {
|
|
488
|
+
const state = await this.awaitInjectable(pid);
|
|
489
|
+
if (state === 'ready')
|
|
490
|
+
continue;
|
|
491
|
+
if (state === 'offline')
|
|
492
|
+
this.degrade('offline', 'offline during injection verification');
|
|
493
|
+
else if (state === 'modal')
|
|
494
|
+
this.degrade('modal', `modal wedge during injection verification — ` +
|
|
495
|
+
`no Enter sent for ${MODAL_GIVE_UP_MS / 1000}s`);
|
|
412
496
|
return false;
|
|
413
497
|
}
|
|
414
498
|
if (!stillInComposer(capture.pane, line)) {
|
|
@@ -416,11 +500,13 @@ export class Monitor {
|
|
|
416
500
|
break;
|
|
417
501
|
}
|
|
418
502
|
await this.deps.tmux.sendKey(this.name, 'Enter');
|
|
503
|
+
i++;
|
|
419
504
|
}
|
|
420
505
|
if (!delivered) {
|
|
421
|
-
this.
|
|
506
|
+
this.degrade('delivery', 'injection unverified');
|
|
422
507
|
return false;
|
|
423
508
|
}
|
|
509
|
+
this.recover('delivery', 'modal');
|
|
424
510
|
// The wake landed and a turn started; observe how that turn terminates so a
|
|
425
511
|
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
426
512
|
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
@@ -442,7 +528,7 @@ export class Monitor {
|
|
|
442
528
|
return; // loop marks offline
|
|
443
529
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
444
530
|
if (!capture.ok) {
|
|
445
|
-
this.
|
|
531
|
+
this.degrade('delivery', 'capture failed during turn observation');
|
|
446
532
|
return;
|
|
447
533
|
}
|
|
448
534
|
if (looksApiError(capture.pane)) {
|
|
@@ -459,14 +545,17 @@ export class Monitor {
|
|
|
459
545
|
}
|
|
460
546
|
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
461
547
|
recordTurn(outcome) {
|
|
548
|
+
if (outcome === 'inconclusive')
|
|
549
|
+
return; // no evidence either way; leave the streak
|
|
462
550
|
if (outcome === 'api-error')
|
|
463
551
|
this.apiErrorStreak++;
|
|
464
|
-
else
|
|
552
|
+
else
|
|
465
553
|
this.apiErrorStreak = 0;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
554
|
+
if (this.apiErrorStreak >= this.turnFailThreshold)
|
|
555
|
+
this.degrade('turns-failing', 'turns failing (api error)');
|
|
556
|
+
else if (outcome === 'completed')
|
|
557
|
+
// A turn that ran to the end is the ONLY thing that clears this.
|
|
558
|
+
this.recover('turns-failing');
|
|
470
559
|
}
|
|
471
560
|
/**
|
|
472
561
|
* Reset the composer to empty before typing a wake. Without this, any
|
|
@@ -500,7 +589,7 @@ export class Monitor {
|
|
|
500
589
|
}
|
|
501
590
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
502
591
|
if (!capture.ok) {
|
|
503
|
-
this.
|
|
592
|
+
this.degrade('delivery', 'capture failed while checking session readiness');
|
|
504
593
|
await this.deps.sleep(MODAL_RETRY_MS);
|
|
505
594
|
continue;
|
|
506
595
|
}
|
|
@@ -592,15 +681,41 @@ export class Monitor {
|
|
|
592
681
|
this.deps.log(`[${this.name}] monitor: failed to persist state: ${msg(e)}`);
|
|
593
682
|
}
|
|
594
683
|
}
|
|
595
|
-
|
|
684
|
+
/** Record a degradation under its own cause and republish the status. */
|
|
685
|
+
degrade(cause, detail, level = 'degraded') {
|
|
686
|
+
const previous = this.causes.get(cause);
|
|
687
|
+
this.causes.set(cause, { level, detail, at: new Date(this.deps.now()).toISOString() });
|
|
688
|
+
this.writeStatus();
|
|
689
|
+
if (previous?.detail !== detail)
|
|
690
|
+
this.deps.log(`[${this.name}] monitor ${level}: ${cause} — ${detail}`);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Clear exactly the causes this recovery signal speaks to. Anything else
|
|
694
|
+
* stays: one successful poll must never be able to erase `turns failing`.
|
|
695
|
+
*/
|
|
696
|
+
recover(...causes) {
|
|
697
|
+
let changed = false;
|
|
698
|
+
for (const cause of causes)
|
|
699
|
+
changed = this.causes.delete(cause) || changed;
|
|
700
|
+
if (changed)
|
|
701
|
+
this.deps.log(`[${this.name}] monitor recovered: ${causes.join(', ')}`);
|
|
702
|
+
this.writeStatus();
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* One line per active cause, each dated; `armed` when there are none. Every
|
|
706
|
+
* line carries an ISO timestamp so an operator can tell a live status from a
|
|
707
|
+
* stale one left behind by a monitor that stopped writing.
|
|
708
|
+
*/
|
|
709
|
+
writeStatus() {
|
|
710
|
+
const lines = this.causes.size
|
|
711
|
+
? [...this.causes.entries()].map(([cause, e]) => `${e.level}: ${cause} at ${e.at} — ${e.detail}`)
|
|
712
|
+
: [`armed at ${new Date(this.deps.now()).toISOString()}`];
|
|
596
713
|
try {
|
|
597
|
-
writeFileSync(this.statusPath,
|
|
714
|
+
writeFileSync(this.statusPath, lines.join('\n') + '\n');
|
|
598
715
|
}
|
|
599
716
|
catch (e) {
|
|
600
717
|
this.deps.log(`[${this.name}] monitor: failed to write status: ${msg(e)}`);
|
|
601
718
|
}
|
|
602
|
-
if (!s.startsWith('armed'))
|
|
603
|
-
this.deps.log(`[${this.name}] monitor ${s}`);
|
|
604
719
|
}
|
|
605
720
|
}
|
|
606
721
|
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
|
+
}
|