@prjct.app/pi-team 0.6.0 → 0.7.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 (49) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTING.md +2 -1
  3. package/README.md +23 -177
  4. package/docs/architecture.md +36 -168
  5. package/package.json +10 -4
  6. package/src/commands/team-command.ts +37 -0
  7. package/src/domain/lease.ts +54 -0
  8. package/src/domain/member.ts +58 -0
  9. package/src/domain/message.ts +91 -0
  10. package/src/domain/request.ts +67 -0
  11. package/src/domain/team.ts +71 -0
  12. package/src/dynamic/domain.ts +110 -0
  13. package/src/dynamic/memory.ts +38 -0
  14. package/src/dynamic/panel.ts +155 -0
  15. package/src/dynamic/peer-log.ts +39 -0
  16. package/src/dynamic/runner.ts +196 -0
  17. package/src/dynamic/service.ts +292 -0
  18. package/src/dynamic/store.ts +57 -0
  19. package/src/dynamic/view.ts +21 -0
  20. package/src/dynamic/worker.ts +210 -0
  21. package/src/dynamic/workspace.ts +43 -0
  22. package/src/index.ts +204 -679
  23. package/src/process-identity.ts +68 -0
  24. package/src/runtime/delivery.ts +326 -0
  25. package/src/runtime/membership.ts +212 -0
  26. package/src/runtime/presence.ts +98 -0
  27. package/src/runtime/purge.ts +39 -0
  28. package/src/runtime/reconciler.ts +112 -0
  29. package/src/runtime/requests.ts +353 -0
  30. package/src/runtime/resources.ts +117 -0
  31. package/src/runtime/team-runtime.ts +47 -0
  32. package/src/runtime/team-tool.ts +191 -0
  33. package/src/storage/atomic.ts +347 -0
  34. package/src/storage/inbox-store.ts +290 -0
  35. package/src/storage/lease-store.ts +158 -0
  36. package/src/storage/paths.ts +76 -0
  37. package/src/storage/receipt-store.ts +117 -0
  38. package/src/storage/team-store.ts +190 -0
  39. package/src/supervisor/control-protocol.ts +125 -0
  40. package/src/supervisor/runtime-store.ts +231 -0
  41. package/src/supervisor/shutdown.ts +141 -0
  42. package/src/supervisor/supervisor.ts +657 -0
  43. package/src/supervisor/tmux-adapter.ts +192 -0
  44. package/src/supervisor/worker-bootstrap.ts +43 -0
  45. package/src/supervisor/worker-client.ts +233 -0
  46. package/src/ui/team-dashboard.ts +179 -0
  47. package/src/mailbox.ts +0 -536
  48. package/src/schema.ts +0 -25
  49. package/src/store.ts +0 -230
@@ -0,0 +1,657 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, unlink } from 'node:fs/promises';
3
+ import { createServer, type Server, type Socket } from 'node:net';
4
+ import { dirname, join } from 'node:path';
5
+ import {
6
+ CONTROL_PROTOCOL_VERSION, NdjsonFrameDecoder, assertWorkerFrame, controlTokenMatches, encodeControlFrame,
7
+ type SupervisorFrame, type SupervisorFramePayload, type WorkerFrame,
8
+ } from './control-protocol.ts';
9
+ import { defaultProcessController, sameProcess, type ProcessController } from '../process-identity.ts';
10
+ import { ensurePrivateDirectory, ensurePrivateTree } from '../storage/atomic.ts';
11
+ import { TeamPaths } from '../storage/paths.ts';
12
+ import { TeamStore } from '../storage/team-store.ts';
13
+ import { RuntimeShutdown, type ShutdownReason, type ShutdownResult, type ShutdownTimings } from './shutdown.ts';
14
+ import {
15
+ RuntimeStore, sameOwner, type OwnerIdentity, type OwnedRuntime,
16
+ } from './runtime-store.ts';
17
+ import { TmuxAdapter, type TmuxLaunchOptions } from './tmux-adapter.ts';
18
+ import type { Membership } from '../runtime/membership.ts';
19
+
20
+ const PROCESS_NONCE = Symbol.for('prjct.pi-team.owner-process-nonce');
21
+
22
+ type ProcessGlobal = typeof globalThis & { [PROCESS_NONCE]?: string };
23
+
24
+ export function ownerProcessNonce(): string {
25
+ const shared = globalThis as ProcessGlobal;
26
+ if (!shared[PROCESS_NONCE]) shared[PROCESS_NONCE] = randomBytes(32).toString('hex');
27
+ return shared[PROCESS_NONCE]!;
28
+ }
29
+
30
+ export type SupervisorOptions = {
31
+ readonly teamId: string;
32
+ readonly ownerSessionId: string;
33
+ readonly paths?: TeamPaths;
34
+ readonly teams?: TeamStore;
35
+ readonly runtimes?: RuntimeStore;
36
+ readonly tmux?: TmuxAdapter;
37
+ readonly processes?: ProcessController;
38
+ readonly ownerProcessNonce?: string;
39
+ readonly ownerInstanceId?: string;
40
+ readonly ownerEpoch?: number;
41
+ readonly socketPath?: string;
42
+ readonly heartbeatMs?: number;
43
+ readonly shutdownTimings?: Partial<ShutdownTimings>;
44
+ readonly now?: () => number;
45
+ };
46
+
47
+ export type SupervisorLaunch = {
48
+ readonly memberId: string;
49
+ readonly cwd: string;
50
+ readonly command: readonly [string, ...string[]];
51
+ readonly environment?: Readonly<Record<string, string | undefined>>;
52
+ readonly workerMembership: Membership;
53
+ readonly autoRequests: boolean;
54
+ };
55
+
56
+ export type SupervisorHandoff = {
57
+ readonly teamId: string;
58
+ readonly from: OwnerIdentity;
59
+ readonly to: OwnerIdentity;
60
+ readonly socketPath: string;
61
+ readonly runtimes: readonly {
62
+ readonly record: OwnedRuntime;
63
+ readonly controlToken: string;
64
+ readonly ownershipToken: string;
65
+ }[];
66
+ };
67
+
68
+ export type ReconcileResult = {
69
+ readonly runtimeId: string;
70
+ readonly status: 'owned' | 'lost' | 'foreign' | 'terminated';
71
+ };
72
+
73
+ type SecretSnapshot = SupervisorHandoff['runtimes'][number];
74
+
75
+ type ControlState = {
76
+ record?: OwnedRuntime;
77
+ readonly runtimeId: string;
78
+ readonly controlToken: string;
79
+ readonly ownershipToken: string;
80
+ socket?: Socket;
81
+ authenticated: boolean;
82
+ lastWorkerSeq: number;
83
+ supervisorSeq: number;
84
+ lastPong: number;
85
+ pendingState?: { readonly state: 'ready' | 'busy'; readonly requestId?: string };
86
+ observedState?: 'ready' | 'busy';
87
+ observedRequestId?: string;
88
+ update: Promise<void>;
89
+ };
90
+
91
+ class ControlHub {
92
+ private server?: Server;
93
+ private inode?: { readonly dev: number; readonly ino: number };
94
+ private heartbeat?: ReturnType<typeof setInterval>;
95
+ private readonly states = new Map<string, ControlState>();
96
+ private readonly sockets = new Set<Socket>();
97
+ private started = false;
98
+
99
+ constructor(
100
+ readonly socketPath: string,
101
+ private readonly owner: () => OwnerIdentity,
102
+ private readonly now: () => number,
103
+ private readonly heartbeatMs: number,
104
+ private readonly onState: (runtimeId: string, state: 'ready' | 'busy', requestId?: string) => Promise<void>,
105
+ private readonly onLost: (runtimeId: string) => Promise<void>,
106
+ ) {
107
+ if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0) throw new Error('Invalid supervisor heartbeat interval.');
108
+ if (Buffer.byteLength(socketPath, 'utf8') > 100) throw new Error('Supervisor Unix socket path is too long.');
109
+ }
110
+
111
+ register(runtimeId: string, controlToken: string, ownershipToken: string, record?: OwnedRuntime): void {
112
+ const existing = this.states.get(runtimeId);
113
+ if (existing) {
114
+ if (controlTokenMatches(existing.controlToken, controlToken) && existing.ownershipToken === ownershipToken) {
115
+ if (record) existing.record = record;
116
+ return;
117
+ }
118
+ throw Object.assign(new Error(`Runtime "${runtimeId}" is already registered.`), { code: 'ALREADY_EXISTS' });
119
+ }
120
+ this.states.set(runtimeId, {
121
+ runtimeId, controlToken, ownershipToken, ...(record ? { record } : {}), authenticated: false,
122
+ lastWorkerSeq: 0, supervisorSeq: 0, lastPong: this.now(), update: Promise.resolve(),
123
+ });
124
+ }
125
+
126
+ unregister(runtimeId: string): void {
127
+ const state = this.states.get(runtimeId);
128
+ state?.socket?.destroy();
129
+ this.states.delete(runtimeId);
130
+ }
131
+
132
+ setRecord(record: OwnedRuntime): void {
133
+ const state = this.states.get(record.runtimeId);
134
+ if (!state) throw new Error(`Runtime "${record.runtimeId}" is not registered.`);
135
+ state.record = record;
136
+ if (state.pendingState) this.queueState(state, state.pendingState.state, state.pendingState.requestId);
137
+ }
138
+
139
+ record(runtimeId: string): OwnedRuntime | undefined { return this.states.get(runtimeId)?.record; }
140
+ has(runtimeId: string): boolean { return this.states.has(runtimeId); }
141
+
142
+ updateRecord(record: OwnedRuntime): void {
143
+ const state = this.states.get(record.runtimeId);
144
+ if (state) state.record = record;
145
+ }
146
+
147
+ requestMatches(runtimeId: string, requestId: string): boolean {
148
+ const state = this.states.get(runtimeId);
149
+ return !!state && (state.observedState
150
+ ? state.observedRequestId === requestId
151
+ : state.record?.activeRequestId === requestId);
152
+ }
153
+
154
+ snapshots(): readonly SecretSnapshot[] {
155
+ return [...this.states.values()].flatMap(state => state.record && state.record.state !== 'terminated' ? [{
156
+ record: state.record,
157
+ controlToken: state.controlToken,
158
+ ownershipToken: state.ownershipToken,
159
+ }] : []);
160
+ }
161
+
162
+ private send(state: ControlState, frame: SupervisorFramePayload): boolean {
163
+ if (!state.socket || state.socket.destroyed || !state.authenticated) return false;
164
+ state.supervisorSeq += 1;
165
+ const value = {
166
+ version: CONTROL_PROTOCOL_VERSION,
167
+ runtimeId: state.runtimeId,
168
+ seq: state.supervisorSeq,
169
+ ...frame,
170
+ } as SupervisorFrame;
171
+ state.socket.write(encodeControlFrame(value));
172
+ return true;
173
+ }
174
+
175
+ private queueState(state: ControlState, value: 'ready' | 'busy', requestId?: string): void {
176
+ state.observedState = value;
177
+ state.observedRequestId = value === 'busy' ? requestId : undefined;
178
+ state.pendingState = { state: value, ...(requestId ? { requestId } : {}) };
179
+ state.update = state.update.then(async () => {
180
+ if (!state.record || !state.pendingState) return;
181
+ const pending = state.pendingState;
182
+ state.pendingState = undefined;
183
+ await this.onState(state.runtimeId, pending.state, pending.requestId);
184
+ }).catch(() => { state.socket?.destroy(); });
185
+ }
186
+
187
+ private accept(state: ControlState, socket: Socket, frame: WorkerFrame): void {
188
+ if (frame.runtimeId !== state.runtimeId || frame.seq <= state.lastWorkerSeq) {
189
+ throw Object.assign(new Error('Worker frame is stale or targets another runtime.'), { code: 'FENCED' });
190
+ }
191
+ state.lastWorkerSeq = frame.seq;
192
+ state.lastPong = this.now();
193
+ if (frame.type === 'hello') return;
194
+ if (!state.authenticated || state.socket !== socket) throw new Error('Unauthenticated worker control frame.');
195
+ if (frame.ownerEpoch !== this.owner().ownerEpoch) {
196
+ throw Object.assign(new Error('Worker frame owner epoch has been fenced.'), { code: 'FENCED' });
197
+ }
198
+ if (frame.type === 'ready') this.queueState(state, 'ready');
199
+ else if (frame.type === 'state') this.queueState(state, frame.state, frame.requestId);
200
+ }
201
+
202
+ private connection(socket: Socket): void {
203
+ this.sockets.add(socket);
204
+ const decoder = new NdjsonFrameDecoder<WorkerFrame>(assertWorkerFrame);
205
+ const binding: { state?: ControlState } = {};
206
+ socket.on('data', chunk => {
207
+ try {
208
+ for (const frame of decoder.push(chunk)) {
209
+ if (!binding.state) {
210
+ if (frame.type !== 'hello') throw new Error('First worker frame must authenticate.');
211
+ const state = this.states.get(frame.runtimeId);
212
+ if (!state || !controlTokenMatches(state.controlToken, frame.token) ||
213
+ frame.ownerProcessNonce !== this.owner().ownerProcessNonce) {
214
+ throw Object.assign(new Error('Worker control authentication failed.'), { code: 'FENCED' });
215
+ }
216
+ if (state.authenticated && frame.seq <= state.lastWorkerSeq) {
217
+ throw Object.assign(new Error('Worker hello sequence is stale.'), { code: 'FENCED' });
218
+ }
219
+ if (!state.authenticated) state.lastWorkerSeq = 0;
220
+ state.socket?.destroy();
221
+ state.socket = socket;
222
+ state.authenticated = true;
223
+ binding.state = state;
224
+ this.accept(state, socket, frame);
225
+ this.send(state, { type: 'hello_ack', owner: this.owner() });
226
+ } else this.accept(binding.state, socket, frame);
227
+ }
228
+ } catch { socket.destroy(); }
229
+ });
230
+ socket.on('error', () => {});
231
+ socket.on('close', () => {
232
+ this.sockets.delete(socket);
233
+ const state = binding.state;
234
+ if (state?.socket === socket) {
235
+ state.socket = undefined;
236
+ state.authenticated = false;
237
+ }
238
+ });
239
+ }
240
+
241
+ private tick(): void {
242
+ const timestamp = this.now();
243
+ for (const state of this.states.values()) {
244
+ if (!state.authenticated) continue;
245
+ if (timestamp - state.lastPong > this.heartbeatMs * 3) {
246
+ state.socket?.destroy();
247
+ void this.onLost(state.runtimeId).catch(() => {});
248
+ continue;
249
+ }
250
+ this.send(state, { type: 'ping', nonce: randomUUID() });
251
+ }
252
+ }
253
+
254
+ async start(): Promise<void> {
255
+ if (this.started) return;
256
+ const existing = await lstat(this.socketPath).catch(error => {
257
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
258
+ throw error;
259
+ });
260
+ if (existing) throw Object.assign(new Error(`Supervisor socket already exists: ${this.socketPath}`), { code: 'ALREADY_EXISTS' });
261
+ const server = createServer(socket => this.connection(socket));
262
+ await new Promise<void>((resolvePromise, reject) => {
263
+ server.once('error', reject);
264
+ server.listen(this.socketPath, () => {
265
+ server.off('error', reject);
266
+ resolvePromise();
267
+ });
268
+ });
269
+ const verifySocket = async (): Promise<{ readonly dev: number; readonly ino: number }> => {
270
+ const opened = await lstat(this.socketPath);
271
+ try {
272
+ if (!opened.isSocket() || opened.isSymbolicLink() || (process.getuid && opened.uid !== process.getuid())) {
273
+ throw Object.assign(new Error('Unsafe supervisor socket.'), { code: 'UNSAFE_STORAGE' });
274
+ }
275
+ await chmod(this.socketPath, 0o600);
276
+ const secured = await lstat(this.socketPath);
277
+ if (!secured.isSocket() || secured.dev !== opened.dev || secured.ino !== opened.ino ||
278
+ (secured.mode & 0o077) !== 0 || (process.getuid && secured.uid !== process.getuid())) {
279
+ throw Object.assign(new Error('Unsafe supervisor socket.'), { code: 'UNSAFE_STORAGE' });
280
+ }
281
+ return { dev: secured.dev, ino: secured.ino };
282
+ } catch (error) {
283
+ const current = await lstat(this.socketPath).catch(() => undefined);
284
+ if (current?.isSocket() && current.dev === opened.dev && current.ino === opened.ino) {
285
+ await unlink(this.socketPath).catch(() => {});
286
+ }
287
+ throw error;
288
+ }
289
+ };
290
+ const inode = await verifySocket().catch(async error => {
291
+ await new Promise<void>(resolvePromise => server.close(() => resolvePromise()));
292
+ throw error;
293
+ });
294
+ this.inode = inode;
295
+ this.server = server;
296
+ this.started = true;
297
+ const timer = setInterval(() => this.tick(), this.heartbeatMs);
298
+ timer.unref();
299
+ this.heartbeat = timer;
300
+ }
301
+
302
+ cancel(runtimeId: string, requestId: string): boolean {
303
+ const state = this.states.get(runtimeId);
304
+ return state ? this.send(state, { type: 'cancel_request', requestId }) : false;
305
+ }
306
+
307
+ prepareShutdown(runtimeId: string, reason: ShutdownReason, deadlineAt: string): Promise<boolean> {
308
+ const state = this.states.get(runtimeId);
309
+ return Promise.resolve(state ? this.send(state, { type: 'prepare_shutdown', reason, deadlineAt }) : false);
310
+ }
311
+
312
+ async stop(): Promise<void> {
313
+ if (!this.started) return;
314
+ this.started = false;
315
+ if (this.heartbeat) clearInterval(this.heartbeat);
316
+ this.heartbeat = undefined;
317
+ for (const socket of this.sockets) socket.destroy();
318
+ this.sockets.clear();
319
+ for (const state of this.states.values()) {
320
+ state.socket = undefined;
321
+ state.authenticated = false;
322
+ }
323
+ const server = this.server;
324
+ this.server = undefined;
325
+ if (server) await new Promise<void>(resolvePromise => server.close(() => resolvePromise()));
326
+ const info = await lstat(this.socketPath).catch(() => undefined);
327
+ if (info && this.inode && info.dev === this.inode.dev && info.ino === this.inode.ino && info.isSocket()) {
328
+ await unlink(this.socketPath);
329
+ }
330
+ this.inode = undefined;
331
+ }
332
+ }
333
+
334
+ /** Unix socket paths fail above ~104 bytes (macOS); the hub refuses more than this. */
335
+ export const SOCKET_PATH_MAX = 100;
336
+
337
+ /**
338
+ * The control socket lives in the store's private control directory. A home
339
+ * directory deep enough to push that path past the Unix limit falls back to a
340
+ * short private directory under /tmp, owned by this user and mode 0700 (both
341
+ * checked before listening). Without this, no Expert could ever launch.
342
+ */
343
+ export function socketName(paths: TeamPaths, teamId: string, ownerSessionId: string, processNonce: string): string {
344
+ const hash = createHash('sha256').update(`${teamId}\0${ownerSessionId}\0${processNonce}`).digest('hex').slice(0, 32);
345
+ const preferred = join(paths.control(), `supervisor-${hash}.sock`);
346
+ if (Buffer.byteLength(preferred, 'utf8') <= SOCKET_PATH_MAX) return preferred;
347
+ return join(shortSocketRoot(), `${hash}.sock`);
348
+ }
349
+
350
+ export const shortSocketRoot = (): string => join('/tmp', `prjct-team-${process.getuid?.() ?? 'user'}`);
351
+
352
+ export class TeamSupervisor {
353
+ readonly paths: TeamPaths;
354
+ readonly teams: TeamStore;
355
+ readonly runtimes: RuntimeStore;
356
+ readonly tmux: TmuxAdapter;
357
+ private readonly processes: ProcessController;
358
+ private readonly now: () => number;
359
+ private ownerValue: OwnerIdentity;
360
+ private readonly hub: ControlHub;
361
+ private readonly shutdown: RuntimeShutdown;
362
+ private readonly stops = new Map<string, Promise<ShutdownResult>>();
363
+ private startPromise?: Promise<void>;
364
+ private closePromise?: Promise<void>;
365
+ private closed = false;
366
+ private handedOff = false;
367
+
368
+ constructor(readonly options: SupervisorOptions) {
369
+ this.paths = options.paths ?? new TeamPaths();
370
+ this.teams = options.teams ?? new TeamStore(this.paths);
371
+ this.runtimes = options.runtimes ?? new RuntimeStore(this.paths);
372
+ this.processes = options.processes ?? defaultProcessController;
373
+ this.tmux = options.tmux ?? new TmuxAdapter(undefined, this.processes);
374
+ this.now = options.now ?? Date.now;
375
+ this.ownerValue = {
376
+ ownerSessionId: options.ownerSessionId,
377
+ ownerInstanceId: options.ownerInstanceId ?? randomUUID(),
378
+ ownerProcessNonce: options.ownerProcessNonce ?? ownerProcessNonce(),
379
+ ownerEpoch: options.ownerEpoch ?? 1,
380
+ };
381
+ const controlSocket = options.socketPath ?? socketName(this.paths, options.teamId, options.ownerSessionId, this.ownerValue.ownerProcessNonce);
382
+ this.hub = new ControlHub(
383
+ controlSocket,
384
+ () => this.ownerValue,
385
+ this.now,
386
+ options.heartbeatMs ?? 5_000,
387
+ (runtimeId, state, requestId) => this.acceptState(runtimeId, state, requestId),
388
+ runtimeId => this.handleHeartbeatLoss(runtimeId),
389
+ );
390
+ this.shutdown = new RuntimeShutdown(this.hub, this.tmux, this.processes, { timings: options.shutdownTimings });
391
+ }
392
+
393
+ get owner(): OwnerIdentity { return this.ownerValue; }
394
+ get socketPath(): string { return this.hub.socketPath; }
395
+
396
+ start(): Promise<void> {
397
+ if (this.startPromise) return this.startPromise;
398
+ this.startPromise = (async () => {
399
+ await ensurePrivateTree(this.paths.root, 'teams');
400
+ await ensurePrivateTree(this.paths.root, 'control');
401
+ const socketDirectory = dirname(this.hub.socketPath);
402
+ // Only the short fallback is ours to create and check; a caller-chosen
403
+ // socket path lives wherever the caller decided.
404
+ if (socketDirectory === shortSocketRoot()) await ensurePrivateDirectory(socketDirectory);
405
+ await this.hub.start();
406
+ })();
407
+ return this.startPromise;
408
+ }
409
+
410
+ private current(runtime: OwnedRuntime): boolean {
411
+ return !this.closed && sameOwner(runtime.owner, this.ownerValue);
412
+ }
413
+
414
+ private async acceptState(runtimeId: string, state: 'ready' | 'busy', requestId?: string): Promise<void> {
415
+ const record = this.hub.record(runtimeId);
416
+ if (!record || !this.current(record) || ['stopping', 'terminated', 'lost'].includes(record.state)) return;
417
+ const timestamp = new Date(this.now()).toISOString();
418
+ const next = await this.runtimes.update(record.teamId, runtimeId, this.ownerValue, current => {
419
+ const { activeRequestId: _activeRequestId, ...idle } = current;
420
+ return {
421
+ ...idle,
422
+ state,
423
+ ...(state === 'busy' && requestId ? { activeRequestId: requestId } : {}),
424
+ updatedAt: timestamp,
425
+ };
426
+ });
427
+ this.hub.updateRecord(next);
428
+ }
429
+
430
+ private async handleHeartbeatLoss(runtimeId: string): Promise<void> {
431
+ await this.markLost(runtimeId);
432
+ await this.stop(runtimeId, 'owner_lost').catch(() => undefined);
433
+ }
434
+
435
+ private async markLost(runtimeId: string): Promise<void> {
436
+ const record = this.hub.record(runtimeId);
437
+ if (!record || !this.current(record) || ['stopping', 'terminated', 'lost'].includes(record.state)) return;
438
+ const next = await this.runtimes.update(record.teamId, runtimeId, this.ownerValue, current => {
439
+ const { activeRequestId: _activeRequestId, ...idle } = current;
440
+ return { ...idle, state: 'lost', updatedAt: new Date(this.now()).toISOString() };
441
+ });
442
+ this.hub.updateRecord(next);
443
+ }
444
+
445
+ async launch(input: SupervisorLaunch): Promise<OwnedRuntime> {
446
+ await this.start();
447
+ if (this.closed || this.handedOff) throw Object.assign(new Error('Supervisor does not accept launches.'), { code: 'SUPERVISOR_CLOSED' });
448
+ const [team, member] = await Promise.all([
449
+ this.teams.read(this.options.teamId),
450
+ this.teams.readMember(this.options.teamId, input.memberId),
451
+ ]);
452
+ if (!team || team.state !== 'open') throw Object.assign(new Error('Team is not open for supervised launches.'), { code: 'TEAM_CLOSED' });
453
+ if (!member || member.state !== 'active' || member.kind !== 'supervised') {
454
+ throw Object.assign(new Error('Only an active supervised member may be launched.'), { code: 'FENCED' });
455
+ }
456
+ if (!await this.tmux.available()) throw new Error('Supervised peers require tmux.');
457
+ const runtimeId = randomUUID();
458
+ const controlToken = randomBytes(32).toString('hex');
459
+ const ownershipToken = randomBytes(32).toString('hex');
460
+ this.hub.register(runtimeId, controlToken, ownershipToken);
461
+ const launch: TmuxLaunchOptions = {
462
+ runtimeId,
463
+ owner: this.ownerValue,
464
+ cwd: input.cwd,
465
+ command: input.command,
466
+ environment: input.environment,
467
+ controlSocket: this.socketPath,
468
+ controlToken,
469
+ ownershipToken,
470
+ workerMembership: input.workerMembership,
471
+ autoRequests: input.autoRequests,
472
+ };
473
+ const process = await this.tmux.launch(launch).catch(error => {
474
+ this.hub.unregister(runtimeId);
475
+ throw error;
476
+ });
477
+ const timestamp = new Date(this.now()).toISOString();
478
+ const runtime: OwnedRuntime = {
479
+ schemaVersion: 2,
480
+ runtimeId,
481
+ teamId: this.options.teamId,
482
+ memberId: input.memberId,
483
+ owner: this.ownerValue,
484
+ processPid: process.identity.processPid,
485
+ processStartToken: process.identity.processStartToken,
486
+ processGroupId: process.identity.processGroupId,
487
+ tmuxSession: process.session,
488
+ tmuxOwnershipTokenHash: process.ownershipTokenHash,
489
+ cwd: input.cwd,
490
+ state: 'starting',
491
+ createdAt: timestamp,
492
+ updatedAt: timestamp,
493
+ };
494
+ try {
495
+ await this.runtimes.create(runtime);
496
+ this.hub.setRecord(runtime);
497
+ return runtime;
498
+ } catch (error) {
499
+ this.hub.setRecord(runtime);
500
+ const stopped = await this.shutdown.stop(runtime, 'stop').catch(() => ({
501
+ runtimeId,
502
+ status: 'blocked' as const,
503
+ phase: 'blocked' as const,
504
+ }));
505
+ if (stopped.status === 'terminated') this.hub.unregister(runtimeId);
506
+ if (stopped.status === 'blocked') {
507
+ throw Object.assign(new Error(`Runtime launch storage failed and cleanup was blocked: ${(error as Error).message}`), {
508
+ code: 'CLEANUP_BLOCKED', cause: error,
509
+ });
510
+ }
511
+ throw error;
512
+ }
513
+ }
514
+
515
+ cancelRequest(runtimeId: string, requestId: string): boolean {
516
+ const record = this.hub.record(runtimeId);
517
+ return !!record && this.current(record) && !['stopping', 'lost', 'terminated'].includes(record.state) &&
518
+ this.hub.requestMatches(runtimeId, requestId) && this.hub.cancel(runtimeId, requestId);
519
+ }
520
+
521
+ stop(runtimeId: string, reason: ShutdownReason = 'stop'): Promise<ShutdownResult> {
522
+ const existing = this.stops.get(runtimeId);
523
+ if (existing) return existing;
524
+ const operation = this.stopInner(runtimeId, reason).finally(() => { this.stops.delete(runtimeId); });
525
+ this.stops.set(runtimeId, operation);
526
+ return operation;
527
+ }
528
+
529
+ private async stopInner(runtimeId: string, reason: ShutdownReason): Promise<ShutdownResult> {
530
+ const stored = await this.runtimes.read(this.options.teamId, runtimeId);
531
+ if (!stored) throw Object.assign(new Error(`Unknown owned runtime "${runtimeId}".`), { code: 'NOT_FOUND' });
532
+ if (!sameOwner(stored.owner, this.ownerValue)) throw Object.assign(new Error('Runtime belongs to another owner.'), { code: 'FENCED' });
533
+ if (stored.state === 'terminated') return { runtimeId, status: 'terminated', phase: 'terminated' };
534
+ if (stored.activeRequestId) this.hub.cancel(runtimeId, stored.activeRequestId);
535
+ const stopping = stored.state === 'stopping' ? stored : await this.runtimes.update(stored.teamId, runtimeId, this.ownerValue, current => {
536
+ const { activeRequestId: _activeRequestId, ...idle } = current;
537
+ return { ...idle, state: 'stopping', updatedAt: new Date(this.now()).toISOString() };
538
+ });
539
+ this.hub.updateRecord(stopping);
540
+ const result = await this.shutdown.stop(stopping, reason);
541
+ const final = await this.runtimes.update(stopping.teamId, runtimeId, this.ownerValue, current => {
542
+ const { activeRequestId: _activeRequestId, ...idle } = current;
543
+ return {
544
+ ...idle,
545
+ state: result.status === 'terminated' ? 'terminated' : 'lost',
546
+ updatedAt: new Date(this.now()).toISOString(),
547
+ };
548
+ });
549
+ this.hub.updateRecord(final);
550
+ return result;
551
+ }
552
+
553
+ async stopAll(reason: ShutdownReason = 'close'): Promise<readonly ShutdownResult[]> {
554
+ const records = (await this.runtimes.list(this.options.teamId))
555
+ .filter(runtime => sameOwner(runtime.owner, this.ownerValue) && runtime.state !== 'terminated');
556
+ const settled = await Promise.allSettled(records.map(runtime => this.stop(runtime.runtimeId, reason)));
557
+ const failures = settled.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
558
+ if (failures.length > 0) throw new AggregateError(failures, 'One or more supervised runtimes failed to stop.');
559
+ return settled.flatMap(result => result.status === 'fulfilled' ? [result.value] : []);
560
+ }
561
+
562
+ async reconcile(): Promise<readonly ReconcileResult[]> {
563
+ await this.start();
564
+ const records = await this.runtimes.list(this.options.teamId);
565
+ const outcomes = await Promise.all(records.map(async runtime => {
566
+ const current = runtime;
567
+ if (current.state === 'terminated') return { runtimeId: runtime.runtimeId, status: 'terminated' as const };
568
+ if (!sameOwner(current.owner, this.ownerValue)) return { runtimeId: runtime.runtimeId, status: 'foreign' as const };
569
+ const expected = {
570
+ processPid: current.processPid,
571
+ processStartToken: current.processStartToken,
572
+ processGroupId: current.processGroupId ?? current.processPid,
573
+ };
574
+ const alive = sameProcess(expected, await this.processes.inspect(expected.processPid));
575
+ const metadata = alive ? await this.tmux.metadataMatches(current) : false;
576
+ if (alive && metadata && this.hub.has(runtime.runtimeId)) {
577
+ return { runtimeId: runtime.runtimeId, status: 'owned' as const };
578
+ }
579
+ await this.markLost(runtime.runtimeId);
580
+ const stopped = await this.stop(runtime.runtimeId, 'owner_lost');
581
+ return { runtimeId: runtime.runtimeId, status: stopped.status === 'terminated' ? 'terminated' as const : 'lost' as const };
582
+ }));
583
+ return outcomes;
584
+ }
585
+
586
+ async prepareHandoff(): Promise<SupervisorHandoff> {
587
+ await this.start();
588
+ if (this.closed || this.handedOff) throw new Error('Supervisor cannot hand off twice.');
589
+ const to: OwnerIdentity = {
590
+ ...this.ownerValue,
591
+ ownerInstanceId: randomUUID(),
592
+ ownerEpoch: this.ownerValue.ownerEpoch + 1,
593
+ };
594
+ const handoff: SupervisorHandoff = {
595
+ teamId: this.options.teamId,
596
+ from: this.ownerValue,
597
+ to,
598
+ socketPath: this.socketPath,
599
+ runtimes: this.hub.snapshots(),
600
+ };
601
+ await this.hub.stop();
602
+ this.handedOff = true;
603
+ this.closed = true;
604
+ return handoff;
605
+ }
606
+
607
+ async adopt(handoff: SupervisorHandoff): Promise<void> {
608
+ if (handoff.teamId !== this.options.teamId || !sameOwner(handoff.to, this.ownerValue) ||
609
+ handoff.from.ownerSessionId !== this.ownerValue.ownerSessionId ||
610
+ handoff.from.ownerProcessNonce !== this.ownerValue.ownerProcessNonce || handoff.socketPath !== this.socketPath) {
611
+ throw Object.assign(new Error('Supervisor handoff does not match this owner process and session.'), { code: 'FENCED' });
612
+ }
613
+ for (const item of handoff.runtimes) {
614
+ const stored = await this.runtimes.read(item.record.teamId, item.record.runtimeId);
615
+ if (!stored) throw Object.assign(new Error(`Missing handoff runtime "${item.record.runtimeId}".`), { code: 'NOT_FOUND' });
616
+ const targetRecord = { ...stored, owner: handoff.to };
617
+ const record = sameOwner(stored.owner, handoff.to) ? stored : await (async () => {
618
+ if (!sameOwner(stored.owner, handoff.from)) {
619
+ throw Object.assign(new Error('Stored runtime owner changed during handoff.'), { code: 'FENCED' });
620
+ }
621
+ if (await this.tmux.metadataMatches(stored)) await this.tmux.reassign(stored, handoff.to);
622
+ else if (!await this.tmux.metadataMatches(targetRecord)) {
623
+ throw Object.assign(new Error('Tmux metadata changed during handoff.'), { code: 'FENCED' });
624
+ }
625
+ return this.runtimes.handoff(
626
+ item.record.teamId, item.record.runtimeId, handoff.from, handoff.to, new Date(this.now()).toISOString(),
627
+ );
628
+ })();
629
+ if (!await this.tmux.metadataMatches(record)) {
630
+ throw Object.assign(new Error('Adopted tmux metadata does not match the new owner.'), { code: 'FENCED' });
631
+ }
632
+ this.hub.register(record.runtimeId, item.controlToken, item.ownershipToken, record);
633
+ }
634
+ await this.start();
635
+ }
636
+
637
+ close(): Promise<void> {
638
+ if (this.closePromise) return this.closePromise;
639
+ this.closed = true;
640
+ const cleanup = (async () => {
641
+ try {
642
+ if (!this.handedOff) {
643
+ const outcomes = await this.stopAll('close');
644
+ const blocked = outcomes.filter(outcome => outcome.status === 'blocked');
645
+ if (blocked.length > 0) {
646
+ throw new Error(`Supervisor shutdown blocked for ${blocked.length} runtime(s).`);
647
+ }
648
+ }
649
+ } finally { await this.hub.stop(); }
650
+ })();
651
+ this.closePromise = cleanup.catch(error => {
652
+ this.closePromise = undefined;
653
+ throw error;
654
+ });
655
+ return this.closePromise;
656
+ }
657
+ }