@ours.network/fleet 0.9.4 → 0.9.5

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/dist/monitor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  // Code constants (not config — YAGNI, design §2).
@@ -242,12 +242,16 @@ function stillInComposer(pane, line) {
242
242
  }
243
243
  export class Monitor {
244
244
  name;
245
+ identity;
245
246
  cfg;
246
247
  deps;
247
248
  ep;
248
249
  statusPath;
249
250
  cursorPath;
251
+ statePath;
250
252
  cursor = null;
253
+ deliveredCursor = null;
254
+ pendingState = null;
251
255
  fatal = false;
252
256
  stopped = false;
253
257
  bootDeadline = 0;
@@ -258,16 +262,25 @@ export class Monitor {
258
262
  turnFailThreshold;
259
263
  constructor(o) {
260
264
  this.name = o.name;
265
+ this.identity = o.identity ?? o.name;
261
266
  this.cfg = o.cfg;
262
267
  this.deps = o.deps;
263
268
  this.ep = resolveEndpoint(o.deps.env);
264
269
  this.statusPath = join(o.agentDir, '.monitor-status');
265
270
  this.cursorPath = join(o.agentDir, '.notify-cursor');
271
+ this.statePath = join(o.agentDir, '.monitor-state.json');
266
272
  const n = o.cfg.turn_fail_threshold;
267
273
  this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
268
274
  }
269
- /** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
275
+ /** Resume the last delivered cursor; only a brand-new monitor primes at stream tip. */
270
276
  async prime() {
277
+ const persisted = this.readPersistedCursor();
278
+ if (persisted !== null) {
279
+ this.cursor = persisted;
280
+ this.deliveredCursor = persisted;
281
+ this.setStatus('armed');
282
+ return;
283
+ }
271
284
  try {
272
285
  const body = await this.doFetch('tip', LONGPOLL_TIMEOUT_MS);
273
286
  this.cursor = typeof body.cursor === 'number' ? body.cursor : 0;
@@ -280,7 +293,7 @@ export class Monitor {
280
293
  this.setStatus(`failed: ${e.message}`);
281
294
  }
282
295
  else {
283
- this.cursor = this.readPersistedCursor();
296
+ this.cursor = null;
284
297
  this.setStatus(`degraded: prime failed (${msg(e)})`);
285
298
  }
286
299
  }
@@ -291,6 +304,7 @@ export class Monitor {
291
304
  return;
292
305
  this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
293
306
  let backoff = 0;
307
+ const pending = [];
294
308
  while (!this.stopped) {
295
309
  if (!this.deps.isAlive(pid)) {
296
310
  this.setStatus('degraded: session offline');
@@ -314,12 +328,35 @@ export class Monitor {
314
328
  await this.deps.sleep(backoff);
315
329
  continue;
316
330
  }
317
- this.advance(body.cursor);
331
+ this.advance(body.cursor, false);
318
332
  const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
319
- if (batch.length === 0)
333
+ pending.push(...batch);
334
+ if (pending.length === 0) {
335
+ this.persistCursor();
320
336
  continue;
321
- await this.coalesce(batch);
322
- await this.deliver(pid, batch);
337
+ }
338
+ this.pendingState = {
339
+ count: pending.length,
340
+ eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
341
+ attempts: (this.pendingState?.attempts ?? 0) + 1,
342
+ };
343
+ this.persistState();
344
+ await this.coalesce(pending);
345
+ // Do not durably commit this cursor until the session explicitly accepts
346
+ // the wake. If delivery fails or the runner crashes, the daemon replays
347
+ // from the last committed cursor and the wake is attempted again.
348
+ let accepted = false;
349
+ try {
350
+ accepted = await this.deliver(pid, pending);
351
+ }
352
+ catch (e) {
353
+ this.setStatus(`degraded: delivery failed (${msg(e)})`);
354
+ }
355
+ if (accepted) {
356
+ pending.length = 0;
357
+ this.pendingState = null;
358
+ this.persistCursor();
359
+ }
323
360
  }
324
361
  }
325
362
  stop() {
@@ -336,12 +373,22 @@ export class Monitor {
336
373
  return;
337
374
  try {
338
375
  const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
339
- this.advance(more.cursor);
376
+ this.advance(more.cursor, false);
340
377
  batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
341
378
  }
342
379
  catch { /* no stragglers / abort — deliver what we have */ }
343
380
  }
344
381
  async deliver(pid, batch) {
382
+ const line = formatNotificationLine(batch);
383
+ if (this.deps.delivery) {
384
+ const result = await this.deps.delivery.submit(line);
385
+ if (!result.accepted) {
386
+ this.setStatus(`degraded: ACP prompt not accepted${result.detail ? ` (${result.detail})` : ''}`);
387
+ return false;
388
+ }
389
+ this.recordTurn('completed');
390
+ return true;
391
+ }
345
392
  const state = await this.awaitInjectable(pid);
346
393
  if (state !== 'ready') {
347
394
  if (state === 'offline')
@@ -349,9 +396,8 @@ export class Monitor {
349
396
  else if (state === 'modal')
350
397
  this.setStatus(`degraded: modal wedge — pane held a dialog for ` +
351
398
  `${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
352
- return; // events remain covered by unread.json / SessionStart backlog
399
+ return false;
353
400
  }
354
- const line = formatNotificationLine(batch);
355
401
  await this.clearComposer(); // start from an empty composer
356
402
  await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
357
403
  let delivered = false;
@@ -360,8 +406,12 @@ export class Monitor {
360
406
  // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
361
407
  for (let i = 0; i < MAX_ENTER_RETRIES; i++) {
362
408
  await this.deps.sleep(POST_VERIFY_MS);
363
- const pane = await safeCapture(this.deps.tmux, this.name);
364
- if (!stillInComposer(pane, line)) {
409
+ const capture = await safeCapture(this.deps.tmux, this.name);
410
+ if (!capture.ok) {
411
+ this.setStatus('degraded: capture failed during injection verification');
412
+ return false;
413
+ }
414
+ if (!stillInComposer(capture.pane, line)) {
365
415
  delivered = true;
366
416
  break;
367
417
  }
@@ -369,12 +419,13 @@ export class Monitor {
369
419
  }
370
420
  if (!delivered) {
371
421
  this.setStatus('degraded: injection unverified');
372
- return;
422
+ return false;
373
423
  }
374
424
  // The wake landed and a turn started; observe how that turn terminates so a
375
425
  // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
376
426
  // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
377
427
  await this.observeTurnOutcome(pid);
428
+ return true;
378
429
  }
379
430
  /**
380
431
  * Watch the pane until the just-triggered turn settles, then fold its outcome
@@ -389,12 +440,16 @@ export class Monitor {
389
440
  return; // shutting down — leave status
390
441
  if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
391
442
  return; // loop marks offline
392
- const pane = await safeCapture(this.deps.tmux, this.name);
393
- if (looksApiError(pane)) {
443
+ const capture = await safeCapture(this.deps.tmux, this.name);
444
+ if (!capture.ok) {
445
+ this.setStatus('degraded: capture failed during turn observation');
446
+ return;
447
+ }
448
+ if (looksApiError(capture.pane)) {
394
449
  this.recordTurn('api-error');
395
450
  return;
396
451
  }
397
- if (!looksRunning(pane)) {
452
+ if (!looksRunning(capture.pane)) {
398
453
  this.recordTurn('completed');
399
454
  return;
400
455
  }
@@ -443,8 +498,13 @@ export class Monitor {
443
498
  await this.deps.sleep(this.bootDeadline - now);
444
499
  continue;
445
500
  }
446
- const pane = await safeCapture(this.deps.tmux, this.name);
447
- if (!looksModal(pane))
501
+ const capture = await safeCapture(this.deps.tmux, this.name);
502
+ if (!capture.ok) {
503
+ this.setStatus('degraded: capture failed while checking session readiness');
504
+ await this.deps.sleep(MODAL_RETRY_MS);
505
+ continue;
506
+ }
507
+ if (!looksModal(capture.pane))
448
508
  return 'ready';
449
509
  if (modalWaits++ >= MAX_MODAL_WAITS)
450
510
  return 'modal';
@@ -457,7 +517,7 @@ export class Monitor {
457
517
  const timer = this.deps.timers.set(() => ctrl.abort(), holdMs);
458
518
  let resp;
459
519
  try {
460
- resp = await this.deps.fetch(`${this.ep.url(this.name)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
520
+ resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
461
521
  }
462
522
  finally {
463
523
  this.deps.timers.clear(timer);
@@ -469,16 +529,22 @@ export class Monitor {
469
529
  throw new Error(`daemon returned HTTP ${resp.status}`);
470
530
  return resp.json();
471
531
  }
472
- advance(cursor) {
532
+ advance(cursor, persist = true) {
473
533
  if (typeof cursor === 'number' && cursor !== this.cursor) {
474
534
  this.cursor = cursor;
475
- this.persistCursor();
535
+ if (persist)
536
+ this.persistCursor();
537
+ else
538
+ this.persistState();
476
539
  }
477
540
  }
478
541
  persistCursor() {
479
542
  try {
480
- if (this.cursor !== null)
543
+ if (this.cursor !== null) {
544
+ this.deliveredCursor = this.cursor;
481
545
  writeFileSync(this.cursorPath, `${this.cursor}\n`);
546
+ this.persistState();
547
+ }
482
548
  }
483
549
  catch (e) {
484
550
  this.deps.log(`[${this.name}] monitor: failed to persist cursor: ${msg(e)}`);
@@ -486,15 +552,46 @@ export class Monitor {
486
552
  }
487
553
  readPersistedCursor() {
488
554
  try {
555
+ if (existsSync(this.statePath)) {
556
+ const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
557
+ if ((state.identity !== undefined && state.identity !== this.identity)
558
+ || (state.profileKey !== undefined && state.profileKey !== this.ep.origin))
559
+ return null;
560
+ if (typeof state.deliveredCursor === 'number') {
561
+ this.deliveredCursor = state.deliveredCursor;
562
+ return state.deliveredCursor;
563
+ }
564
+ }
489
565
  if (!existsSync(this.cursorPath))
490
566
  return null;
491
567
  const n = parseInt(readFileSync(this.cursorPath, 'utf8').trim(), 10);
568
+ if (Number.isFinite(n))
569
+ this.deliveredCursor = n;
492
570
  return Number.isFinite(n) ? n : null;
493
571
  }
494
572
  catch {
495
573
  return null;
496
574
  }
497
575
  }
576
+ /** Atomically persist body-free delivery state; restart always resumes from deliveredCursor. */
577
+ persistState() {
578
+ try {
579
+ const tmp = `${this.statePath}.${process.pid}.tmp`;
580
+ writeFileSync(tmp, JSON.stringify({
581
+ version: 1,
582
+ identity: this.identity,
583
+ profileKey: this.ep.origin,
584
+ observedCursor: this.cursor,
585
+ deliveredCursor: this.deliveredCursor,
586
+ pending: this.pendingState,
587
+ updatedAt: new Date(this.deps.now()).toISOString(),
588
+ }, null, 2) + '\n', { mode: 0o600 });
589
+ renameSync(tmp, this.statePath);
590
+ }
591
+ catch (e) {
592
+ this.deps.log(`[${this.name}] monitor: failed to persist state: ${msg(e)}`);
593
+ }
594
+ }
498
595
  setStatus(s) {
499
596
  try {
500
597
  writeFileSync(this.statusPath, `${s}\n`);
@@ -511,10 +608,10 @@ export function createMonitor(o) {
511
608
  }
512
609
  async function safeCapture(tmux, name) {
513
610
  try {
514
- return await tmux.capture(name);
611
+ return { ok: true, pane: await tmux.capture(name) };
515
612
  }
516
613
  catch {
517
- return '';
614
+ return { ok: false, pane: '' };
518
615
  }
519
616
  }
520
617
  const msg = (e) => e?.message ?? String(e);
package/dist/runner.js CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
5
5
  import { agentDir, home, stateRoot } from './paths.js';
6
- import { loadConfig, findRole } from './config.js';
6
+ import { loadConfig, findRole, 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';
@@ -11,6 +11,9 @@ import { realExec, shq } from './exec.js';
11
11
  import { resolveIsolation } from './isolation/policy.js';
12
12
  import { selectIsolationBackend } from './isolation/registry.js';
13
13
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
+ import { AcpSession } from './session/acp.js';
15
+ import { RoleControlServer } from './session/control.js';
16
+ import { TmuxSession } from './session/tmux.js';
14
17
  const defaultDeps = () => ({
15
18
  tmux: new Tmux(),
16
19
  exec: realExec,
@@ -182,10 +185,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
182
185
  writeFileSync(bootedFile, '');
183
186
  const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
184
187
  const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
185
- const launch = adapter.buildLaunch(role, mode, { sessionId }, prep);
188
+ const sessionBackend = role.session ?? 'tmux';
189
+ const launch = sessionBackend === 'acp'
190
+ ? (() => {
191
+ if (!adapter.buildAcpLaunch)
192
+ throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
193
+ return adapter.buildAcpLaunch(role, prep);
194
+ })()
195
+ : adapter.buildLaunch(role, mode, { sessionId }, prep);
186
196
  // Isolation is additive: only roles that declare `isolation:` are wrapped. The
187
197
  // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
188
- let paneArgv = launch.argv;
198
+ let wrappedArgv = launch.argv;
189
199
  if (role.isolation) {
190
200
  const addDirs = role.harness === 'codex'
191
201
  ? (role.harness_options?.add_dirs ?? [])
@@ -204,14 +214,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
204
214
  deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
205
215
  rmSync(degradedMarker, { force: true });
206
216
  }
207
- paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
217
+ wrappedArgv = sel.backend.wrap(launch.argv, policy, ctx);
208
218
  // Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
209
219
  // (§5.4). Applies even when the sandbox degraded to none.
210
220
  const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
211
221
  for (const w of warnings)
212
222
  deps.log(`[${name}] WARNING ${w}`);
213
223
  if (rprefix.length)
214
- paneArgv = [...rprefix, ...paneArgv];
224
+ wrappedArgv = [...rprefix, ...wrappedArgv];
215
225
  }
216
226
  // Start-stagger: space this launch at least start_stagger_ms after the previous
217
227
  // agent launch across the whole host, so a burst of boots (systemd starts every
@@ -232,34 +242,79 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
232
242
  // (backlog before the tip is the SessionStart hook's job). Disabled roles keep
233
243
  // the legacy in-session watch. Temp snapshots predating `monitor:` are treated
234
244
  // as disabled (monitor may be undefined on an old role.yaml).
245
+ const resolvedMonitorDeps = monitorDeps(deps, role.env);
235
246
  const monitor = role.monitor?.enabled ? deps.createMonitor({
236
- name, agentDir: dir, cfg: role.monitor,
237
- deps: monitorDeps(deps, role.env),
247
+ name, identity: role.identity, agentDir: dir, cfg: role.monitor,
248
+ deps: resolvedMonitorDeps,
238
249
  }) : null;
239
250
  if (monitor)
240
251
  await monitor.prime();
241
252
  rmSync(exitFile, { force: true });
242
- await deps.tmux.kill(name);
243
- await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
244
- let pid = null;
245
- for (let i = 0; i < 40 && pid === null; i++) {
246
- pid = await deps.tmux.panePid(name);
247
- if (pid === null)
248
- await deps.sleep(250);
253
+ let pid;
254
+ let sessionHandle;
255
+ let acpSession;
256
+ let control;
257
+ if (sessionBackend === 'acp') {
258
+ acpSession = await AcpSession.start({
259
+ name,
260
+ argv: wrappedArgv,
261
+ cwd: runCwd,
262
+ env: { ...launch.env, ...(role.env ?? {}) },
263
+ stateDir: dir,
264
+ mode,
265
+ permissions: role.permissions ?? resolvePermissions(undefined, undefined),
266
+ log: deps.log,
267
+ });
268
+ pid = acpSession.pid;
269
+ sessionHandle = acpSession;
270
+ control = new RoleControlServer(dir, acpSession, deps.log);
271
+ await control.start();
272
+ resolvedMonitorDeps.delivery = {
273
+ submit: async (text) => {
274
+ const result = await acpSession.submitPrompt(text);
275
+ return { accepted: result.accepted, detail: result.detail };
276
+ },
277
+ };
278
+ const firstPrompt = mode === 'fresh'
279
+ ? `Read and follow ${join(dir, 'briefing.md')} now.`
280
+ : adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
281
+ const started = await acpSession.submitPrompt(firstPrompt);
282
+ if (!started.accepted) {
283
+ monitor?.stop();
284
+ await control.close();
285
+ await acpSession.close();
286
+ throw new Error(`[${name}] ACP session rejected startup prompt: ${started.detail ?? started.outcome}`);
287
+ }
288
+ }
289
+ else {
290
+ await deps.tmux.kill(name);
291
+ await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, wrappedArgv));
292
+ let panePid = null;
293
+ for (let i = 0; i < 40 && panePid === null; i++) {
294
+ panePid = await deps.tmux.panePid(name);
295
+ if (panePid === null)
296
+ await deps.sleep(250);
297
+ }
298
+ if (panePid === null)
299
+ throw new Error(`[${name}] could not resolve tmux pane pid`);
300
+ pid = panePid;
301
+ sessionHandle = new TmuxSession(name, pid, deps.tmux, deps.isAlive);
249
302
  }
250
- if (pid === null)
251
- throw new Error(`[${name}] could not resolve tmux pane pid`);
252
- deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} mode=${mode}`);
303
+ deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
253
304
  // The monitor loop lives exactly as long as the session: it starts once the
254
305
  // pane pid is known and is stopped when that pid dies (task dies with runner).
255
306
  const monitorLoop = monitor?.run(pid);
256
307
  const start = deps.now();
257
- while (deps.isAlive(pid))
308
+ while (sessionHandle.isAlive())
258
309
  await deps.sleep(2000);
259
310
  if (monitor) {
260
311
  monitor.stop();
261
312
  await monitorLoop;
262
313
  }
314
+ if (control)
315
+ await control.close();
316
+ if (acpSession)
317
+ await acpSession.close();
263
318
  const elapsed = (deps.now() - start) / 1000;
264
319
  const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
265
320
  const rotate = (why) => {
@@ -0,0 +1,49 @@
1
+ import type { CommonPermissions } from '../config.js';
2
+ import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
3
+ export interface AcpSessionOptions {
4
+ name: string;
5
+ argv: string[];
6
+ cwd: string;
7
+ env: Record<string, string>;
8
+ stateDir: string;
9
+ mode: 'fresh' | 'resume';
10
+ permissions: CommonPermissions;
11
+ log(line: string): void;
12
+ }
13
+ /**
14
+ * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
15
+ * human/automation attachment happens through the fleet role-control protocol.
16
+ */
17
+ export declare class AcpSession implements SessionHandle {
18
+ private readonly options;
19
+ readonly backend: "acp";
20
+ readonly pid: number;
21
+ private readonly child;
22
+ private readonly events;
23
+ private readonly sessionFile;
24
+ private readonly pendingPermissions;
25
+ private connection;
26
+ private sessionId?;
27
+ private readiness;
28
+ private lastError?;
29
+ private promptTail;
30
+ private capabilities?;
31
+ private controllerCount;
32
+ private constructor();
33
+ static start(options: AcpSessionOptions): Promise<AcpSession>;
34
+ isAlive(): boolean;
35
+ snapshot(): SessionSnapshot;
36
+ submitPrompt(text: string): Promise<TurnResult>;
37
+ interrupt(): Promise<void>;
38
+ respondPermission(permissionId: string, optionId: string): boolean;
39
+ eventsSince(seq: number): SessionEvent[];
40
+ subscribe(listener: (event: SessionEvent) => void): () => void;
41
+ setControllerAttached(attached: boolean): void;
42
+ close(): Promise<void>;
43
+ private initialize;
44
+ private runPrompt;
45
+ private requestPermission;
46
+ private withinAutomaticBoundary;
47
+ private recordUpdate;
48
+ private fail;
49
+ }