@ours.network/fleet 0.11.1 → 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.
Files changed (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -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 +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
@@ -0,0 +1,246 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, readFileSync } from 'node:fs';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ export const OWNER_TASK_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
5
+ export const OWNER_TASK_MAX_OPEN = 32;
6
+ export const OWNER_TASK_MAX_PER_OWNER = 8;
7
+ export const OWNER_TASK_MAX_REPORTS = 20;
8
+ export const OWNER_TASK_REPORT_MIN_INTERVAL_MS = 5_000;
9
+ const MAX_TOMBSTONES = 256;
10
+ const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
11
+ const HEX_64 = /^[a-f0-9]{64}$/;
12
+ const CID = /^[A-Fa-f0-9]{64}$/;
13
+ /**
14
+ * Durable proactive-task routing. It deliberately stores no report body: only
15
+ * the authenticated route, bounded hashes/counters, and delivery state.
16
+ */
17
+ export class OwnerTaskState {
18
+ path;
19
+ tasks = [];
20
+ tombstones = [];
21
+ corruptReason;
22
+ constructor(path) {
23
+ this.path = path;
24
+ if (!existsSync(path))
25
+ return;
26
+ try {
27
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
28
+ if (raw.version !== 1 || !Array.isArray(raw.tasks) || !Array.isArray(raw.tombstones)
29
+ || raw.tasks.length > OWNER_TASK_MAX_OPEN || raw.tombstones.length > MAX_TOMBSTONES
30
+ || !raw.tasks.every(task => this.validTask(task))
31
+ || !raw.tombstones.every(tombstone => this.validTombstone(tombstone)))
32
+ throw new Error('invalid or unbounded task state');
33
+ const ids = [...raw.tasks.map(task => task.id), ...raw.tombstones.map(item => item.id)];
34
+ if (new Set(ids).size !== ids.length)
35
+ throw new Error('duplicate task ID');
36
+ this.tasks = raw.tasks.map(task => ({ ...task, digests: [...task.digests] }));
37
+ this.tombstones = raw.tombstones.map(item => ({ ...item }));
38
+ // A crash or lost response after the durable pre-send marker makes the
39
+ // result unknowable. Never resend it automatically and risk duplication.
40
+ let recovered = false;
41
+ for (const task of this.tasks) {
42
+ if (task.status === 'sending') {
43
+ task.status = 'uncertain';
44
+ recovered = true;
45
+ }
46
+ }
47
+ chmodSync(path, 0o600);
48
+ if (recovered)
49
+ this.persist();
50
+ }
51
+ catch {
52
+ this.tasks = [];
53
+ this.tombstones = [];
54
+ this.corruptReason = 'invalid persisted owner task state';
55
+ try {
56
+ chmodSync(path, 0o600);
57
+ }
58
+ catch { /* remain fail-closed */ }
59
+ }
60
+ }
61
+ integrity() {
62
+ return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
63
+ }
64
+ open(route, now = Date.now()) {
65
+ this.assertHealthy();
66
+ if (!HEX_64.test(route.requestId) || !CID.test(route.contact)
67
+ || typeof route.wireId !== 'string' || route.wireId.length < 1 || route.wireId.length > 1_024)
68
+ throw new Error('owner task route is invalid or exceeds its bounds');
69
+ this.cleanup(now);
70
+ if (this.tasks.length >= OWNER_TASK_MAX_OPEN)
71
+ throw new Error(`owner channel is limited to ${OWNER_TASK_MAX_OPEN} open tasks`);
72
+ if (this.tasks.filter(task => task.contact === route.contact).length >= OWNER_TASK_MAX_PER_OWNER)
73
+ throw new Error(`an owner is limited to ${OWNER_TASK_MAX_PER_OWNER} open tasks per role`);
74
+ let id;
75
+ do {
76
+ id = randomBytes(32).toString('hex');
77
+ } while (this.tasks.some(task => task.id === id) || this.tombstones.some(item => item.id === id));
78
+ const task = {
79
+ id, ...route, createdAt: now, expiresAt: now + OWNER_TASK_TTL_MS,
80
+ status: 'open', sequence: 0, reportCount: 0, digests: [],
81
+ };
82
+ this.mutate(() => { this.tasks.push(task); });
83
+ return { ...task, digests: [] };
84
+ }
85
+ route(taskId, now = Date.now()) {
86
+ this.assertHealthy();
87
+ this.cleanup(now);
88
+ const task = this.findOpen(taskId);
89
+ return {
90
+ ...task, digests: [...task.digests],
91
+ ...(task.pending ? { pending: { ...task.pending } } : {}),
92
+ };
93
+ }
94
+ beginReport(taskId, phase, digest, chars, bytes, now = Date.now()) {
95
+ this.assertHealthy();
96
+ this.cleanup(now);
97
+ const task = this.findOpen(taskId);
98
+ if (task.status === 'uncertain')
99
+ throw new Error('task report delivery outcome is uncertain; refusing to resend or reorder reports');
100
+ if (task.status !== 'open')
101
+ throw new Error('task report is already being delivered');
102
+ if (task.digests.includes(digest))
103
+ throw new Error('duplicate task report refused');
104
+ if (task.reportCount >= OWNER_TASK_MAX_REPORTS)
105
+ throw new Error(`owner task is limited to ${OWNER_TASK_MAX_REPORTS} reports`);
106
+ if (task.lastReportAt !== undefined && now - task.lastReportAt < OWNER_TASK_REPORT_MIN_INTERVAL_MS)
107
+ throw new Error(`owner task reports are rate-limited to one every ${OWNER_TASK_REPORT_MIN_INTERVAL_MS}ms`);
108
+ this.mutate(() => {
109
+ task.status = 'sending';
110
+ task.pending = { digest, phase, chars, bytes };
111
+ });
112
+ return { ...task, digests: [...task.digests], pending: { ...task.pending } };
113
+ }
114
+ delivered(taskId, digest, terminal, now = Date.now()) {
115
+ this.assertHealthy();
116
+ const task = this.tasks.find(item => item.id === taskId);
117
+ if (!task || task.status !== 'sending' || task.pending?.digest !== digest)
118
+ throw new Error('task report delivery state changed unexpectedly');
119
+ const sequence = task.sequence + 1;
120
+ this.mutate(() => {
121
+ task.sequence = sequence;
122
+ task.reportCount++;
123
+ task.lastReportAt = now;
124
+ task.digests.push(digest);
125
+ task.pending = undefined;
126
+ if (terminal)
127
+ this.remove(task, 'closed', now);
128
+ else
129
+ task.status = 'open';
130
+ });
131
+ return sequence;
132
+ }
133
+ uncertain(taskId, digest) {
134
+ this.assertHealthy();
135
+ const task = this.tasks.find(item => item.id === taskId);
136
+ if (!task || task.pending?.digest !== digest)
137
+ return;
138
+ this.mutate(() => { task.status = 'uncertain'; });
139
+ }
140
+ revoke(contact, now = Date.now()) {
141
+ this.assertHealthy();
142
+ const revoked = this.tasks.filter(task => task.contact === contact);
143
+ if (!revoked.length)
144
+ return 0;
145
+ this.mutate(() => { for (const task of revoked)
146
+ this.remove(task, 'revoked', now); });
147
+ return revoked.length;
148
+ }
149
+ cleanup(now = Date.now(), effectiveOwners) {
150
+ this.assertHealthy();
151
+ const expired = this.tasks.filter(task => task.expiresAt <= now);
152
+ const revoked = effectiveOwners
153
+ ? this.tasks.filter(task => !effectiveOwners.has(task.contact) && !expired.includes(task)) : [];
154
+ const oldTombstones = this.tombstones.filter(item => now - item.at > TOMBSTONE_TTL_MS);
155
+ if (!expired.length && !revoked.length && !oldTombstones.length)
156
+ return 0;
157
+ this.mutate(() => {
158
+ for (const task of expired)
159
+ this.remove(task, 'expired', now);
160
+ for (const task of revoked)
161
+ this.remove(task, 'revoked', now);
162
+ this.tombstones = this.tombstones
163
+ .filter(item => now - item.at <= TOMBSTONE_TTL_MS).slice(-MAX_TOMBSTONES);
164
+ });
165
+ return expired.length + revoked.length;
166
+ }
167
+ findOpen(taskId) {
168
+ if (!HEX_64.test(taskId))
169
+ throw new Error('owner task ID must be exactly 64 lowercase hexadecimal characters');
170
+ const task = this.tasks.find(item => item.id === taskId);
171
+ if (task)
172
+ return task;
173
+ const tombstone = this.tombstones.find(item => item.id === taskId);
174
+ if (tombstone)
175
+ throw new Error(`owner task is ${tombstone.state}`);
176
+ throw new Error('unknown owner task ID');
177
+ }
178
+ remove(task, state, now) {
179
+ this.tasks = this.tasks.filter(item => item !== task);
180
+ this.tombstones.push({ id: task.id, state, at: now });
181
+ this.tombstones = this.tombstones.slice(-MAX_TOMBSTONES);
182
+ }
183
+ mutate(change) {
184
+ const snapshot = JSON.stringify({ tasks: this.tasks, tombstones: this.tombstones });
185
+ change();
186
+ try {
187
+ this.persist();
188
+ }
189
+ catch (error) {
190
+ const old = JSON.parse(snapshot);
191
+ this.tasks = old.tasks;
192
+ this.tombstones = old.tombstones;
193
+ throw error;
194
+ }
195
+ }
196
+ persist() {
197
+ replaceFileAtomically(this.path, JSON.stringify({
198
+ version: 1, tasks: this.tasks, tombstones: this.tombstones,
199
+ }) + '\n', 0o600);
200
+ chmodSync(this.path, 0o600);
201
+ }
202
+ assertHealthy() {
203
+ if (this.corruptReason)
204
+ throw new Error(`owner task state is corrupt; refusing operation: ${this.corruptReason}`);
205
+ }
206
+ validTask(value) {
207
+ if (!value || typeof value !== 'object')
208
+ return false;
209
+ const task = value;
210
+ return HEX_64.test(task.id) && HEX_64.test(task.requestId) && CID.test(task.contact)
211
+ && typeof task.wireId === 'string' && task.wireId.length > 0 && task.wireId.length <= 1_024
212
+ && Number.isSafeInteger(task.createdAt) && task.createdAt >= 0
213
+ && Number.isSafeInteger(task.expiresAt) && task.expiresAt - task.createdAt === OWNER_TASK_TTL_MS
214
+ && ['open', 'sending', 'uncertain'].includes(task.status)
215
+ && Number.isSafeInteger(task.sequence) && task.sequence >= 0
216
+ && Number.isSafeInteger(task.reportCount) && task.reportCount >= 0
217
+ && task.reportCount <= OWNER_TASK_MAX_REPORTS
218
+ && Array.isArray(task.digests) && task.digests.length <= OWNER_TASK_MAX_REPORTS
219
+ && task.digests.every(digest => HEX_64.test(digest))
220
+ && new Set(task.digests).size === task.digests.length
221
+ && task.sequence === task.reportCount && task.reportCount === task.digests.length
222
+ && (task.reportCount === 0
223
+ ? task.lastReportAt === undefined
224
+ : Number.isSafeInteger(task.lastReportAt) && task.lastReportAt >= task.createdAt)
225
+ && (task.pending === undefined || this.validPending(task.pending))
226
+ && (task.pending === undefined || !task.digests.includes(task.pending.digest))
227
+ && (task.status === 'open' ? task.pending === undefined : task.pending !== undefined);
228
+ }
229
+ validPending(value) {
230
+ if (!value || typeof value !== 'object')
231
+ return false;
232
+ const pending = value;
233
+ return HEX_64.test(pending.digest) && ['progress', 'done', 'blocked'].includes(pending.phase)
234
+ && Number.isSafeInteger(pending.chars) && pending.chars >= 1 && pending.chars <= 280
235
+ && Number.isSafeInteger(pending.bytes) && pending.bytes >= 1 && pending.bytes <= 1_024;
236
+ }
237
+ validTombstone(value) {
238
+ if (!value || typeof value !== 'object')
239
+ return false;
240
+ const item = value;
241
+ return HEX_64.test(item.id) && ['closed', 'expired', 'revoked'].includes(item.state)
242
+ && Number.isSafeInteger(item.at);
243
+ }
244
+ }
245
+ export const ownerTaskDigest = (phase, message) => createHash('sha256').update(`${phase}\0${message}`).digest('hex');
246
+ export const ownerTaskAuditId = (value) => createHash('sha256').update(value).digest('hex').slice(0, 12);
@@ -20,6 +20,12 @@ export function resolvedPlan(cfg) {
20
20
  startStaggerMs: cfg.startStaggerMs,
21
21
  diagnostics: cfg.diagnostics.map(diagnostic => ({ ...diagnostic })),
22
22
  roles: cfg.roles.map(resolvedRolePlan),
23
+ loops: cfg.loops.map(loop => sortedObject({
24
+ name: loop.name, selectors: [...loop.selectors], roles: [...loop.roleNames],
25
+ intervalMs: loop.intervalMs, initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
26
+ enabled: loop.enabled, sourceFile: loop.sourceFile,
27
+ prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
28
+ })),
23
29
  watchdogs: cfg.watchdogs.map(w => sortedObject({
24
30
  name: w.name, sourceFile: w.sourceFile, enabled: w.enabled,
25
31
  intervalMs: w.intervalMs, coordinator: w.coordinator, watch: [...w.watch],
@@ -60,6 +66,12 @@ export function resolvedRolePlan(role) {
60
66
  ],
61
67
  },
62
68
  monitor: role.monitor,
69
+ ownerChannel: role.owner_channel ?? null,
70
+ loops: (role.loops ?? []).map(loop => ({
71
+ name: loop.name, enabled: loop.enabled, intervalMs: loop.intervalMs,
72
+ initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
73
+ definitionHash: loop.definitionHash, prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
74
+ })),
63
75
  isolation: role.isolation ?? null,
64
76
  worklog: role.worklog ?? null,
65
77
  authProxy: role.auth_proxy ?? null,
package/dist/runner.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Tmux } from './tmux.js';
4
4
  import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
5
5
  import { type Exec } from './exec.js';
6
6
  import type { ExitRecord } from './session/types.js';
7
+ import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
7
8
  export interface RunnerDeps {
8
9
  tmux: Tmux;
9
10
  exec: Exec;
@@ -16,6 +17,8 @@ export interface RunnerDeps {
16
17
  fetch: FetchLike;
17
18
  /** Construct the supervisor mail monitor (injectable so tests stub it out). */
18
19
  createMonitor(opts: MonitorOpts): MonitorHandle;
20
+ /** Construct trusted owner ingress (injectable for lifecycle tests). */
21
+ createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
19
22
  /** Lets a test (or a shutdown path) end the supervised restart loop. */
20
23
  shouldStop?(): boolean;
21
24
  }
package/dist/runner.js CHANGED
@@ -18,6 +18,9 @@ import { TmuxSession } from './session/tmux.js';
18
18
  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
+ import { OwnerChannel } from './owner-channel/channel.js';
22
+ import { RoleTurnArbiter } from './session/arbiter.js';
23
+ import { ScheduledLoopManager, } from './loops/manager.js';
21
24
  const defaultDeps = () => ({
22
25
  tmux: new Tmux(),
23
26
  exec: realExec,
@@ -34,6 +37,7 @@ const defaultDeps = () => ({
34
37
  log: line => process.stderr.write(line + '\n'),
35
38
  fetch: (url, init) => globalThis.fetch(url, init),
36
39
  createMonitor: opts => createMonitor(opts),
40
+ createOwnerChannel: opts => new OwnerChannel(opts),
37
41
  });
38
42
  const MONITOR_OWNER_FILE = '.monitor-owner';
39
43
  /**
@@ -415,6 +419,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
415
419
  let unsubscribeRecovery;
416
420
  let monitorLoop;
417
421
  let acpStartupComplete = false;
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
+ ]));
418
429
  if (sessionBackend === 'acp') {
419
430
  const perms = role.permissions ?? resolvePermissions(undefined, undefined);
420
431
  // Say once, at startup, that this role will decide permission requests by
@@ -434,7 +445,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
434
445
  log: deps.log,
435
446
  });
436
447
  pid = acpSession.pid;
437
- sessionHandle = acpSession;
448
+ arbiter = new RoleTurnArbiter(acpSession);
449
+ sessionHandle = arbiter;
438
450
  unsubscribeRecovery = acpSession.subscribe(event => {
439
451
  if (event.kind !== 'error' || !event.text)
440
452
  return;
@@ -442,7 +454,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
442
454
  if (evidence)
443
455
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
444
456
  });
445
- control = new RoleControlServer(dir, acpSession, deps.log);
457
+ control = new RoleControlServer(dir, arbiter, deps.log);
446
458
  await control.start();
447
459
  resolvedMonitorDeps.delivery = {
448
460
  // A wake is only delivered when its turn TERMINATES successfully. A
@@ -454,7 +466,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
454
466
  // steer into the live turn instead; after it completes, honor the
455
467
  // configured interrupt policy normally.
456
468
  const interrupt = options?.interrupt === true && acpStartupComplete;
457
- 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
+ });
458
474
  const steered = result.accepted
459
475
  && (result.detail === 'injected' || result.detail === 'startedNewTurn');
460
476
  return {
@@ -470,7 +486,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
470
486
  // Wait for the first turn's TERMINAL result. An agent that accepts the
471
487
  // startup prompt and then refuses it has not started; logging the role as
472
488
  // up would hide a role that never read its briefing.
473
- const starting = acpSession.submitPrompt(firstPrompt);
489
+ const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
474
490
  // Monitoring starts immediately. The delivery adapter above downgrades
475
491
  // interruption to steering until this startup turn reaches a terminal
476
492
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
@@ -500,6 +516,71 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
500
516
  `${started.detail ? `: ${started.detail}` : ''}`);
501
517
  }
502
518
  acpStartupComplete = true;
519
+ if (role.owner_channel) {
520
+ ownerChannel = deps.createOwnerChannel({
521
+ role: name,
522
+ config: role.owner_channel,
523
+ session: arbiter,
524
+ stateDir: dir,
525
+ env: role.env,
526
+ log: deps.log,
527
+ });
528
+ try {
529
+ await ownerChannel.start();
530
+ }
531
+ catch (error) {
532
+ monitor?.stop();
533
+ if (monitorLoop)
534
+ await monitorLoop;
535
+ await control.close();
536
+ await acpSession.close();
537
+ unsubscribeRecovery?.();
538
+ throw new Error(`[${name}] owner channel failed to start: `
539
+ + `${error?.message ?? String(error)}`);
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
+ }
583
+ }
503
584
  }
504
585
  else {
505
586
  await deps.tmux.kill(name);
@@ -520,8 +601,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
520
601
  // pane pid is known and is stopped when that pid dies (task dies with runner).
521
602
  monitorLoop ??= monitor?.run(pid);
522
603
  const start = deps.now();
523
- while (sessionHandle.isAlive())
604
+ let nextLoopReloadAt = deps.now() + 30_000;
605
+ let lastReloadError = '';
606
+ while (sessionHandle.isAlive()) {
524
607
  await deps.sleep(2000);
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);
630
+ await ownerChannel.close();
631
+ }
525
632
  if (monitor) {
526
633
  monitor.stop();
527
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[];
@@ -38,6 +38,7 @@ export declare class AcpSession implements SessionHandle {
38
38
  private steeringSupported;
39
39
  private capabilities?;
40
40
  private controllerCount;
41
+ private activeTurn?;
41
42
  private constructor();
42
43
  static start(options: AcpSessionOptions): Promise<AcpSession>;
43
44
  isAlive(): boolean;
@@ -49,7 +50,8 @@ export declare class AcpSession implements SessionHandle {
49
50
  */
50
51
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
51
52
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
52
- interrupt(): Promise<void>;
53
+ interrupt(source?: TurnCancellationSource): Promise<void>;
54
+ private cancelActive;
53
55
  respondPermission(permissionId: string, optionId: string): boolean;
54
56
  eventsSince(seq: number): SessionEvent[];
55
57
  subscribe(listener: (event: SessionEvent) => void): () => void;