@ours.network/fleet 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +184 -0
  2. package/dist/briefing.js +10 -0
  3. package/dist/cli.js +404 -1
  4. package/dist/config.d.ts +18 -2
  5. package/dist/config.js +67 -3
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +111 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/harness/claude-code.js +5 -0
  10. package/dist/harness/types.d.ts +6 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +114 -2
  21. package/dist/owner-channel/channel.js +622 -43
  22. package/dist/owner-channel/notices.d.ts +21 -0
  23. package/dist/owner-channel/notices.js +66 -0
  24. package/dist/owner-channel/state.d.ts +34 -0
  25. package/dist/owner-channel/state.js +148 -1
  26. package/dist/owner-channel/tasks.d.ts +62 -0
  27. package/dist/owner-channel/tasks.js +246 -0
  28. package/dist/resolved-plan.js +11 -0
  29. package/dist/runner.js +87 -7
  30. package/dist/session/acp.d.ts +5 -2
  31. package/dist/session/acp.js +83 -25
  32. package/dist/session/arbiter.d.ts +42 -0
  33. package/dist/session/arbiter.js +72 -0
  34. package/dist/session/control.d.ts +12 -1
  35. package/dist/session/control.js +56 -3
  36. package/dist/session/types.d.ts +26 -2
  37. package/dist/session/types.js +5 -2
  38. package/dist/spawn.js +1 -0
  39. package/dist/supervisor/systemd.js +12 -2
  40. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -19,6 +19,8 @@ import { classifyShellStatus } from './session/types.js';
19
19
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
20
20
  import { rotateWorklog } from './worklog.js';
21
21
  import { OwnerChannel } from './owner-channel/channel.js';
22
+ import { RoleTurnArbiter } from './session/arbiter.js';
23
+ import { ScheduledLoopManager, } from './loops/manager.js';
22
24
  const defaultDeps = () => ({
23
25
  tmux: new Tmux(),
24
26
  exec: realExec,
@@ -418,6 +420,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
418
420
  let monitorLoop;
419
421
  let acpStartupComplete = false;
420
422
  let ownerChannel;
423
+ let loopManager;
424
+ let arbiter;
425
+ let reloadLoopConfig;
426
+ let loopGeneration = JSON.stringify((role.loops ?? []).map(loop => [
427
+ loop.name, loop.definitionHash, loop.promptHash,
428
+ ]));
421
429
  if (sessionBackend === 'acp') {
422
430
  const perms = role.permissions ?? resolvePermissions(undefined, undefined);
423
431
  // Say once, at startup, that this role will decide permission requests by
@@ -434,10 +442,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
434
442
  stateDir: dir,
435
443
  mode,
436
444
  permissions: perms,
445
+ modeId: adapter.acpPermissionModeId?.(role),
437
446
  log: deps.log,
438
447
  });
439
448
  pid = acpSession.pid;
440
- sessionHandle = acpSession;
449
+ arbiter = new RoleTurnArbiter(acpSession);
450
+ sessionHandle = arbiter;
441
451
  unsubscribeRecovery = acpSession.subscribe(event => {
442
452
  if (event.kind !== 'error' || !event.text)
443
453
  return;
@@ -445,7 +455,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
445
455
  if (evidence)
446
456
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
447
457
  });
448
- control = new RoleControlServer(dir, acpSession, deps.log);
458
+ control = new RoleControlServer(dir, arbiter, deps.log);
449
459
  await control.start();
450
460
  resolvedMonitorDeps.delivery = {
451
461
  // A wake is only delivered when its turn TERMINATES successfully. A
@@ -457,7 +467,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
457
467
  // steer into the live turn instead; after it completes, honor the
458
468
  // configured interrupt policy normally.
459
469
  const interrupt = options?.interrupt === true && acpStartupComplete;
460
- const result = await acpSession.submitPrompt(text, { ...options, interrupt, steer: true });
470
+ const result = await arbiter.submitPrompt(text, {
471
+ ...options, interrupt, steer: true,
472
+ ...(interrupt ? { interruptSource: 'fleet-monitor' } : {}),
473
+ origin: { kind: 'fleet-monitor' },
474
+ });
461
475
  const steered = result.accepted
462
476
  && (result.detail === 'injected' || result.detail === 'startedNewTurn');
463
477
  return {
@@ -473,7 +487,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
473
487
  // Wait for the first turn's TERMINAL result. An agent that accepts the
474
488
  // startup prompt and then refuses it has not started; logging the role as
475
489
  // up would hide a role that never read its briefing.
476
- const starting = acpSession.submitPrompt(firstPrompt);
490
+ const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
477
491
  // Monitoring starts immediately. The delivery adapter above downgrades
478
492
  // interruption to steering until this startup turn reaches a terminal
479
493
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
@@ -507,7 +521,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
507
521
  ownerChannel = deps.createOwnerChannel({
508
522
  role: name,
509
523
  config: role.owner_channel,
510
- session: acpSession,
524
+ session: arbiter,
511
525
  stateDir: dir,
512
526
  env: role.env,
513
527
  log: deps.log,
@@ -525,6 +539,48 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
525
539
  throw new Error(`[${name}] owner channel failed to start: `
526
540
  + `${error?.message ?? String(error)}`);
527
541
  }
542
+ control.setOwnerChannel(ownerChannel);
543
+ }
544
+ reloadLoopConfig = async () => {
545
+ const nextRole = findRole(loadConfig(configPath), name);
546
+ const definitions = nextRole.loops ?? [];
547
+ const generation = JSON.stringify(definitions.map(loop => [
548
+ loop.name, loop.definitionHash, loop.promptHash,
549
+ ]));
550
+ if (generation === loopGeneration && (loopManager || !definitions.length))
551
+ return { changed: false, loops: definitions.length };
552
+ if (!loopManager && definitions.length) {
553
+ loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
554
+ now: deps.now,
555
+ setTimer: (callback, ms) => setTimeout(callback, ms),
556
+ clearTimer: timer => clearTimeout(timer),
557
+ log: deps.log,
558
+ });
559
+ control.setLoopManager(loopManager);
560
+ loopManager.start();
561
+ }
562
+ else {
563
+ loopManager?.reconcile(definitions);
564
+ }
565
+ loopGeneration = generation;
566
+ deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
567
+ return { changed: true, loops: definitions.length };
568
+ };
569
+ control.setConfigReloader(reloadLoopConfig);
570
+ if (role.loops?.length) {
571
+ try {
572
+ loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
573
+ now: deps.now,
574
+ setTimer: (callback, ms) => setTimeout(callback, ms),
575
+ clearTimer: timer => clearTimeout(timer),
576
+ log: deps.log,
577
+ });
578
+ control.setLoopManager(loopManager);
579
+ loopManager.start();
580
+ }
581
+ catch (error) {
582
+ deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
583
+ }
528
584
  }
529
585
  }
530
586
  else {
@@ -546,10 +602,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
546
602
  // pane pid is known and is stopped when that pid dies (task dies with runner).
547
603
  monitorLoop ??= monitor?.run(pid);
548
604
  const start = deps.now();
549
- while (sessionHandle.isAlive())
605
+ let nextLoopReloadAt = deps.now() + 30_000;
606
+ let lastReloadError = '';
607
+ while (sessionHandle.isAlive()) {
550
608
  await deps.sleep(2000);
551
- if (ownerChannel)
609
+ const now = deps.now();
610
+ if (reloadLoopConfig && now >= nextLoopReloadAt) {
611
+ nextLoopReloadAt = now + 30_000;
612
+ try {
613
+ await reloadLoopConfig();
614
+ lastReloadError = '';
615
+ }
616
+ catch (error) {
617
+ const message = error?.message ?? String(error);
618
+ if (message !== lastReloadError)
619
+ deps.log(`[${name}] scheduled loop config reload rejected: ${message}`);
620
+ lastReloadError = message;
621
+ }
622
+ }
623
+ }
624
+ if (loopManager) {
625
+ control?.setLoopManager(undefined);
626
+ await loopManager.stop();
627
+ }
628
+ control?.setConfigReloader(undefined);
629
+ if (ownerChannel) {
630
+ control?.setOwnerChannel(undefined);
552
631
  await ownerChannel.close();
632
+ }
553
633
  if (monitor) {
554
634
  monitor.stop();
555
635
  await monitorLoop;
@@ -1,5 +1,5 @@
1
1
  import type { CommonPermissions } from '../config.js';
2
- import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnOutcome, TurnResult } from './types.js';
2
+ import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
3
3
  export interface AcpSessionOptions {
4
4
  name: string;
5
5
  argv: string[];
@@ -8,6 +8,8 @@ export interface AcpSessionOptions {
8
8
  stateDir: string;
9
9
  mode: 'fresh' | 'resume';
10
10
  permissions: CommonPermissions;
11
+ /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
12
+ modeId?: string;
11
13
  log(line: string): void;
12
14
  }
13
15
  /**
@@ -50,7 +52,8 @@ export declare class AcpSession implements SessionHandle {
50
52
  */
51
53
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
52
54
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
53
- interrupt(): Promise<void>;
55
+ interrupt(source?: TurnCancellationSource): Promise<void>;
56
+ private cancelActive;
54
57
  respondPermission(permissionId: string, optionId: string): boolean;
55
58
  eventsSince(seq: number): SessionEvent[];
56
59
  subscribe(listener: (event: SessionEvent) => void): () => void;
@@ -118,24 +118,24 @@ export class AcpSession {
118
118
  if (!this.sessionId || !this.isAlive())
119
119
  throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
120
120
  if (options.interrupt)
121
- await this.interrupt();
121
+ await this.cancelActive(options.interruptSource ?? 'local-console');
122
122
  // Interrupting delivery must still use steering when supported. With no
123
123
  // live turn, the extension starts one and acknowledges `startedNewTurn`
124
124
  // immediately; a normal session/prompt would keep the monitor blocked until
125
125
  // the entire wake-triggered turn terminated.
126
126
  if (options.steer && this.steeringSupported) {
127
127
  const promptId = randomUUID();
128
- return { promptId, queuedBehind: 0, completion: this.steerPrompt(text) };
128
+ return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
129
129
  }
130
130
  const promptId = randomUUID();
131
131
  const queuedBehind = this.queueDepth++;
132
- const run = this.promptTail.then(() => this.runPrompt(text, promptId));
132
+ const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
133
133
  this.promptTail = run.then(() => undefined, () => undefined);
134
134
  const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
135
135
  this.queueDepth = Math.max(0, this.queueDepth - 1);
136
136
  return turnResult(false, 'failed', error?.message ?? String(error));
137
137
  });
138
- return { promptId, queuedBehind, completion };
138
+ return { promptId, queuedBehind, completion, origin: options.origin };
139
139
  }
140
140
  async submitPrompt(text, options = {}) {
141
141
  try {
@@ -147,10 +147,24 @@ export class AcpSession {
147
147
  throw error;
148
148
  }
149
149
  }
150
- async interrupt() {
150
+ async interrupt(source = 'local-console') {
151
+ await this.cancelActive(source);
152
+ }
153
+ async cancelActive(source) {
151
154
  if (!this.sessionId)
152
155
  return;
153
- await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
156
+ const active = this.activeTurn;
157
+ const previousSource = active?.cancellationSource;
158
+ if (active && (source === 'owner' || source === 'local-console' || !previousSource))
159
+ active.cancellationSource = source;
160
+ try {
161
+ await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
162
+ }
163
+ catch (error) {
164
+ if (this.activeTurn === active && active?.cancellationSource === source)
165
+ active.cancellationSource = previousSource;
166
+ throw error;
167
+ }
154
168
  for (const pending of this.pendingPermissions.values())
155
169
  pending.resolve({ outcome: { outcome: 'cancelled' } });
156
170
  this.pendingPermissions.clear();
@@ -164,6 +178,7 @@ export class AcpSession {
164
178
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
165
179
  this.events.emit('permission', {
166
180
  turnId: this.activeTurn?.id,
181
+ origin: this.activeTurn?.origin,
167
182
  permissionId,
168
183
  status: 'completed',
169
184
  decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
@@ -236,31 +251,55 @@ export class AcpSession {
236
251
  this.sessionId = created.sessionId;
237
252
  }
238
253
  writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
254
+ // Deliver the configured permission mode whichever way the session came up
255
+ // (new, resume or load) — the launch flag never reaches an ACP agent. A
256
+ // refusal is loud but never fatal: the session then simply runs at the
257
+ // agent's own default.
258
+ if (this.options.modeId) {
259
+ try {
260
+ await this.connection.agent.request(acp.methods.agent.session.setMode, {
261
+ sessionId: this.sessionId,
262
+ modeId: this.options.modeId,
263
+ });
264
+ }
265
+ catch (e) {
266
+ this.options.log(`[${this.options.name}] acp: session/set_mode "${this.options.modeId}" failed ` +
267
+ `(${e instanceof Error ? e.message : String(e)}) — session runs at the agent default permission mode`);
268
+ }
269
+ }
239
270
  this.readiness = 'idle';
240
271
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
241
272
  }
242
- async runPrompt(text, turnId = randomUUID()) {
273
+ async runPrompt(text, turnId = randomUUID(), origin) {
243
274
  if (!this.sessionId || !this.isAlive())
244
275
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
245
276
  this.readiness = 'running';
246
- this.activeTurn = { id: turnId, output: '' };
247
- this.events.emit('state', { turnId, status: 'running' });
277
+ this.activeTurn = { id: turnId, output: '', origin };
278
+ this.events.emit('state', { turnId, status: 'running', origin });
248
279
  try {
249
280
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
250
281
  sessionId: this.sessionId,
251
282
  prompt: [{ type: 'text', text }],
252
283
  });
253
284
  this.readiness = 'idle';
254
- this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
285
+ this.events.emit('turn_stop', {
286
+ turnId, stopReason: response.stopReason, origin,
287
+ cancellationSource: this.activeTurn?.id === turnId
288
+ ? this.activeTurn.cancellationSource : undefined,
289
+ });
255
290
  this.events.emit('state', { status: 'idle' });
256
291
  // The prompt was accepted either way — the agent answered. Whether the
257
292
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
258
- return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
293
+ return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
259
294
  }
260
295
  catch (error) {
261
- this.lastError = error?.message ?? String(error);
296
+ const detail = error?.message ?? String(error);
297
+ this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
262
298
  this.readiness = this.isAlive() ? 'idle' : 'failed';
263
- this.events.emit('error', { turnId, text: this.lastError });
299
+ this.events.emit('error', {
300
+ turnId, origin,
301
+ text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
302
+ });
264
303
  if (this.isAlive())
265
304
  this.events.emit('state', { status: 'idle' });
266
305
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
@@ -320,12 +359,17 @@ export class AcpSession {
320
359
  this.readiness = 'awaiting_permission';
321
360
  this.events.emit('permission', {
322
361
  turnId: this.activeTurn?.id,
362
+ origin: this.activeTurn?.origin,
323
363
  permissionId,
324
- toolCallId: params.toolCall.toolCallId,
325
- title: params.toolCall.title ?? 'Permission requested',
364
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
365
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
366
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
367
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
326
368
  status: 'pending',
327
369
  options: params.options.map(option => ({
328
- optionId: option.optionId, name: option.name, kind: option.kind,
370
+ optionId: option.optionId,
371
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
372
+ kind: option.kind,
329
373
  })),
330
374
  });
331
375
  return new Promise(resolve => {
@@ -341,16 +385,23 @@ export class AcpSession {
341
385
  const settled = option ? decision : 'cancelled';
342
386
  this.events.emit('permission', {
343
387
  turnId: this.activeTurn?.id,
388
+ origin: this.activeTurn?.origin,
344
389
  permissionId: randomUUID(),
345
- toolCallId: params.toolCall.toolCallId,
346
- title: params.toolCall.title ?? 'Permission requested',
390
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
391
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
347
392
  status: 'completed',
348
393
  decision: settled,
349
394
  decisionSource: 'automatic',
350
395
  policy,
351
396
  reason: option ? reason : `${reason}, but the agent offered no matching option`,
352
397
  optionId: option?.optionId,
353
- options: params.options.map(o => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
398
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
399
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
400
+ options: params.options.map(o => ({
401
+ optionId: o.optionId,
402
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? o.kind : o.name,
403
+ kind: o.kind,
404
+ })),
354
405
  });
355
406
  return option
356
407
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
@@ -373,34 +424,41 @@ export class AcpSession {
373
424
  });
374
425
  }
375
426
  recordUpdate(update) {
427
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
376
428
  switch (update.sessionUpdate) {
377
429
  case 'agent_message_chunk':
378
430
  if (this.activeTurn && update.content.type === 'text')
379
431
  this.activeTurn.output += update.content.text;
380
432
  this.events.emit('agent_text', {
381
433
  turnId: this.activeTurn?.id,
382
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
434
+ origin: this.activeTurn?.origin,
435
+ text: scheduled ? '[scheduled-loop output redacted]'
436
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
383
437
  });
384
438
  break;
385
439
  case 'agent_thought_chunk':
386
440
  this.events.emit('thought', {
387
441
  turnId: this.activeTurn?.id,
388
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
442
+ origin: this.activeTurn?.origin,
443
+ text: scheduled ? '[scheduled-loop thought redacted]'
444
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
389
445
  });
390
446
  break;
391
447
  case 'tool_call':
392
448
  this.events.emit('tool_call', {
393
449
  turnId: this.activeTurn?.id,
394
- toolCallId: update.toolCallId,
395
- title: update.title,
450
+ origin: this.activeTurn?.origin,
451
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
452
+ title: scheduled ? 'scheduled-loop tool' : update.title,
396
453
  status: update.status,
397
454
  });
398
455
  break;
399
456
  case 'tool_call_update':
400
457
  this.events.emit('tool_update', {
401
458
  turnId: this.activeTurn?.id,
402
- toolCallId: update.toolCallId,
403
- title: update.title ?? undefined,
459
+ origin: this.activeTurn?.origin,
460
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
461
+ title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
404
462
  status: update.status ?? undefined,
405
463
  });
406
464
  break;
@@ -0,0 +1,42 @@
1
+ import type { ExitRecord, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
2
+ export type ScheduledAttempt = {
3
+ state: 'started';
4
+ queued: QueuedPrompt;
5
+ } | {
6
+ state: 'skipped_busy';
7
+ } | {
8
+ state: 'unavailable';
9
+ error: string;
10
+ };
11
+ /**
12
+ * One in-process admission boundary for every producer targeting a role.
13
+ * Scheduled callers get an atomic idle recheck plus submission; ordinary
14
+ * producers retain ACP queue semantics while making their unsettled claim
15
+ * visible before another producer can inspect idle state.
16
+ */
17
+ export declare class RoleTurnArbiter implements SessionHandle {
18
+ private readonly session;
19
+ readonly backend: import("../config.js").SessionBackendId;
20
+ readonly pid: number;
21
+ private tail;
22
+ private unsettled;
23
+ private stopping;
24
+ constructor(session: SessionHandle);
25
+ private exclusive;
26
+ private track;
27
+ queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
28
+ submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
29
+ tryScheduled(text: string, origin: Extract<PromptOrigin, {
30
+ kind: 'scheduled-loop';
31
+ }>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
32
+ stopScheduledAdmission(): void;
33
+ isAlive(): boolean;
34
+ snapshot(): SessionSnapshot;
35
+ interrupt(source?: TurnCancellationSource): Promise<void>;
36
+ respondPermission(permissionId: string, optionId: string): boolean;
37
+ eventsSince(seq: number): SessionEvent[];
38
+ subscribe(listener: (event: SessionEvent) => void): () => void;
39
+ setControllerAttached(attached: boolean): void;
40
+ exitResult(): ExitRecord | null;
41
+ close(): Promise<void>;
42
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One in-process admission boundary for every producer targeting a role.
3
+ * Scheduled callers get an atomic idle recheck plus submission; ordinary
4
+ * producers retain ACP queue semantics while making their unsettled claim
5
+ * visible before another producer can inspect idle state.
6
+ */
7
+ export class RoleTurnArbiter {
8
+ session;
9
+ backend;
10
+ pid;
11
+ tail = Promise.resolve();
12
+ unsettled = 0;
13
+ stopping = false;
14
+ constructor(session) {
15
+ this.session = session;
16
+ this.backend = session.backend;
17
+ this.pid = session.pid;
18
+ }
19
+ exclusive(operation) {
20
+ const run = this.tail.then(operation);
21
+ this.tail = run.then(() => undefined, () => undefined);
22
+ return run;
23
+ }
24
+ track(queued) {
25
+ this.unsettled++;
26
+ const completion = queued.completion.finally(() => { this.unsettled = Math.max(0, this.unsettled - 1); });
27
+ return { ...queued, completion };
28
+ }
29
+ queuePrompt(text, options = {}) {
30
+ return this.exclusive(async () => this.track(await this.session.queuePrompt(text, options)));
31
+ }
32
+ async submitPrompt(text, options = {}) {
33
+ return (await this.queuePrompt(text, options)).completion;
34
+ }
35
+ async tryScheduled(text, origin, beforeQueue) {
36
+ // Give owner/console/I/O callbacks already ready in this event-loop turn a
37
+ // chance to claim the arbiter first. Scheduled work is best-effort; humans
38
+ // and authenticated ingress have priority at the idle boundary.
39
+ await new Promise(resolve => setImmediate(resolve));
40
+ return this.exclusive(async () => {
41
+ const snapshot = this.session.snapshot();
42
+ if (this.stopping || !snapshot.alive || snapshot.readiness === 'failed')
43
+ return { state: 'unavailable', error: this.stopping ? 'role is stopping' : 'session is unavailable' };
44
+ if (snapshot.readiness !== 'idle' || this.unsettled > 0)
45
+ return { state: 'skipped_busy' };
46
+ try {
47
+ await beforeQueue?.();
48
+ const queued = await this.session.queuePrompt(text, { interrupt: false, origin });
49
+ if (queued.queuedBehind > 0)
50
+ return { state: 'unavailable', error: 'scheduled admission race' };
51
+ return { state: 'started', queued: this.track(queued) };
52
+ }
53
+ catch (error) {
54
+ return { state: 'unavailable', error: error?.message ?? String(error) };
55
+ }
56
+ });
57
+ }
58
+ stopScheduledAdmission() { this.stopping = true; }
59
+ isAlive() { return this.session.isAlive(); }
60
+ snapshot() { return this.session.snapshot(); }
61
+ interrupt(source = 'local-console') {
62
+ return this.exclusive(() => this.session.interrupt(source));
63
+ }
64
+ respondPermission(permissionId, optionId) {
65
+ return this.session.respondPermission(permissionId, optionId);
66
+ }
67
+ eventsSince(seq) { return this.session.eventsSince(seq); }
68
+ subscribe(listener) { return this.session.subscribe(listener); }
69
+ setControllerAttached(attached) { this.session.setControllerAttached(attached); }
70
+ exitResult() { return this.session.exitResult(); }
71
+ close() { return this.session.close(); }
72
+ }
@@ -1,16 +1,20 @@
1
1
  import { type Socket } from 'node:net';
2
2
  import type { ControlFailureKind, SessionHandle } from './types.js';
3
+ import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
+ import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
3
5
  export interface ControlRequest {
4
6
  version: 1 | 2;
5
7
  id: string;
6
8
  token: string;
7
- command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since';
9
+ command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config';
8
10
  text?: string;
9
11
  permissionId?: string;
10
12
  optionId?: string;
11
13
  since?: number;
12
14
  /** Existing clients omit this and remain interactive controllers. */
13
15
  controller?: boolean;
16
+ ownerChannel?: OwnerChannelManagementRequest;
17
+ loop?: string;
14
18
  }
15
19
  export interface ControlResponse {
16
20
  version: 1;
@@ -71,9 +75,16 @@ export declare class RoleControlServer {
71
75
  private readonly socketPath;
72
76
  private readonly token;
73
77
  private readonly sockets;
78
+ private ownerChannel?;
79
+ private loopManager?;
80
+ private reloadConfig?;
74
81
  constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
75
82
  start(): Promise<void>;
76
83
  close(): Promise<void>;
84
+ /** Attach only the already-started supervisor-owned channel client. */
85
+ setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
86
+ setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
87
+ setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
77
88
  private accept;
78
89
  private handle;
79
90
  private write;
@@ -95,6 +95,9 @@ export class RoleControlServer {
95
95
  socketPath;
96
96
  token;
97
97
  sockets = new Set();
98
+ ownerChannel;
99
+ loopManager;
100
+ reloadConfig;
98
101
  constructor(stateDir, session, log) {
99
102
  this.session = session;
100
103
  this.log = log;
@@ -127,6 +130,16 @@ export class RoleControlServer {
127
130
  await new Promise(resolve => this.server.close(() => resolve()));
128
131
  rmSync(this.socketPath, { force: true });
129
132
  }
133
+ /** Attach only the already-started supervisor-owned channel client. */
134
+ setOwnerChannel(ownerChannel) {
135
+ this.ownerChannel = ownerChannel;
136
+ }
137
+ setLoopManager(loopManager) {
138
+ this.loopManager = loopManager;
139
+ }
140
+ setConfigReloader(reloadConfig) {
141
+ this.reloadConfig = reloadConfig;
142
+ }
130
143
  accept(socket) {
131
144
  this.sockets.add(socket);
132
145
  socket.setEncoding('utf8');
@@ -202,7 +215,9 @@ export class RoleControlServer {
202
215
  // Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
203
216
  // for minutes; blocking here made every `send` into a busy agent time
204
217
  // out, and the timeout was then reported as a dead agent.
205
- const queued = await this.session.queuePrompt(request.text);
218
+ const queued = await this.session.queuePrompt(request.text, {
219
+ origin: { kind: 'local-console' },
220
+ });
206
221
  this.write(socket, {
207
222
  version: 1, id: request.id, ok: true,
208
223
  result: {
@@ -224,9 +239,47 @@ export class RoleControlServer {
224
239
  return;
225
240
  }
226
241
  case 'interrupt':
227
- await this.session.interrupt();
242
+ await this.session.interrupt('local-console');
228
243
  this.write(socket, { version: 1, id: request.id, ok: true });
229
244
  return;
245
+ case 'loop_status': {
246
+ if (!this.loopManager)
247
+ throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
248
+ this.write(socket, { version: 1, id: request.id, ok: true, result: this.loopManager.status() });
249
+ return;
250
+ }
251
+ case 'loop_run_now':
252
+ case 'loop_disable':
253
+ case 'loop_enable': {
254
+ if (request.version !== 2 || !request.loop)
255
+ throw new SessionControlError('rejected', 'version 2 and loop name are required');
256
+ if (!this.loopManager)
257
+ throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
258
+ const result = request.command === 'loop_run_now'
259
+ ? await this.loopManager.runNow(request.loop)
260
+ : request.command === 'loop_disable'
261
+ ? this.loopManager.disable(request.loop)
262
+ : this.loopManager.enable(request.loop);
263
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
264
+ return;
265
+ }
266
+ case 'reload_config': {
267
+ if (request.version !== 2 || !this.reloadConfig)
268
+ throw new SessionControlError('rejected', 'config reload is unavailable for this role');
269
+ this.write(socket, {
270
+ version: 1, id: request.id, ok: true, result: await this.reloadConfig(),
271
+ });
272
+ return;
273
+ }
274
+ case 'owner_channel_manage': {
275
+ if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
276
+ throw new SessionControlError('rejected', 'owner-channel management action is required');
277
+ if (!this.ownerChannel)
278
+ throw new SessionControlError('rejected', 'owner channel is disabled or unavailable for this role');
279
+ const result = await this.ownerChannel.manage(request.ownerChannel);
280
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
281
+ return;
282
+ }
230
283
  case 'events_since': {
231
284
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
232
285
  const events = this.session.eventsSince(since);
@@ -323,7 +376,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
323
376
  socket.end();
324
377
  });
325
378
  socket.once('connect', () => socket.write(JSON.stringify({
326
- version: 1, id, token, ...request,
379
+ version: 2, id, token, ...request,
327
380
  }) + '\n'));
328
381
  });
329
382
  }