@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.
- package/README.md +53 -7
- package/dist/briefing.js +11 -0
- package/dist/cli.js +38 -6
- package/dist/creation.d.ts +6 -3
- package/dist/creation.js +5 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +48 -6
- package/dist/fleet-proxy.d.ts +25 -0
- package/dist/fleet-proxy.js +36 -0
- package/dist/owner-channel/binder.d.ts +24 -0
- package/dist/owner-channel/binder.js +224 -0
- package/dist/owner-channel/channel.d.ts +25 -0
- package/dist/owner-channel/channel.js +238 -7
- package/dist/owner-channel/mcp.js +10 -0
- package/dist/owner-channel/notices.d.ts +1 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/owner-channel/state.d.ts +7 -1
- package/dist/owner-channel/state.js +41 -4
- package/dist/runner.d.ts +7 -0
- package/dist/runner.js +145 -10
- package/dist/session/control.d.ts +6 -1
- package/dist/session/control.js +13 -0
- package/dist/spawn.d.ts +5 -1
- package/dist/spawn.js +14 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 =
|
|
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({
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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;
|
package/dist/session/control.js
CHANGED
|
@@ -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.
|
|
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",
|