@ours.network/fleet 0.15.0 → 0.15.3

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.
@@ -11,7 +11,7 @@ export declare class OwnerChannelState {
11
11
  has(wireId: string): boolean;
12
12
  remember(wireId: string): void;
13
13
  }
14
- export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner';
14
+ export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner' | 'source-wire';
15
15
  interface OwnerProactiveSend {
16
16
  id: string;
17
17
  contact: string;
@@ -28,6 +28,7 @@ interface OwnerProactiveSend {
28
28
  export declare class OwnerConversationState {
29
29
  private readonly path;
30
30
  private conversations;
31
+ private routes;
31
32
  private sends;
32
33
  private corruptReason?;
33
34
  constructor(path: string);
@@ -41,12 +42,17 @@ export declare class OwnerConversationState {
41
42
  contact: string;
42
43
  basis: OwnerConversationRouteBasis;
43
44
  };
45
+ routeForWire(wireId: string, effective: Set<string>): {
46
+ contact: string;
47
+ basis: OwnerConversationRouteBasis;
48
+ };
44
49
  beginSend(contact: string, digest: string, now?: number, minIntervalMs?: number, dedupe?: 'contact' | 'all'): OwnerProactiveSend;
45
50
  finishSend(id: string, status: 'delivered' | 'uncertain'): void;
46
51
  private mutate;
47
52
  private persist;
48
53
  private assertHealthy;
49
54
  private validConversation;
55
+ private validRoute;
50
56
  private validSend;
51
57
  }
52
58
  export type OwnerSource = 'baseline' | 'dynamic';
@@ -42,6 +42,7 @@ export class OwnerChannelState {
42
42
  }
43
43
  }
44
44
  const CONVERSATION_LIMIT = 64;
45
+ const WIRE_ROUTE_LIMIT = 512;
45
46
  const PROACTIVE_SEND_LIMIT = 256;
46
47
  const PROACTIVE_MIN_INTERVAL_MS = 30_000;
47
48
  const HEX_64_LOWER = /^[a-f0-9]{64}$/;
@@ -55,6 +56,7 @@ const CID = /^[A-Fa-f0-9]{64}$/;
55
56
  export class OwnerConversationState {
56
57
  path;
57
58
  conversations = [];
59
+ routes = [];
58
60
  sends = [];
59
61
  corruptReason;
60
62
  constructor(path) {
@@ -63,18 +65,29 @@ export class OwnerConversationState {
63
65
  return;
64
66
  try {
65
67
  const raw = JSON.parse(readFileSync(path, 'utf8'));
66
- if (raw.version !== 1 || !Array.isArray(raw.conversations) || !Array.isArray(raw.sends)
68
+ const legacy = raw.version === 1;
69
+ const routes = legacy
70
+ ? (raw.conversations ?? []).map(record => ({
71
+ contact: record.contact, wireId: record.lastInboundWireId, at: record.lastInboundAt,
72
+ }))
73
+ : raw.routes;
74
+ if (![1, 2].includes(raw.version ?? 0)
75
+ || !Array.isArray(raw.conversations) || !Array.isArray(routes) || !Array.isArray(raw.sends)
67
76
  || raw.conversations.length > CONVERSATION_LIMIT
77
+ || routes.length > WIRE_ROUTE_LIMIT
68
78
  || raw.sends.length > PROACTIVE_SEND_LIMIT
69
79
  || !raw.conversations.every(record => this.validConversation(record))
80
+ || !routes.every(route => this.validRoute(route))
70
81
  || !raw.sends.every(send => this.validSend(send)))
71
82
  throw new Error('invalid or unbounded conversation state');
72
83
  if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
84
+ || new Set(routes.map(route => route.wireId)).size !== routes.length
73
85
  || new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
74
86
  throw new Error('duplicate conversation state entry');
75
87
  this.conversations = raw.conversations.map(record => ({ ...record }));
88
+ this.routes = routes.map(route => ({ ...route }));
76
89
  this.sends = raw.sends.map(send => ({ ...send }));
77
- let recovered = false;
90
+ let recovered = legacy;
78
91
  for (const send of this.sends) {
79
92
  if (send.status === 'sending') {
80
93
  send.status = 'uncertain';
@@ -88,6 +101,7 @@ export class OwnerConversationState {
88
101
  catch {
89
102
  this.corruptReason = 'invalid persisted owner conversation state';
90
103
  this.conversations = [];
104
+ this.routes = [];
91
105
  this.sends = [];
92
106
  try {
93
107
  chmodSync(path, 0o600);
@@ -118,6 +132,9 @@ export class OwnerConversationState {
118
132
  throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
119
133
  this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
120
134
  }
135
+ this.routes = this.routes.filter(route => route.wireId !== wireId);
136
+ this.routes.push({ contact, wireId, at: acceptedAt });
137
+ this.routes = this.routes.slice(-WIRE_ROUTE_LIMIT);
121
138
  });
122
139
  }
123
140
  remove(contact) {
@@ -127,6 +144,7 @@ export class OwnerConversationState {
127
144
  return;
128
145
  this.mutate(() => {
129
146
  this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
147
+ this.routes = this.routes.filter(route => canonicalCid(route.contact) !== canonical);
130
148
  });
131
149
  }
132
150
  route(effective) {
@@ -146,6 +164,14 @@ export class OwnerConversationState {
146
164
  return { contact: [...effective][0], basis: 'sole-owner' };
147
165
  throw new Error('no authenticated owner conversation route is available yet');
148
166
  }
167
+ routeForWire(wireId, effective) {
168
+ this.assertHealthy();
169
+ const route = [...this.routes].reverse().find(item => item.wireId === wireId);
170
+ const allowed = new Set([...effective].map(canonicalCid));
171
+ if (!route || !allowed.has(canonicalCid(route.contact)))
172
+ throw new Error('no authenticated owner route matches the source wire');
173
+ return { contact: route.contact, basis: 'source-wire' };
174
+ }
149
175
  beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
150
176
  this.assertHealthy();
151
177
  if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
@@ -177,7 +203,9 @@ export class OwnerConversationState {
177
203
  this.mutate(() => { send.status = status; });
178
204
  }
179
205
  mutate(change) {
180
- const snapshot = JSON.stringify({ conversations: this.conversations, sends: this.sends });
206
+ const snapshot = JSON.stringify({
207
+ conversations: this.conversations, routes: this.routes, sends: this.sends,
208
+ });
181
209
  change();
182
210
  try {
183
211
  this.persist();
@@ -185,13 +213,14 @@ export class OwnerConversationState {
185
213
  catch (error) {
186
214
  const old = JSON.parse(snapshot);
187
215
  this.conversations = old.conversations;
216
+ this.routes = old.routes;
188
217
  this.sends = old.sends;
189
218
  throw error;
190
219
  }
191
220
  }
192
221
  persist() {
193
222
  replaceFileAtomically(this.path, JSON.stringify({
194
- version: 1, conversations: this.conversations, sends: this.sends,
223
+ version: 2, conversations: this.conversations, routes: this.routes, sends: this.sends,
195
224
  }) + '\n', 0o600);
196
225
  chmodSync(this.path, 0o600);
197
226
  }
@@ -207,6 +236,14 @@ export class OwnerConversationState {
207
236
  && record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
208
237
  && record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
209
238
  }
239
+ validRoute(value) {
240
+ if (!value || typeof value !== 'object')
241
+ return false;
242
+ const route = value;
243
+ return CID.test(route.contact) && typeof route.wireId === 'string'
244
+ && route.wireId.length > 0 && route.wireId.length <= 1_024
245
+ && Number.isSafeInteger(route.at) && route.at >= 0;
246
+ }
210
247
  validSend(value) {
211
248
  if (!value || typeof value !== 'object')
212
249
  return false;
package/dist/runner.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.
5
5
  import { type Exec } from './exec.js';
6
6
  import type { ExitRecord } from './session/types.js';
7
7
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
8
+ import { type OwnerBinderLease } from './owner-channel/binder.js';
8
9
  export interface RunnerDeps {
9
10
  tmux: Tmux;
10
11
  exec: Exec;
@@ -19,9 +20,15 @@ export interface RunnerDeps {
19
20
  createMonitor(opts: MonitorOpts): MonitorHandle;
20
21
  /** Construct trusted owner ingress (injectable for lifecycle tests). */
21
22
  createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
23
+ /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
24
+ acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
25
+ /** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
26
+ reportOwnerStartupFailure(stateDir: string): Promise<'delivered' | 'duplicate'>;
22
27
  /** Lets a test (or a shutdown path) end the supervised restart loop. */
23
28
  shouldStop?(): boolean;
24
29
  }
30
+ /** Environment injected only into the managed harness process. */
31
+ export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
25
32
  /**
26
33
  * Record who owns wake delivery for this run. Returning true means a fleet
27
34
  * monitor is taking ownership back from a native harness and must start at the
package/dist/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
@@ -13,14 +13,16 @@ import { selectIsolationBackend } from './isolation/registry.js';
13
13
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
14
  import { resolveLaunchRuntime } from './isolation/runtime.js';
15
15
  import { AcpSession } from './session/acp.js';
16
- import { RoleControlServer } from './session/control.js';
16
+ import { controlRequest, RoleControlServer } from './session/control.js';
17
17
  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
21
  import { OwnerChannel } from './owner-channel/channel.js';
22
+ import { acquireOwnerBinderLease, OwnerBinderHandoffTimeoutError, } from './owner-channel/binder.js';
22
23
  import { RoleTurnArbiter } from './session/arbiter.js';
23
24
  import { ScheduledLoopManager, } from './loops/manager.js';
25
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
24
26
  const defaultDeps = () => ({
25
27
  tmux: new Tmux(),
26
28
  exec: realExec,
@@ -38,8 +40,75 @@ const defaultDeps = () => ({
38
40
  fetch: (url, init) => globalThis.fetch(url, init),
39
41
  createMonitor: opts => createMonitor(opts),
40
42
  createOwnerChannel: opts => new OwnerChannel(opts),
43
+ acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
44
+ reportOwnerStartupFailure: async (stateDir) => {
45
+ const response = await controlRequest(stateDir, {
46
+ command: 'owner_channel_manage', ownerChannel: { action: 'startup_failure' },
47
+ }, 2_000);
48
+ if (!response.ok)
49
+ throw new Error(response.error ?? 'prior owner channel refused startup notice');
50
+ const result = response.result;
51
+ if (result?.action !== 'startup_failure'
52
+ || (result.status !== 'delivered' && result.status !== 'duplicate'))
53
+ throw new Error('prior owner channel returned an invalid startup notice result');
54
+ return result.status;
55
+ },
41
56
  });
42
57
  const MONITOR_OWNER_FILE = '.monitor-owner';
58
+ /** Environment injected only into the managed harness process. */
59
+ export function managedFleetProxyEnv(role, stateDir) {
60
+ return {
61
+ ...(role.env ?? {}),
62
+ [FLEET_PROXY_STATE_DIR_ENV]: stateDir,
63
+ [FLEET_PROXY_CALLER_ENV]: role.name,
64
+ };
65
+ }
66
+ /**
67
+ * Execute a typed proxy request in the caller's supervisor. Dynamic imports
68
+ * avoid a runner↔spawn initialization cycle (spawn imports runner constants).
69
+ */
70
+ async function executeManagedSpawn(caller, configPath, requested, log) {
71
+ const { options, inherited } = inheritCallerSpawnDefaults(caller, requested, configPath);
72
+ const creationActionId = randomUUID();
73
+ options.creationActionId = creationActionId;
74
+ const spawnModule = await import('./spawn.js');
75
+ const preview = spawnModule.spawnDryRun(options).resolvedRole;
76
+ const runtimeBinPath = (() => {
77
+ try {
78
+ return realpathSync(process.argv[1]);
79
+ }
80
+ catch {
81
+ return process.argv[1];
82
+ }
83
+ })();
84
+ let statePath;
85
+ if (options.temp) {
86
+ statePath = await spawnModule.spawnTemp(options, runtimeBinPath);
87
+ }
88
+ else {
89
+ const { pickBackend } = await import('./supervisor/index.js');
90
+ const { WatchdogServiceManager } = await import('./watchdog/service.js');
91
+ statePath = await spawnModule.spawnPermanent(options, {
92
+ backend: pickBackend(), binPath: runtimeBinPath, log,
93
+ watchdogService: new WatchdogServiceManager(),
94
+ });
95
+ }
96
+ const result = {
97
+ caller: caller.name,
98
+ role: options.name,
99
+ lifetime: options.temp ? 'temporary' : 'permanent',
100
+ statePath,
101
+ harness: preview.harness,
102
+ session: preview.session,
103
+ ...(preview.model ? { model: preview.model } : {}),
104
+ monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
105
+ inherited,
106
+ creationActionId,
107
+ };
108
+ log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
109
+ + `harness=${result.harness} session=${result.session}`);
110
+ return result;
111
+ }
43
112
  /**
44
113
  * Record who owns wake delivery for this run. Returning true means a fleet
45
114
  * monitor is taking ownership back from a native harness and must start at the
@@ -420,6 +489,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
420
489
  let monitorLoop;
421
490
  let acpStartupComplete = false;
422
491
  let ownerChannel;
492
+ let ownerBinder;
493
+ const pendingFleetSpawnNotices = [];
423
494
  let loopManager;
424
495
  let arbiter;
425
496
  let reloadLoopConfig;
@@ -438,7 +509,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
438
509
  name,
439
510
  argv: wrappedArgv,
440
511
  cwd: runCwd,
441
- env: { ...launch.env, ...(role.env ?? {}) },
512
+ env: { ...launch.env, ...managedFleetProxyEnv(role, dir) },
442
513
  stateDir: dir,
443
514
  mode,
444
515
  permissions: perms,
@@ -455,8 +526,54 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
455
526
  if (evidence)
456
527
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
457
528
  });
529
+ if (role.owner_channel) {
530
+ try {
531
+ ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
532
+ }
533
+ catch (error) {
534
+ if (error instanceof OwnerBinderHandoffTimeoutError) {
535
+ try {
536
+ const status = await deps.reportOwnerStartupFailure(dir);
537
+ deps.log(`[${name}] owner channel startup recovery notice ${status} by authenticated predecessor`);
538
+ }
539
+ catch (notifyError) {
540
+ deps.log(`[${name}] owner channel startup recovery notice unavailable: `
541
+ + `${notifyError?.message ?? String(notifyError)}`);
542
+ }
543
+ }
544
+ await acpSession.close();
545
+ unsubscribeRecovery?.();
546
+ throw new Error(`[${name}] owner channel failed to start: `
547
+ + `${error?.message ?? String(error)}`);
548
+ }
549
+ }
458
550
  control = new RoleControlServer(dir, arbiter, deps.log);
459
- await control.start();
551
+ try {
552
+ await control.start();
553
+ }
554
+ catch (error) {
555
+ ownerBinder?.release();
556
+ await acpSession.close();
557
+ unsubscribeRecovery?.();
558
+ throw error;
559
+ }
560
+ control.setFleetSpawner(async (requested) => {
561
+ const event = await executeManagedSpawn(role, configPath, requested, deps.log);
562
+ if (!role.owner_channel)
563
+ return event;
564
+ if (ownerChannel?.notifyFleetSpawn) {
565
+ try {
566
+ await ownerChannel.notifyFleetSpawn(event);
567
+ }
568
+ catch (error) {
569
+ deps.log(`[${name}] spawned-agent owner notice failed: `
570
+ + `${error?.message ?? String(error)}`);
571
+ }
572
+ }
573
+ else
574
+ pendingFleetSpawnNotices.push(event);
575
+ return event;
576
+ });
460
577
  resolvedMonitorDeps.delivery = {
461
578
  // A wake is only delivered when its turn TERMINATES successfully. A
462
579
  // refusal or a cancellation reached the agent and was not acted on, so
@@ -496,6 +613,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
496
613
  if (!started.succeeded) {
497
614
  monitor?.stop();
498
615
  await control.close();
616
+ ownerBinder?.release();
499
617
  await acpSession.close();
500
618
  unsubscribeRecovery?.();
501
619
  if (modelRecovery) {
@@ -526,6 +644,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
526
644
  stateDir: dir,
527
645
  env: role.env,
528
646
  log: deps.log,
647
+ ...(ownerBinder ? { binderLease: ownerBinder } : {}),
529
648
  ...(configPath ? { configPath } : {}),
530
649
  });
531
650
  try {
@@ -535,14 +654,26 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
535
654
  monitor?.stop();
536
655
  if (monitorLoop)
537
656
  await monitorLoop;
657
+ await ownerChannel.close().catch(() => undefined);
538
658
  await control.close();
659
+ ownerBinder?.release();
539
660
  await acpSession.close();
540
661
  unsubscribeRecovery?.();
541
662
  throw new Error(`[${name}] owner channel failed to start: `
542
663
  + `${error?.message ?? String(error)}`);
543
664
  }
544
- control.setOwnerChannel(ownerChannel);
665
+ for (const event of pendingFleetSpawnNotices.splice(0)) {
666
+ try {
667
+ await ownerChannel.notifyFleetSpawn?.(event);
668
+ }
669
+ catch (error) {
670
+ deps.log(`[${name}] deferred spawned-agent owner notice failed: `
671
+ + `${error?.message ?? String(error)}`);
672
+ }
673
+ }
545
674
  }
675
+ if (ownerChannel)
676
+ control.setOwnerChannel(ownerChannel);
546
677
  reloadLoopConfig = async () => {
547
678
  const nextRole = findRole(loadConfig(configPath), name);
548
679
  const definitions = nextRole.loops ?? [];
@@ -628,17 +759,21 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
628
759
  await loopManager.stop();
629
760
  }
630
761
  control?.setConfigReloader(undefined);
631
- if (ownerChannel) {
632
- control?.setOwnerChannel(undefined);
633
- await ownerChannel.close();
762
+ // Close the authenticated control route before releasing the binder lease;
763
+ // otherwise the predecessor can unlink the replacement's new socket.
764
+ if (control) {
765
+ control.setOwnerChannel(undefined);
766
+ await control.close();
767
+ control = undefined;
634
768
  }
769
+ if (ownerChannel)
770
+ await ownerChannel.close();
771
+ ownerBinder?.release();
635
772
  if (monitor) {
636
773
  monitor.stop();
637
774
  await monitorLoop;
638
775
  }
639
776
  unsubscribeRecovery?.();
640
- if (control)
641
- await control.close();
642
777
  if (acpSession)
643
778
  await acpSession.close();
644
779
  const elapsed = (deps.now() - start) / 1000;
@@ -2,11 +2,13 @@ import { type Socket } from 'node:net';
2
2
  import type { ControlFailureKind, SessionHandle } from './types.js';
3
3
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
4
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
+ import type { SpawnOpts } from '../spawn.js';
6
+ import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
7
  export interface ControlRequest {
6
8
  version: 1 | 2;
7
9
  id: string;
8
10
  token: string;
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';
11
+ 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' | 'fleet_spawn';
10
12
  text?: string;
11
13
  permissionId?: string;
12
14
  optionId?: string;
@@ -15,6 +17,7 @@ export interface ControlRequest {
15
17
  controller?: boolean;
16
18
  ownerChannel?: OwnerChannelManagementRequest;
17
19
  loop?: string;
20
+ spawn?: SpawnOpts;
18
21
  }
19
22
  export interface ControlResponse {
20
23
  version: 1;
@@ -78,6 +81,7 @@ export declare class RoleControlServer {
78
81
  private ownerChannel?;
79
82
  private loopManager?;
80
83
  private reloadConfig?;
84
+ private fleetSpawner?;
81
85
  constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
82
86
  start(): Promise<void>;
83
87
  close(): Promise<void>;
@@ -85,6 +89,7 @@ export declare class RoleControlServer {
85
89
  setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
86
90
  setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
87
91
  setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
92
+ setFleetSpawner(fleetSpawner: ((options: SpawnOpts) => Promise<ManagedFleetSpawnResult>) | undefined): void;
88
93
  private accept;
89
94
  private handle;
90
95
  private write;
@@ -98,6 +98,7 @@ export class RoleControlServer {
98
98
  ownerChannel;
99
99
  loopManager;
100
100
  reloadConfig;
101
+ fleetSpawner;
101
102
  constructor(stateDir, session, log) {
102
103
  this.session = session;
103
104
  this.log = log;
@@ -140,6 +141,9 @@ export class RoleControlServer {
140
141
  setConfigReloader(reloadConfig) {
141
142
  this.reloadConfig = reloadConfig;
142
143
  }
144
+ setFleetSpawner(fleetSpawner) {
145
+ this.fleetSpawner = fleetSpawner;
146
+ }
143
147
  accept(socket) {
144
148
  this.sockets.add(socket);
145
149
  socket.setEncoding('utf8');
@@ -271,6 +275,15 @@ export class RoleControlServer {
271
275
  });
272
276
  return;
273
277
  }
278
+ case 'fleet_spawn': {
279
+ if (request.version !== 2 || !request.spawn || typeof request.spawn.name !== 'string')
280
+ throw new SessionControlError('rejected', 'version 2 and typed spawn options are required');
281
+ if (!this.fleetSpawner)
282
+ throw new SessionControlError('rejected', 'managed fleet spawning is unavailable for this role');
283
+ const result = await this.fleetSpawner(request.spawn);
284
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
285
+ return;
286
+ }
274
287
  case 'owner_channel_manage': {
275
288
  if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
276
289
  throw new SessionControlError('rejected', 'owner-channel management action is required');
package/dist/spawn.d.ts CHANGED
@@ -40,8 +40,12 @@ export interface SpawnOpts {
40
40
  bio?: string;
41
41
  persona?: string;
42
42
  /** Internal, non-sensitive provenance correlation for typed presentation layers. */
43
- surface?: 'cli' | 'web';
43
+ surface?: 'cli' | 'web' | 'agent';
44
44
  creationActionId?: string;
45
+ /** Set only by a live role supervisor after a role-scoped proxy request. */
46
+ callerRole?: string;
47
+ /** Internal provenance labels for values filled by the caller's supervisor. */
48
+ inheritedFromCaller?: string[];
45
49
  /**
46
50
  * Path to a file holding exactly the existing `isolation:` mapping — the same
47
51
  * schema fleet.yaml uses, not a second policy language. The ONE new operator
package/dist/spawn.js CHANGED
@@ -238,29 +238,31 @@ export function spawnDryRun(o) {
238
238
  */
239
239
  function provenanceSettings(o, defaults) {
240
240
  const perms = (defaults.permissions ?? {});
241
+ const callerDefaults = new Set(o.inheritedFromCaller ?? []);
242
+ const tagged = (key, entry) => callerDefaults.has(key) ? { ...entry, source: 'caller-role' } : entry;
241
243
  const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
242
244
  const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
243
245
  return {
244
- harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
245
- session: provenanceOf(o.session, defaults.session, 'tmux'),
246
+ harness: tagged('harness', provenanceOf(o.harness, defaults.harness, 'claude-code')),
247
+ session: tagged('session', provenanceOf(o.session, defaults.session, 'tmux')),
246
248
  identity: o.identity
247
249
  ? { value: o.identity, source: 'cli' }
248
250
  : { value: o.name, source: 'built-in' }, // defaults to the role name
249
- cwd: provenanceOf(o.cwd, undefined, undefined),
250
- model: o.model === null
251
+ cwd: tagged('cwd', provenanceOf(o.cwd, undefined, undefined)),
252
+ model: tagged('model', o.model === null
251
253
  ? { value: undefined, source: 'cli' }
252
254
  : explicitModel
253
255
  ? { value: explicitModel, source: 'cli' }
254
- : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
255
- coordinator: provenanceOf(o.coordinator, undefined, undefined),
256
+ : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' }),
257
+ coordinator: tagged('coordinator', provenanceOf(o.coordinator, undefined, undefined)),
256
258
  permission_mode: provenanceOf(o.permissionMode, undefined, undefined),
257
- approval: provenanceOf(o.approval, perms.approval, 'ask'),
258
- filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
259
- unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
259
+ approval: tagged('approval', provenanceOf(o.approval, perms.approval, 'ask')),
260
+ filesystem: tagged('filesystem', provenanceOf(o.filesystem, perms.filesystem, 'workspace')),
261
+ unattended: tagged('unattended', provenanceOf(o.unattended, perms.unattended, 'deny')),
260
262
  isolation: o.isolationFile
261
263
  ? { value: 'declared via --isolation-file', source: 'cli' }
262
264
  : { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
263
- monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
265
+ monitor: tagged('monitorConfig', provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' })),
264
266
  };
265
267
  }
266
268
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
@@ -324,7 +326,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
324
326
  const provenance = buildProvenance({
325
327
  role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
326
328
  settings: provenanceSettings(o, cfg.defaults),
327
- surface: o.surface, creationActionId: o.creationActionId,
329
+ surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
328
330
  });
329
331
  mkdirSync(agentDir(o.name), { recursive: true });
330
332
  writeProvenance(agentDir(o.name), provenance);
@@ -414,7 +416,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
414
416
  const provenance = buildProvenance({
415
417
  role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
416
418
  settings: provenanceSettings(o, cfg.defaults),
417
- surface: o.surface, creationActionId: o.creationActionId,
419
+ surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
418
420
  });
419
421
  writeProvenance(dir, provenance);
420
422
  lastProvenance = provenance;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.15.0",
3
+ "version": "0.15.3",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",