@ours.network/fleet 1.2.0-nightly.53 → 1.2.0-nightly.55

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 (108) hide show
  1. package/README.md +22 -12
  2. package/dist/agent-ours/bridge.js +10 -3
  3. package/dist/agent-ours/file-rpc.d.ts +8 -0
  4. package/dist/agent-ours/file-rpc.js +11 -1
  5. package/dist/agent-ours/harness.js +47 -3
  6. package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
  7. package/dist/agent-ours/mcp-endpoint.js +4 -0
  8. package/dist/agent-ours/service.d.ts +6 -1
  9. package/dist/agent-ours/service.js +73 -8
  10. package/dist/application/conversation-history.d.ts +2 -0
  11. package/dist/application/conversation-history.js +25 -2
  12. package/dist/application/log-service.js +8 -8
  13. package/dist/application/role-creation-service.js +2 -1
  14. package/dist/application/role-removal-service.js +7 -1
  15. package/dist/application/role-repository.js +17 -0
  16. package/dist/application/supervisor-ours-tools.js +3 -1
  17. package/dist/application/task-layouts.js +12 -2
  18. package/dist/application/task-room-service.js +19 -17
  19. package/dist/application/types.d.ts +2 -0
  20. package/dist/build-info.json +4 -4
  21. package/dist/cli.js +31 -3
  22. package/dist/config.d.ts +11 -0
  23. package/dist/config.js +13 -2
  24. package/dist/file-delivery/reader.d.ts +17 -0
  25. package/dist/file-delivery/reader.js +94 -0
  26. package/dist/file-delivery/store.d.ts +15 -0
  27. package/dist/file-delivery/store.js +124 -0
  28. package/dist/file-delivery/types.d.ts +19 -0
  29. package/dist/file-delivery/types.js +1 -0
  30. package/dist/harness/agent-session.d.ts +2 -0
  31. package/dist/harness/claude-code-session.js +1 -1
  32. package/dist/harness/codex-app-server-proxy.js +6 -2
  33. package/dist/harness/codex-file-inventory.d.ts +15 -0
  34. package/dist/harness/codex-file-inventory.js +113 -0
  35. package/dist/harness/codex-session.js +2 -2
  36. package/dist/harness/codex.js +2 -1
  37. package/dist/isolation/policy.js +4 -1
  38. package/dist/managed-recovery.d.ts +13 -0
  39. package/dist/managed-recovery.js +31 -0
  40. package/dist/ops.d.ts +2 -0
  41. package/dist/ops.js +15 -2
  42. package/dist/rooms-tasks/cli.js +21 -0
  43. package/dist/rooms-tasks/close.d.ts +2 -0
  44. package/dist/rooms-tasks/close.js +66 -1
  45. package/dist/rooms-tasks/deletion.js +10 -0
  46. package/dist/rooms-tasks/erasure.js +4 -2
  47. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  48. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  49. package/dist/rooms-tasks/layout-service.d.ts +1 -0
  50. package/dist/rooms-tasks/layout-service.js +31 -13
  51. package/dist/rooms-tasks/layout.d.ts +7 -0
  52. package/dist/rooms-tasks/layout.js +35 -0
  53. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  54. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  55. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  56. package/dist/rooms-tasks/member-ownership.js +40 -0
  57. package/dist/rooms-tasks/provision.js +26 -1
  58. package/dist/rooms-tasks/recovery.d.ts +19 -0
  59. package/dist/rooms-tasks/recovery.js +110 -0
  60. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  61. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  62. package/dist/rooms-tasks/task-state.d.ts +1 -0
  63. package/dist/rooms-tasks/task-state.js +16 -1
  64. package/dist/rooms-tasks/types.d.ts +3 -0
  65. package/dist/rooms-tasks/workspace.d.ts +2 -0
  66. package/dist/rooms-tasks/workspace.js +5 -0
  67. package/dist/runner.d.ts +6 -0
  68. package/dist/runner.js +161 -22
  69. package/dist/session/acp.d.ts +5 -0
  70. package/dist/session/acp.js +68 -8
  71. package/dist/session/arbiter.d.ts +5 -0
  72. package/dist/session/arbiter.js +16 -2
  73. package/dist/session/codex-app-server.d.ts +1 -0
  74. package/dist/session/codex-app-server.js +5 -0
  75. package/dist/session/conversation-types.d.ts +4 -2
  76. package/dist/session/types.d.ts +3 -0
  77. package/dist/spawn.d.ts +5 -1
  78. package/dist/spawn.js +32 -3
  79. package/dist/startup-readiness.d.ts +1 -1
  80. package/dist/startup-readiness.js +3 -3
  81. package/dist/supervisor/adoption.d.ts +8 -0
  82. package/dist/supervisor/adoption.js +78 -0
  83. package/dist/supervisor/catalog.d.ts +62 -0
  84. package/dist/supervisor/catalog.js +254 -0
  85. package/dist/supervisor/fleet.d.ts +8 -0
  86. package/dist/supervisor/fleet.js +89 -0
  87. package/dist/supervisor/index.js +3 -6
  88. package/dist/supervisor/launchd.d.ts +1 -1
  89. package/dist/supervisor/launchd.js +24 -12
  90. package/dist/supervisor/legacy.d.ts +35 -0
  91. package/dist/supervisor/legacy.js +524 -0
  92. package/dist/supervisor/manager.d.ts +15 -0
  93. package/dist/supervisor/manager.js +258 -0
  94. package/dist/supervisor/scope.d.ts +4 -0
  95. package/dist/supervisor/scope.js +10 -0
  96. package/dist/supervisor/systemd.d.ts +2 -2
  97. package/dist/supervisor/systemd.js +14 -14
  98. package/dist/task-supervision.d.ts +9 -0
  99. package/dist/task-supervision.js +145 -0
  100. package/dist/temp-lifecycle.d.ts +17 -7
  101. package/dist/temp-lifecycle.js +85 -53
  102. package/dist/temp-supervisor-recovery.d.ts +1 -1
  103. package/dist/temp-supervisor-recovery.js +2 -2
  104. package/dist/web/server.js +24 -0
  105. package/docs/acp-files.md +102 -0
  106. package/docs/fleet-migration.md +54 -0
  107. package/docs/task-workspaces.md +26 -0
  108. package/package.json +7 -3
@@ -54,6 +54,11 @@ export declare class RoleTurnArbiter implements AgentSession {
54
54
  */
55
55
  retireStalledAdmission(): void;
56
56
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
57
+ /** Inbox traffic must not cancel or steer an active human console/voice turn.
58
+ * Queue it once; the monitor commits its cursor after that turn succeeds.
59
+ * Other origins retain their configured monitor policy.
60
+ */
61
+ private monitorAdmissionOptions;
57
62
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
58
63
  /**
59
64
  * Do not hold `exclusive` while ACP waits for a tool boundary: permission
@@ -90,8 +90,20 @@ export class RoleTurnArbiter {
90
90
  retired.retire();
91
91
  }
92
92
  queuePrompt(text, options = {}) {
93
- return this.exclusive(async () => { if (this.retiring)
94
- throw new Error('Chat is closing'); return this.track(await this.session.queuePrompt(text, options)); });
93
+ return this.exclusive(async () => {
94
+ if (this.retiring)
95
+ throw new Error('Chat is closing');
96
+ return this.track(await this.session.queuePrompt(text, this.monitorAdmissionOptions(options)));
97
+ });
98
+ }
99
+ /** Inbox traffic must not cancel or steer an active human console/voice turn.
100
+ * Queue it once; the monitor commits its cursor after that turn succeeds.
101
+ * Other origins retain their configured monitor policy.
102
+ */
103
+ monitorAdmissionOptions(options) {
104
+ return options.origin?.kind === 'fleet-monitor'
105
+ && this.session.snapshot().activePromptSource === 'owner-admin-console'
106
+ ? { ...options, interrupt: false, steer: false } : options;
95
107
  }
96
108
  async submitPrompt(text, options = {}) {
97
109
  return (await this.queuePrompt(text, options)).completion;
@@ -103,6 +115,8 @@ export class RoleTurnArbiter {
103
115
  submitPromptAfterTool(text, options = {}) {
104
116
  if (this.retiring)
105
117
  return Promise.reject(new Error('Chat is closing'));
118
+ if (this.monitorAdmissionOptions(options) !== options)
119
+ return this.submitPrompt(text, options);
106
120
  return this.session.submitPromptAfterTool?.(text, options)
107
121
  ?? this.session.submitPrompt(text, { ...options, interrupt: false, steer: false });
108
122
  }
@@ -11,6 +11,7 @@ export interface CodexAppServerSessionOptions {
11
11
  env: Record<string, string>;
12
12
  stateDir: string;
13
13
  mode: 'fresh' | 'resume';
14
+ requireResume?: boolean;
14
15
  permissions: CommonPermissions;
15
16
  permissionMode: NonNullable<SessionSnapshot['permissionMode']>;
16
17
  model?: string | null;
@@ -173,6 +173,7 @@ export class CodexAppServerSession {
173
173
  alive: this.isAlive(),
174
174
  readiness: this.readiness,
175
175
  sessionId: this.threadId,
176
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
176
177
  lastError: this.lastError,
177
178
  pendingPermissionId: this.pendingPermissions.keys().next().value,
178
179
  runtimeModel: this.runtimeModel,
@@ -406,6 +407,8 @@ export class CodexAppServerSession {
406
407
  this.transport.notify('initialized');
407
408
  const persisted = this.options.mode === 'resume' && existsSync(this.threadFile)
408
409
  ? readFileSync(this.threadFile, 'utf8').trim() : '';
410
+ if (this.options.requireResume && !persisted)
411
+ throw new Error('TASK_CONTEXT_RESUME_UNAVAILABLE');
409
412
  const common = {
410
413
  cwd: this.options.cwd,
411
414
  approvalPolicy: this.options.approvalPolicy,
@@ -425,6 +428,8 @@ export class CodexAppServerSession {
425
428
  this.threadId = string(thread?.id);
426
429
  if (!this.threadId)
427
430
  throw new Error('Codex app-server did not return a thread id');
431
+ if (this.options.requireResume && this.threadId !== persisted)
432
+ throw new Error('TASK_CONTEXT_RESUME_MISMATCH');
428
433
  writeFileSync(this.threadFile, this.threadId + '\n', { mode: 0o600 });
429
434
  const model = string(response.model) ?? this.options.model ?? undefined;
430
435
  if (model)
@@ -8,7 +8,7 @@ import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.
8
8
  * file touches the wire: transport updates are reduced into these shapes by a
9
9
  * session adapter, and the store assigns `seq`/`eventId`/timestamps.
10
10
  */
11
- export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
+ export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
12
12
  /** A well-formed transport update this version cannot represent. Bounded, never a crash. */
13
13
  | 'unsupported';
14
14
  /** Where a conversation record came from. Typed provenance, never prompt text. */
@@ -228,7 +228,9 @@ export interface BoundedJson {
228
228
  digest?: string;
229
229
  redacted?: true;
230
230
  }
231
- export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
231
+ export type ConversationPayload = {
232
+ attachment: import('../file-delivery/types.js').DeliveredFile;
233
+ } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
232
234
  export interface ConversationEventV1 {
233
235
  schemaVersion: 1;
234
236
  roleId: string;
@@ -186,6 +186,8 @@ export interface SessionSnapshot {
186
186
  backend: SessionBackendId;
187
187
  alive: boolean;
188
188
  readiness: SessionReadiness;
189
+ /** Typed source of the active prompt; excludes prompt bodies and command IDs. */
190
+ activePromptSource?: PromptOrigin['kind'];
189
191
  sessionId?: string;
190
192
  lastError?: string;
191
193
  pendingPermissionId?: string;
@@ -284,6 +286,7 @@ export interface ConversationHandlePage {
284
286
  * on any concrete transport which implements it.
285
287
  */
286
288
  export interface AgentSession {
289
+ sendFileToChat?: (input: import('../file-delivery/types.js').FileDeliveryInput, signal: AbortSignal, copy: import('../file-delivery/types.js').CopyChatFile, policy: import('../config.js').CommonPermissions) => Promise<import('../file-delivery/types.js').DeliveredFile>;
287
290
  readonly backend: SessionBackendId;
288
291
  readonly pid: number;
289
292
  /** Live capabilities; optional only for source compatibility with external adapters. */
package/dist/spawn.d.ts CHANGED
@@ -6,7 +6,7 @@ import { type CreationDeps, type CreationProvenance } from './creation.js';
6
6
  import './harness/claude-code.js';
7
7
  import './harness/codex.js';
8
8
  import './harness/hermes.js';
9
- import { type SupervisorLauncher } from './temp-lifecycle.js';
9
+ import { type SupervisorLauncher, type TaskSupervisorOwner } from './temp-lifecycle.js';
10
10
  /**
11
11
  * The provenance record written by the most recent spawn in this process, so
12
12
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -15,6 +15,8 @@ export declare let lastProvenance: CreationProvenance | undefined;
15
15
  export interface SpawnOpts {
16
16
  name: string;
17
17
  temp?: boolean;
18
+ /** Internal exact task-layout ownership, never a standalone manifest entry. */
19
+ taskOwner?: TaskSupervisorOwner;
18
20
  brain?: AgentSelection;
19
21
  role?: AgentSelection;
20
22
  /** Trusted canonical definition used by room provisioning; never a second schema. */
@@ -85,3 +87,5 @@ export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: C
85
87
  export type { SupervisorLauncher } from './temp-lifecycle.js';
86
88
  /** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
87
89
  export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
90
+ /** Resume the exact durable task launch; no creation, invite issuance or state rotation. */
91
+ export declare function resumeTaskSupervisor(name: string, binPath: string): Promise<void>;
package/dist/spawn.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { taskSupervisorMayRun } from './task-supervision.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { storeRoomSecret, storeTemporaryLaunch, preparePermanentAssignment } from './agent-ours/service.js';
3
4
  import { ensureWorkspace } from './rooms-tasks/workspace.js';
@@ -16,7 +17,8 @@ import { recordGeneratedAgentSource } from './generated-agent-source.js';
16
17
  import './harness/claude-code.js';
17
18
  import './harness/codex.js';
18
19
  import './harness/hermes.js';
19
- import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, } from './temp-lifecycle.js';
20
+ import { getAdapter } from './harness/registry.js';
21
+ import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, stopTempSupervisor, secureStoppedTempArchive, readTempSupervisor, requestedTempStopReason, } from './temp-lifecycle.js';
20
22
  /**
21
23
  * The provenance record written by the most recent spawn in this process, so
22
24
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -322,6 +324,8 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
322
324
  if (o.isolationFile)
323
325
  readIsolationFile(o.isolationFile); // fail before reserving
324
326
  const prepared = resolvedSpawn(o); // canonical validation before mutation
327
+ if ((o.taskOwner || o.roomMemberStartup?.task_id) && !getAdapter(prepared.role.harness).supportsResume)
328
+ throw Error('TASK_HARNESS_RESUME_UNSUPPORTED: task agents require a resumable harness');
325
329
  if (o.roomMemberStartup?.workspace) {
326
330
  const workspace = o.roomMemberStartup.workspace;
327
331
  if (prepared.role.cwd !== workspace.path)
@@ -368,7 +372,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
368
372
  lastProvenance = provenance;
369
373
  tx.record({
370
374
  stage: `temp state dir ${dir}`,
371
- undo: () => {
375
+ undo: async () => {
376
+ if (o.taskOwner || role.roomMemberStartup?.task_id) {
377
+ await stopTempSupervisor(o.name);
378
+ const supervisor = readTempSupervisor(dir);
379
+ if (supervisor)
380
+ await secureStoppedTempArchive(o.name, supervisor.launchId);
381
+ return;
382
+ }
372
383
  // A failed launch is still lifecycle evidence: briefing, provenance,
373
384
  // metadata and supervisor output explain what happened. Remove it from
374
385
  // the live roster by atomic archive, never recursive deletion.
@@ -381,7 +392,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
381
392
  // out; a lone temp spawn still waits zero (time-based gate).
382
393
  if (cfg.startStaggerMs > 0)
383
394
  writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
384
- prepareTempSupervisor(dir, o.name);
395
+ prepareTempSupervisor(dir, o.name, o.taskOwner ?? (o.roomMemberStartup?.task_id ? {
396
+ taskId: o.roomMemberStartup.task_id,
397
+ roomId: o.roomMemberStartup.room_id,
398
+ roomIdentityCid: o.roomMemberStartup.room_identity_cid,
399
+ creationActionId: o.creationActionId,
400
+ } : undefined));
401
+ if (o.taskOwner || o.roomMemberStartup?.task_id)
402
+ taskSupervisorMayRun(o.name);
385
403
  // Run the supervisor independently. On a service-managed host the temp
386
404
  // runner gets its own transient unit/job, so stopping the coordinator's unit
387
405
  // cannot kill a live worker in the coordinator's cgroup.
@@ -389,3 +407,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
389
407
  await launch(binPath, ['_run-temp', o.name], dir);
390
408
  return dir;
391
409
  }
410
+ /** Resume the exact durable task launch; no creation, invite issuance or state rotation. */
411
+ export async function resumeTaskSupervisor(name, binPath) {
412
+ if (!taskSupervisorMayRun(name))
413
+ throw Error('TASK_SUPERVISOR_RETIRED');
414
+ const dir = agentDir(name, true);
415
+ if (requestedTempStopReason(dir))
416
+ throw Error('TASK_SUPERVISOR_STOP_REQUESTED');
417
+ const launcher = readTempSupervisor(dir)?.kind === 'detached'
418
+ ? makeTempSupervisorLauncher({ spawnDetached, supervisor: 'none' }) : independentSupervisor;
419
+ await launcher(binPath, ['_run-temp', name], dir);
420
+ }
@@ -9,5 +9,5 @@ interface ReadinessDeps {
9
9
  env: NodeJS.ProcessEnv;
10
10
  }
11
11
  /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
12
- export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>): Promise<void>;
12
+ export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>, temp?: boolean): Promise<void>;
13
13
  export {};
@@ -1,11 +1,11 @@
1
1
  import { agentDir } from './paths.js';
2
2
  import { findRole, loadConfig } from './config.js';
3
- import { resolveConfigPath } from './runner.js';
3
+ import { resolveConfigPath, loadTempRole } from './runner.js';
4
4
  import { probeDaemonGeneration } from './daemon-recovery.js';
5
5
  export const STARTUP_WAIT_MS = 240_000;
6
6
  export const STARTUP_RETRY_MS = 5_000;
7
7
  /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
8
- export async function waitForRoleDaemon(name, configPath, partial = {}) {
8
+ export async function waitForRoleDaemon(name, configPath, partial = {}, temp = false) {
9
9
  const deps = {
10
10
  probe: env => probeDaemonGeneration(((url, init) => globalThis.fetch(url, {
11
11
  ...init, signal: AbortSignal.timeout(10_000),
@@ -17,7 +17,7 @@ export async function waitForRoleDaemon(name, configPath, partial = {}) {
17
17
  ...partial,
18
18
  };
19
19
  const path = resolveConfigPath(agentDir(name), configPath);
20
- const role = findRole(loadConfig(path), name);
20
+ const role = temp ? loadTempRole(name) : findRole(loadConfig(path), name);
21
21
  const env = { ...deps.env, ...role.env };
22
22
  const deadline = deps.now() + STARTUP_WAIT_MS;
23
23
  let lastReason = '';
@@ -0,0 +1,8 @@
1
+ import { type Exec } from '../exec.js';
2
+ export declare function adoptConfiguredFleet(binPath: string, exec: Exec, platform: NodeJS.Platform, configPath?: string, selectedRole?: string): Promise<void>;
3
+ /** Boot resumes recorded transfers only. It does not rediscover removed agents
4
+ * or decide that every configured role should be started. */
5
+ export declare function resumeFleetTransfers(binPath: string, exec?: Exec, platform?: NodeJS.Platform): Promise<void>;
6
+ /** A remove racing the native-retired → registered seam must not be undone by
7
+ * boot receipt recovery. Serialize with that seam and close its cursor first. */
8
+ export declare function retirePermanentRegistration(name: string, exec: Exec): Promise<boolean>;
@@ -0,0 +1,78 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { agentDir, defaultConfigPath, stateRoot } from '../paths.js';
4
+ import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
5
+ import { loadConfig, findRole } from '../config.js';
6
+ import { realExec } from '../exec.js';
7
+ import { readMember, memberKey, unregisterMember, registerMember } from './catalog.js';
8
+ import { migrateLegacyPermanentMembers } from './legacy.js';
9
+ import { ensureFleetParent } from './fleet.js';
10
+ export async function adoptConfiguredFleet(binPath, exec, platform, configPath, selectedRole) {
11
+ const path = configPath || defaultConfigPath();
12
+ if (existsSync(path)) {
13
+ const config = loadConfig(path);
14
+ await migrateLegacyPermanentMembers({
15
+ roles: selectedRole ? [findRole(config, selectedRole)] : config.roles, binPath, configPath: path, exec, platform,
16
+ prepareParent: () => ensureFleetParent(exec, platform),
17
+ register: async (name, desired, selectedConfig) => {
18
+ await registerMember({ name, kind: 'permanent', dir: agentDir(name), configPath: selectedConfig }, { initialDesired: desired, preserveExisting: true });
19
+ },
20
+ });
21
+ }
22
+ else
23
+ await ensureFleetParent(exec, platform);
24
+ }
25
+ /** Boot resumes recorded transfers only. It does not rediscover removed agents
26
+ * or decide that every configured role should be started. */
27
+ export async function resumeFleetTransfers(binPath, exec = realExec, platform = process.platform) {
28
+ const root = join(stateRoot(), 'supervisor/legacy-permanent');
29
+ let names;
30
+ try {
31
+ names = readdirSync(root).filter(name => name.endsWith('.json'));
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT')
35
+ names = [];
36
+ else
37
+ throw error;
38
+ }
39
+ for (const name of names) {
40
+ const path = join(root, name), stat = lstatSync(path);
41
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
42
+ throw Error('LEGACY_PERMANENT_UNSAFE_PROOF');
43
+ const receipt = JSON.parse(readFileSync(path, 'utf8'));
44
+ if (receipt.phase === 'registered')
45
+ continue;
46
+ if (name !== receipt.name + '.json')
47
+ throw Error('LEGACY_PERMANENT_RECEIPT_MISMATCH');
48
+ const configPath = receipt.configPath || defaultConfigPath();
49
+ const role = findRole(loadConfig(configPath), receipt.name);
50
+ await migrateLegacyPermanentMembers({
51
+ roles: [role], binPath, configPath, exec, platform,
52
+ // This runs inside the parent. No recursive OS start, unit writes or reload.
53
+ prepareParent: async () => { },
54
+ register: async (name, desired, selectedConfig) => {
55
+ await registerMember({ name, kind: 'permanent', dir: agentDir(name), configPath: selectedConfig }, { initialDesired: desired, preserveExisting: true });
56
+ },
57
+ });
58
+ }
59
+ }
60
+ /** A remove racing the native-retired → registered seam must not be undone by
61
+ * boot receipt recovery. Serialize with that seam and close its cursor first. */
62
+ export async function retirePermanentRegistration(name, exec) {
63
+ memberKey(name, 'permanent');
64
+ return withFileLock(join(stateRoot(), 'locks/legacy-permanent', name), async () => {
65
+ const member = readMember(memberKey(name, 'permanent'));
66
+ const path = join(stateRoot(), 'supervisor/legacy-permanent', name + '.json');
67
+ if (member && existsSync(path)) {
68
+ const stat = lstatSync(path);
69
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
70
+ throw Error('LEGACY_PERMANENT_UNSAFE_PROOF');
71
+ const receipt = JSON.parse(readFileSync(path, 'utf8'));
72
+ if (receipt.version !== 1 || receipt.name !== name || !['native-retired', 'registered'].includes(receipt.phase))
73
+ throw Error('LEGACY_PERMANENT_RECEIPT_MISMATCH');
74
+ replaceFileAtomically(path, JSON.stringify({ ...receipt, phase: 'registered' }) + '\n');
75
+ }
76
+ return unregisterMember(memberKey(name, 'permanent'), { exec });
77
+ });
78
+ }
@@ -0,0 +1,62 @@
1
+ import { type Exec } from '../exec.js';
2
+ import type { TaskSupervisorOwner } from '../temp-lifecycle.js';
3
+ import type { Liveness } from './types.js';
4
+ export interface FleetMember {
5
+ version: 1;
6
+ key: string;
7
+ name: string;
8
+ kind: 'permanent' | 'task' | 'temporary' | 'watchdogs';
9
+ dir: string;
10
+ desired: 'running' | 'stopped';
11
+ retiring?: boolean;
12
+ incarnation: string;
13
+ taskOwner?: TaskSupervisorOwner;
14
+ launchId?: string;
15
+ configPath?: string;
16
+ environment?: Record<string, string>;
17
+ generation?: string;
18
+ pid?: number;
19
+ retryAt?: number;
20
+ }
21
+ /** Private 0600 member state retains exactly the creator's profile selection.
22
+ * Pin the resolved profile path even when the caller relied on HOME defaults. */
23
+ export declare function captureMemberEnvironment(env?: NodeJS.ProcessEnv): Promise<Record<string, string>>;
24
+ export declare function managedMemberEnvironment(member: FleetMember, parent?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
25
+ export declare const catalogRoot: () => string;
26
+ export declare function memberKey(name: string, kind: FleetMember['kind']): string;
27
+ export declare function memberPath(key: string): string;
28
+ export declare const withMemberLock: <T>(key: string, work: () => T | Promise<T>) => Promise<T>;
29
+ export declare function readMember(key: string): FleetMember | undefined;
30
+ export declare function writeMember(record: FleetMember): void;
31
+ export declare function listMemberKeys(): string[];
32
+ export declare function listMembers(): FleetMember[];
33
+ export type MemberRegistration = Pick<FleetMember, 'name' | 'kind' | 'dir' | 'taskOwner' | 'launchId' | 'configPath' | 'environment'>;
34
+ /** Persist intent before starting anything. The parent never discovers members from tmp directories. */
35
+ export declare function registerMember(input: MemberRegistration, options?: {
36
+ initialDesired?: FleetMember['desired'];
37
+ preserveExisting?: boolean;
38
+ }): Promise<{
39
+ created: boolean;
40
+ member: FleetMember;
41
+ }>;
42
+ export interface CatalogDeps {
43
+ exec?: Exec;
44
+ sleep?(ms: number): Promise<void>;
45
+ timeoutMs?: number;
46
+ }
47
+ /** A unique generation in argv fences PID reuse, including the spawn-to-pid-write crash seam. */
48
+ export declare function memberProcess(record: FleetMember, exec?: Exec): Promise<Liveness & {
49
+ pid?: number;
50
+ }>;
51
+ export declare function catalogLiveness(key: string, exec?: Exec): Promise<Liveness>;
52
+ export declare function waitMember(key: string, desired: 'running' | 'stopped', deps?: CatalogDeps): Promise<void>;
53
+ export declare function stopMember(key: string, deps?: CatalogDeps): Promise<void>;
54
+ export declare function unregisterMember(key: string, deps?: CatalogDeps): Promise<boolean>;
55
+ export declare function unregisterTaskMember(role: string, expected: {
56
+ taskId: string;
57
+ creationActionId?: string;
58
+ launchId?: string;
59
+ }, deps?: CatalogDeps): Promise<void>;
60
+ export declare function hasTaskMemberRegistration(role: string): boolean;
61
+ export declare function assertTaskRegistrationsAbsent(taskId: string): void;
62
+ export declare const permanentRegistration: (name: string) => MemberRegistration;
@@ -0,0 +1,254 @@
1
+ import { chmodSync, mkdirSync, lstatSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
5
+ import { agentDir, stateRoot, watchdogsRoot } from '../paths.js';
6
+ import { realExec } from '../exec.js';
7
+ import { assertSafeAncestors } from '../rooms-tasks/workspace.js';
8
+ const MEMBER_ENV_KEYS = ['HOME', 'PATH', 'XDG_RUNTIME_DIR', 'CODEX_HOME', 'CODEX_PATH', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_CONFIG'];
9
+ /** Private 0600 member state retains exactly the creator's profile selection.
10
+ * Pin the resolved profile path even when the caller relied on HOME defaults. */
11
+ export async function captureMemberEnvironment(env = process.env) {
12
+ const { clientConfigPath } = await import('../client-profile.js');
13
+ return Object.fromEntries([...MEMBER_ENV_KEYS.flatMap(key => env[key] === undefined ? [] : [[key, env[key]]]), ['OURS_CONFIG', clientConfigPath(env)]]);
14
+ }
15
+ export function managedMemberEnvironment(member, parent = process.env) {
16
+ const env = { ...parent };
17
+ if (member.environment) {
18
+ for (const key of MEMBER_ENV_KEYS)
19
+ delete env[key];
20
+ Object.assign(env, member.environment);
21
+ }
22
+ return env;
23
+ }
24
+ export const catalogRoot = () => join(stateRoot(), 'supervisor', 'members');
25
+ export function memberKey(name, kind) {
26
+ if (!/^[A-Za-z0-9_-]{1,160}$/.test(name))
27
+ throw Error('FLEET_MEMBER_INVALID_NAME');
28
+ return `${kind}-${name}`;
29
+ }
30
+ export function memberPath(key) {
31
+ if (!/^(permanent|task|temporary|watchdogs)-[A-Za-z0-9_-]{1,160}$/.test(key))
32
+ throw Error('FLEET_MEMBER_INVALID_KEY');
33
+ return join(catalogRoot(), `${key}.json`);
34
+ }
35
+ export const withMemberLock = (key, work) => withFileLock(join(stateRoot(), 'locks', 'fleet-members', memberKeyFromValidatedKey(key)), work);
36
+ function memberKeyFromValidatedKey(key) { memberPath(key); return key; }
37
+ export function readMember(key) {
38
+ const path = memberPath(key);
39
+ assertSafeAncestors(catalogRoot());
40
+ let stat;
41
+ try {
42
+ stat = lstatSync(path);
43
+ }
44
+ catch (error) {
45
+ if (error.code === 'ENOENT')
46
+ return;
47
+ throw error;
48
+ }
49
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
50
+ throw Error('FLEET_MEMBER_UNSAFE_RECORD');
51
+ const record = JSON.parse(readFileSync(path, 'utf8'));
52
+ validateMember(record, key);
53
+ return record;
54
+ }
55
+ function validateMember(record, key) {
56
+ const expectedDir = record.kind === 'watchdogs' ? watchdogsRoot()
57
+ : agentDir(record.name, record.kind !== 'permanent');
58
+ if (record.version !== 1 || !['permanent', 'task', 'temporary', 'watchdogs'].includes(record.kind)
59
+ || memberKey(record.name, record.kind) !== key || record.key !== key
60
+ || !['running', 'stopped'].includes(record.desired)
61
+ || !/^[a-f0-9-]{36}$/.test(record.incarnation)
62
+ || record.dir !== resolve(record.dir) || record.dir !== expectedDir
63
+ || (record.environment !== undefined && (typeof record.environment !== 'object' || record.environment === null
64
+ || Object.entries(record.environment).some(([key, value]) => !MEMBER_ENV_KEYS.includes(key) || typeof value !== 'string' || value.includes('\0'))))
65
+ || (record.pid !== undefined && (!Number.isSafeInteger(record.pid) || record.pid <= 0))
66
+ || (record.retryAt !== undefined && (!Number.isFinite(record.retryAt) || record.retryAt < 0))
67
+ || (record.kind === 'task' && (!record.taskOwner?.taskId || !record.taskOwner.creationActionId || !record.launchId)))
68
+ throw Error('FLEET_MEMBER_INVALID_RECORD');
69
+ }
70
+ export function writeMember(record) {
71
+ validateMember(record, record.key);
72
+ assertSafeAncestors(catalogRoot());
73
+ mkdirSync(catalogRoot(), { recursive: true, mode: 0o700 });
74
+ chmodSync(catalogRoot(), 0o700);
75
+ replaceFileAtomically(memberPath(record.key), JSON.stringify(record) + '\n');
76
+ }
77
+ export function listMemberKeys() {
78
+ try {
79
+ return readdirSync(catalogRoot()).filter(name => name.endsWith('.json')).map(name => name.slice(0, -5));
80
+ }
81
+ catch (error) {
82
+ if (error.code === 'ENOENT')
83
+ return [];
84
+ throw error;
85
+ }
86
+ }
87
+ export function listMembers() {
88
+ let entries;
89
+ try {
90
+ entries = readdirSync(catalogRoot(), { withFileTypes: true });
91
+ }
92
+ catch (error) {
93
+ if (error.code === 'ENOENT')
94
+ return [];
95
+ throw error;
96
+ }
97
+ return entries.filter(entry => entry.name.endsWith('.json')).map(entry => {
98
+ const value = readMember(entry.name.slice(0, -5));
99
+ if (!value)
100
+ throw Error('FLEET_MEMBER_DISAPPEARED');
101
+ return value;
102
+ });
103
+ }
104
+ /** Persist intent before starting anything. The parent never discovers members from tmp directories. */
105
+ export async function registerMember(input, options = {}) {
106
+ const key = memberKey(input.name, input.kind);
107
+ return withMemberLock(key, () => {
108
+ const previous = readMember(key);
109
+ if (previous && (previous.dir !== input.dir || previous.launchId !== input.launchId
110
+ || JSON.stringify(previous.taskOwner) !== JSON.stringify(input.taskOwner)))
111
+ throw Error('FLEET_MEMBER_OWNER_COLLISION');
112
+ if (previous && options.preserveExisting)
113
+ return { created: false, member: previous };
114
+ if (previous?.retiring)
115
+ throw Error('FLEET_MEMBER_RETIRING');
116
+ const desired = options.initialDesired ?? 'running';
117
+ const member = previous
118
+ ? { ...previous, desired, configPath: input.configPath ?? previous.configPath,
119
+ environment: previous.kind === 'task' ? previous.environment : input.environment ?? previous.environment, retryAt: 0 }
120
+ : { ...input, version: 1, key, desired, incarnation: randomUUID() };
121
+ writeMember(member);
122
+ return { created: !previous, member };
123
+ });
124
+ }
125
+ /** A unique generation in argv fences PID reuse, including the spawn-to-pid-write crash seam. */
126
+ export async function memberProcess(record, exec = realExec) {
127
+ if (!record.generation)
128
+ return { state: 'stopped', detail: 'no child generation' };
129
+ if (!/^[a-f0-9-]{36}$/.test(record.generation))
130
+ return { state: 'unknown', detail: 'invalid child generation' };
131
+ const matches = (command) => command.split(/\s+/).some((part, index, words) => part === '_run-managed' && words[index + 1] === record.key && words[index + 2] === record.generation);
132
+ if (record.pid) {
133
+ try {
134
+ process.kill(record.pid, 0);
135
+ }
136
+ catch (error) {
137
+ return error.code === 'ESRCH'
138
+ ? { state: 'stopped', detail: 'child pid absent' }
139
+ : { state: 'unknown', detail: 'child pid probe failed' };
140
+ }
141
+ try {
142
+ const command = readFileSync(`/proc/${record.pid}/cmdline`, 'utf8').split('\0').join(' ');
143
+ return matches(command) ? { state: 'running', detail: 'exact child generation', pid: record.pid }
144
+ : { state: 'stopped', detail: 'pid belongs to another generation' };
145
+ }
146
+ catch { /* non-/proc hosts use ps */ }
147
+ }
148
+ const result = await exec('ps', record.pid ? ['-p', String(record.pid), '-o', 'pid=', '-o', 'command=']
149
+ : ['-ax', '-o', 'pid=', '-o', 'command=']);
150
+ if (result.code !== 0)
151
+ return result.code === 1 && record.pid
152
+ ? { state: 'stopped', detail: 'child absent' } : { state: 'unknown', detail: 'process probe unavailable' };
153
+ const pids = result.stdout.split('\n').flatMap(line => {
154
+ const row = /^\s*(\d+)\s+(.*)$/.exec(line);
155
+ return row && matches(row[2]) ? [Number(row[1])] : [];
156
+ });
157
+ if (pids.length > 1)
158
+ return { state: 'unknown', detail: 'ambiguous child generation' };
159
+ return pids.length ? { state: 'running', detail: 'exact child generation', pid: pids[0] }
160
+ : { state: 'stopped', detail: 'child generation absent' };
161
+ }
162
+ export async function catalogLiveness(key, exec = realExec) {
163
+ try {
164
+ const record = readMember(key);
165
+ if (!record)
166
+ return { state: 'stopped', detail: 'member unregistered' };
167
+ const live = await memberProcess(record, exec);
168
+ if (live.state === 'stopped' && record.desired === 'running') {
169
+ const { readRestartLedger } = await import('../runner.js');
170
+ const ledger = readRestartLedger(record.dir);
171
+ if (ledger.circuit === 'open')
172
+ return { ...live, detail: `held after ${ledger.consecutiveImmediateFailures} failures: ${ledger.lastReason}; use up/restart or task recovery to reset` };
173
+ }
174
+ return live;
175
+ }
176
+ catch {
177
+ return { state: 'unknown', detail: 'member record unavailable' };
178
+ }
179
+ }
180
+ export async function waitMember(key, desired, deps = {}) {
181
+ const deadline = Date.now() + (deps.timeoutMs ?? 30_000);
182
+ do {
183
+ const live = await catalogLiveness(key, deps.exec);
184
+ if (live.state === desired)
185
+ return;
186
+ await (deps.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))))(100);
187
+ } while (Date.now() < deadline);
188
+ throw Error(desired === 'stopped' ? 'FLEET_MEMBER_STOP_UNPROVEN' : 'FLEET_MEMBER_START_UNPROVEN');
189
+ }
190
+ export async function stopMember(key, deps = {}) {
191
+ await withMemberLock(key, () => {
192
+ const record = readMember(key);
193
+ if (record)
194
+ writeMember({ ...record, desired: 'stopped' });
195
+ });
196
+ // A stopped parent cannot consume intent, but its IPC children stop on disconnect.
197
+ // Never signal an arbitrary recorded pid or erase a record on timeout.
198
+ await waitMember(key, 'stopped', deps);
199
+ }
200
+ export async function unregisterMember(key, deps = {}) {
201
+ const original = readMember(key);
202
+ if (!original)
203
+ return false;
204
+ await withMemberLock(key, () => {
205
+ const current = readMember(key);
206
+ if (!current || current.incarnation !== original.incarnation)
207
+ throw Error('FLEET_MEMBER_RETIREMENT_CONFLICT');
208
+ writeMember({ ...current, desired: 'stopped', retiring: true });
209
+ });
210
+ await waitMember(key, 'stopped', deps);
211
+ return withMemberLock(key, async () => {
212
+ const current = readMember(key);
213
+ if (!current)
214
+ return false;
215
+ if (current.incarnation !== original.incarnation || current.desired !== 'stopped')
216
+ throw Error('FLEET_MEMBER_RETIREMENT_CONFLICT');
217
+ if ((await memberProcess(current, deps.exec)).state !== 'stopped')
218
+ throw Error('FLEET_MEMBER_STOP_UNPROVEN');
219
+ rmSync(memberPath(key));
220
+ return true;
221
+ });
222
+ }
223
+ export async function unregisterTaskMember(role, expected, deps = {}) {
224
+ const key = memberKey(role, 'task');
225
+ await withMemberLock(key, () => {
226
+ const current = readMember(key);
227
+ if (!current)
228
+ return;
229
+ if (current.kind !== 'task' || current.taskOwner?.taskId !== expected.taskId
230
+ || (!expected.creationActionId && !expected.launchId)
231
+ || (expected.creationActionId !== undefined && current.taskOwner.creationActionId !== expected.creationActionId)
232
+ || (expected.launchId !== undefined && current.launchId !== expected.launchId))
233
+ throw Error('TASK_SERVICE_OWNER_MISMATCH');
234
+ writeMember({ ...current, desired: 'stopped', retiring: true });
235
+ });
236
+ await unregisterMember(key, deps);
237
+ }
238
+ export function hasTaskMemberRegistration(role) {
239
+ const path = memberPath(memberKey(role, 'task'));
240
+ try {
241
+ lstatSync(path);
242
+ return true;
243
+ }
244
+ catch (error) {
245
+ if (error.code === 'ENOENT')
246
+ return false;
247
+ throw error;
248
+ }
249
+ }
250
+ export function assertTaskRegistrationsAbsent(taskId) {
251
+ if (listMembers().some(record => record.taskOwner?.taskId === taskId))
252
+ throw Error('TASK_SERVICES_REMAIN');
253
+ }
254
+ export const permanentRegistration = (name) => ({ name, kind: 'permanent', dir: agentDir(name) });