@ours.network/fleet 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -437,7 +445,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
437
445
  log: deps.log,
438
446
  });
439
447
  pid = acpSession.pid;
440
- sessionHandle = acpSession;
448
+ arbiter = new RoleTurnArbiter(acpSession);
449
+ sessionHandle = arbiter;
441
450
  unsubscribeRecovery = acpSession.subscribe(event => {
442
451
  if (event.kind !== 'error' || !event.text)
443
452
  return;
@@ -445,7 +454,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
445
454
  if (evidence)
446
455
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
447
456
  });
448
- control = new RoleControlServer(dir, acpSession, deps.log);
457
+ control = new RoleControlServer(dir, arbiter, deps.log);
449
458
  await control.start();
450
459
  resolvedMonitorDeps.delivery = {
451
460
  // A wake is only delivered when its turn TERMINATES successfully. A
@@ -457,7 +466,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
457
466
  // steer into the live turn instead; after it completes, honor the
458
467
  // configured interrupt policy normally.
459
468
  const interrupt = options?.interrupt === true && acpStartupComplete;
460
- const result = await acpSession.submitPrompt(text, { ...options, interrupt, steer: true });
469
+ const result = await arbiter.submitPrompt(text, {
470
+ ...options, interrupt, steer: true,
471
+ ...(interrupt ? { interruptSource: 'fleet-monitor' } : {}),
472
+ origin: { kind: 'fleet-monitor' },
473
+ });
461
474
  const steered = result.accepted
462
475
  && (result.detail === 'injected' || result.detail === 'startedNewTurn');
463
476
  return {
@@ -473,7 +486,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
473
486
  // Wait for the first turn's TERMINAL result. An agent that accepts the
474
487
  // startup prompt and then refuses it has not started; logging the role as
475
488
  // up would hide a role that never read its briefing.
476
- const starting = acpSession.submitPrompt(firstPrompt);
489
+ const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
477
490
  // Monitoring starts immediately. The delivery adapter above downgrades
478
491
  // interruption to steering until this startup turn reaches a terminal
479
492
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
@@ -507,7 +520,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
507
520
  ownerChannel = deps.createOwnerChannel({
508
521
  role: name,
509
522
  config: role.owner_channel,
510
- session: acpSession,
523
+ session: arbiter,
511
524
  stateDir: dir,
512
525
  env: role.env,
513
526
  log: deps.log,
@@ -525,6 +538,48 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
525
538
  throw new Error(`[${name}] owner channel failed to start: `
526
539
  + `${error?.message ?? String(error)}`);
527
540
  }
541
+ control.setOwnerChannel(ownerChannel);
542
+ }
543
+ reloadLoopConfig = async () => {
544
+ const nextRole = findRole(loadConfig(configPath), name);
545
+ const definitions = nextRole.loops ?? [];
546
+ const generation = JSON.stringify(definitions.map(loop => [
547
+ loop.name, loop.definitionHash, loop.promptHash,
548
+ ]));
549
+ if (generation === loopGeneration && (loopManager || !definitions.length))
550
+ return { changed: false, loops: definitions.length };
551
+ if (!loopManager && definitions.length) {
552
+ loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
553
+ now: deps.now,
554
+ setTimer: (callback, ms) => setTimeout(callback, ms),
555
+ clearTimer: timer => clearTimeout(timer),
556
+ log: deps.log,
557
+ });
558
+ control.setLoopManager(loopManager);
559
+ loopManager.start();
560
+ }
561
+ else {
562
+ loopManager?.reconcile(definitions);
563
+ }
564
+ loopGeneration = generation;
565
+ deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
566
+ return { changed: true, loops: definitions.length };
567
+ };
568
+ control.setConfigReloader(reloadLoopConfig);
569
+ if (role.loops?.length) {
570
+ try {
571
+ loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
572
+ now: deps.now,
573
+ setTimer: (callback, ms) => setTimeout(callback, ms),
574
+ clearTimer: timer => clearTimeout(timer),
575
+ log: deps.log,
576
+ });
577
+ control.setLoopManager(loopManager);
578
+ loopManager.start();
579
+ }
580
+ catch (error) {
581
+ deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
582
+ }
528
583
  }
529
584
  }
530
585
  else {
@@ -546,10 +601,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
546
601
  // pane pid is known and is stopped when that pid dies (task dies with runner).
547
602
  monitorLoop ??= monitor?.run(pid);
548
603
  const start = deps.now();
549
- while (sessionHandle.isAlive())
604
+ let nextLoopReloadAt = deps.now() + 30_000;
605
+ let lastReloadError = '';
606
+ while (sessionHandle.isAlive()) {
550
607
  await deps.sleep(2000);
551
- if (ownerChannel)
608
+ const now = deps.now();
609
+ if (reloadLoopConfig && now >= nextLoopReloadAt) {
610
+ nextLoopReloadAt = now + 30_000;
611
+ try {
612
+ await reloadLoopConfig();
613
+ lastReloadError = '';
614
+ }
615
+ catch (error) {
616
+ const message = error?.message ?? String(error);
617
+ if (message !== lastReloadError)
618
+ deps.log(`[${name}] scheduled loop config reload rejected: ${message}`);
619
+ lastReloadError = message;
620
+ }
621
+ }
622
+ }
623
+ if (loopManager) {
624
+ control?.setLoopManager(undefined);
625
+ await loopManager.stop();
626
+ }
627
+ control?.setConfigReloader(undefined);
628
+ if (ownerChannel) {
629
+ control?.setOwnerChannel(undefined);
552
630
  await ownerChannel.close();
631
+ }
553
632
  if (monitor) {
554
633
  monitor.stop();
555
634
  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[];
@@ -50,7 +50,8 @@ export declare class AcpSession implements SessionHandle {
50
50
  */
51
51
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
52
52
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
53
- interrupt(): Promise<void>;
53
+ interrupt(source?: TurnCancellationSource): Promise<void>;
54
+ private cancelActive;
54
55
  respondPermission(permissionId: string, optionId: string): boolean;
55
56
  eventsSince(seq: number): SessionEvent[];
56
57
  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',
@@ -239,28 +254,36 @@ export class AcpSession {
239
254
  this.readiness = 'idle';
240
255
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
241
256
  }
242
- async runPrompt(text, turnId = randomUUID()) {
257
+ async runPrompt(text, turnId = randomUUID(), origin) {
243
258
  if (!this.sessionId || !this.isAlive())
244
259
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
245
260
  this.readiness = 'running';
246
- this.activeTurn = { id: turnId, output: '' };
247
- this.events.emit('state', { turnId, status: 'running' });
261
+ this.activeTurn = { id: turnId, output: '', origin };
262
+ this.events.emit('state', { turnId, status: 'running', origin });
248
263
  try {
249
264
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
250
265
  sessionId: this.sessionId,
251
266
  prompt: [{ type: 'text', text }],
252
267
  });
253
268
  this.readiness = 'idle';
254
- this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
269
+ this.events.emit('turn_stop', {
270
+ turnId, stopReason: response.stopReason, origin,
271
+ cancellationSource: this.activeTurn?.id === turnId
272
+ ? this.activeTurn.cancellationSource : undefined,
273
+ });
255
274
  this.events.emit('state', { status: 'idle' });
256
275
  // The prompt was accepted either way — the agent answered. Whether the
257
276
  // 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);
277
+ 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
278
  }
260
279
  catch (error) {
261
- this.lastError = error?.message ?? String(error);
280
+ const detail = error?.message ?? String(error);
281
+ this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
262
282
  this.readiness = this.isAlive() ? 'idle' : 'failed';
263
- this.events.emit('error', { turnId, text: this.lastError });
283
+ this.events.emit('error', {
284
+ turnId, origin,
285
+ text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
286
+ });
264
287
  if (this.isAlive())
265
288
  this.events.emit('state', { status: 'idle' });
266
289
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
@@ -320,12 +343,17 @@ export class AcpSession {
320
343
  this.readiness = 'awaiting_permission';
321
344
  this.events.emit('permission', {
322
345
  turnId: this.activeTurn?.id,
346
+ origin: this.activeTurn?.origin,
323
347
  permissionId,
324
- toolCallId: params.toolCall.toolCallId,
325
- title: params.toolCall.title ?? 'Permission requested',
348
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
349
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
350
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
351
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
326
352
  status: 'pending',
327
353
  options: params.options.map(option => ({
328
- optionId: option.optionId, name: option.name, kind: option.kind,
354
+ optionId: option.optionId,
355
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
356
+ kind: option.kind,
329
357
  })),
330
358
  });
331
359
  return new Promise(resolve => {
@@ -341,16 +369,23 @@ export class AcpSession {
341
369
  const settled = option ? decision : 'cancelled';
342
370
  this.events.emit('permission', {
343
371
  turnId: this.activeTurn?.id,
372
+ origin: this.activeTurn?.origin,
344
373
  permissionId: randomUUID(),
345
- toolCallId: params.toolCall.toolCallId,
346
- title: params.toolCall.title ?? 'Permission requested',
374
+ toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
375
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
347
376
  status: 'completed',
348
377
  decision: settled,
349
378
  decisionSource: 'automatic',
350
379
  policy,
351
380
  reason: option ? reason : `${reason}, but the agent offered no matching option`,
352
381
  optionId: option?.optionId,
353
- options: params.options.map(o => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
382
+ title: this.activeTurn?.origin?.kind === 'scheduled-loop'
383
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
384
+ options: params.options.map(o => ({
385
+ optionId: o.optionId,
386
+ name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? o.kind : o.name,
387
+ kind: o.kind,
388
+ })),
354
389
  });
355
390
  return option
356
391
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
@@ -373,34 +408,41 @@ export class AcpSession {
373
408
  });
374
409
  }
375
410
  recordUpdate(update) {
411
+ const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
376
412
  switch (update.sessionUpdate) {
377
413
  case 'agent_message_chunk':
378
414
  if (this.activeTurn && update.content.type === 'text')
379
415
  this.activeTurn.output += update.content.text;
380
416
  this.events.emit('agent_text', {
381
417
  turnId: this.activeTurn?.id,
382
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
418
+ origin: this.activeTurn?.origin,
419
+ text: scheduled ? '[scheduled-loop output redacted]'
420
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
383
421
  });
384
422
  break;
385
423
  case 'agent_thought_chunk':
386
424
  this.events.emit('thought', {
387
425
  turnId: this.activeTurn?.id,
388
- text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
426
+ origin: this.activeTurn?.origin,
427
+ text: scheduled ? '[scheduled-loop thought redacted]'
428
+ : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
389
429
  });
390
430
  break;
391
431
  case 'tool_call':
392
432
  this.events.emit('tool_call', {
393
433
  turnId: this.activeTurn?.id,
394
- toolCallId: update.toolCallId,
395
- title: update.title,
434
+ origin: this.activeTurn?.origin,
435
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
436
+ title: scheduled ? 'scheduled-loop tool' : update.title,
396
437
  status: update.status,
397
438
  });
398
439
  break;
399
440
  case 'tool_call_update':
400
441
  this.events.emit('tool_update', {
401
442
  turnId: this.activeTurn?.id,
402
- toolCallId: update.toolCallId,
403
- title: update.title ?? undefined,
443
+ origin: this.activeTurn?.origin,
444
+ toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
445
+ title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
404
446
  status: update.status ?? undefined,
405
447
  });
406
448
  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
  }