@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.
- package/README.md +22 -12
- package/dist/agent-ours/bridge.js +10 -3
- package/dist/agent-ours/file-rpc.d.ts +8 -0
- package/dist/agent-ours/file-rpc.js +11 -1
- package/dist/agent-ours/harness.js +47 -3
- package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
- package/dist/agent-ours/mcp-endpoint.js +4 -0
- package/dist/agent-ours/service.d.ts +6 -1
- package/dist/agent-ours/service.js +73 -8
- package/dist/application/conversation-history.d.ts +2 -0
- package/dist/application/conversation-history.js +25 -2
- package/dist/application/log-service.js +8 -8
- package/dist/application/role-creation-service.js +2 -1
- package/dist/application/role-removal-service.js +7 -1
- package/dist/application/role-repository.js +17 -0
- package/dist/application/supervisor-ours-tools.js +3 -1
- package/dist/application/task-layouts.js +12 -2
- package/dist/application/task-room-service.js +19 -17
- package/dist/application/types.d.ts +2 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +11 -0
- package/dist/config.js +13 -2
- package/dist/file-delivery/reader.d.ts +17 -0
- package/dist/file-delivery/reader.js +94 -0
- package/dist/file-delivery/store.d.ts +15 -0
- package/dist/file-delivery/store.js +124 -0
- package/dist/file-delivery/types.d.ts +19 -0
- package/dist/file-delivery/types.js +1 -0
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +1 -1
- package/dist/harness/codex-app-server-proxy.js +6 -2
- package/dist/harness/codex-file-inventory.d.ts +15 -0
- package/dist/harness/codex-file-inventory.js +113 -0
- package/dist/harness/codex-session.js +2 -2
- package/dist/harness/codex.js +2 -1
- package/dist/isolation/policy.js +4 -1
- package/dist/managed-recovery.d.ts +13 -0
- package/dist/managed-recovery.js +31 -0
- package/dist/ops.d.ts +2 -0
- package/dist/ops.js +15 -2
- package/dist/rooms-tasks/cli.js +21 -0
- package/dist/rooms-tasks/close.d.ts +2 -0
- package/dist/rooms-tasks/close.js +66 -1
- package/dist/rooms-tasks/deletion.js +10 -0
- package/dist/rooms-tasks/erasure.js +4 -2
- package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
- package/dist/rooms-tasks/layout-member-retirement.js +90 -0
- package/dist/rooms-tasks/layout-service.d.ts +1 -0
- package/dist/rooms-tasks/layout-service.js +31 -13
- package/dist/rooms-tasks/layout.d.ts +7 -0
- package/dist/rooms-tasks/layout.js +35 -0
- package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
- package/dist/rooms-tasks/legacy-task-member.js +116 -0
- package/dist/rooms-tasks/member-ownership.d.ts +5 -0
- package/dist/rooms-tasks/member-ownership.js +40 -0
- package/dist/rooms-tasks/provision.js +26 -1
- package/dist/rooms-tasks/recovery.d.ts +19 -0
- package/dist/rooms-tasks/recovery.js +110 -0
- package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
- package/dist/rooms-tasks/task-service-retirement.js +21 -0
- package/dist/rooms-tasks/task-state.d.ts +1 -0
- package/dist/rooms-tasks/task-state.js +16 -1
- package/dist/rooms-tasks/types.d.ts +3 -0
- package/dist/rooms-tasks/workspace.d.ts +2 -0
- package/dist/rooms-tasks/workspace.js +5 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +161 -22
- package/dist/session/acp.d.ts +5 -0
- package/dist/session/acp.js +68 -8
- package/dist/session/arbiter.d.ts +5 -0
- package/dist/session/arbiter.js +16 -2
- package/dist/session/codex-app-server.d.ts +1 -0
- package/dist/session/codex-app-server.js +5 -0
- package/dist/session/conversation-types.d.ts +4 -2
- package/dist/session/types.d.ts +3 -0
- package/dist/spawn.d.ts +5 -1
- package/dist/spawn.js +32 -3
- package/dist/startup-readiness.d.ts +1 -1
- package/dist/startup-readiness.js +3 -3
- package/dist/supervisor/adoption.d.ts +8 -0
- package/dist/supervisor/adoption.js +78 -0
- package/dist/supervisor/catalog.d.ts +62 -0
- package/dist/supervisor/catalog.js +254 -0
- package/dist/supervisor/fleet.d.ts +8 -0
- package/dist/supervisor/fleet.js +89 -0
- package/dist/supervisor/index.js +3 -6
- package/dist/supervisor/launchd.d.ts +1 -1
- package/dist/supervisor/launchd.js +24 -12
- package/dist/supervisor/legacy.d.ts +35 -0
- package/dist/supervisor/legacy.js +524 -0
- package/dist/supervisor/manager.d.ts +15 -0
- package/dist/supervisor/manager.js +258 -0
- package/dist/supervisor/scope.d.ts +4 -0
- package/dist/supervisor/scope.js +10 -0
- package/dist/supervisor/systemd.d.ts +2 -2
- package/dist/supervisor/systemd.js +14 -14
- package/dist/task-supervision.d.ts +9 -0
- package/dist/task-supervision.js +145 -0
- package/dist/temp-lifecycle.d.ts +17 -7
- package/dist/temp-lifecycle.js +85 -53
- package/dist/temp-supervisor-recovery.d.ts +1 -1
- package/dist/temp-supervisor-recovery.js +2 -2
- package/dist/web/server.js +24 -0
- package/docs/acp-files.md +102 -0
- package/docs/fleet-migration.md +54 -0
- package/docs/task-workspaces.md +26 -0
- 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
|
package/dist/session/arbiter.js
CHANGED
|
@@ -90,8 +90,20 @@ export class RoleTurnArbiter {
|
|
|
90
90
|
retired.retire();
|
|
91
91
|
}
|
|
92
92
|
queuePrompt(text, options = {}) {
|
|
93
|
-
return this.exclusive(async () => {
|
|
94
|
-
|
|
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 =
|
|
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;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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) });
|