@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
@@ -0,0 +1,113 @@
1
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
2
+ const parse = (line) => { try {
3
+ const v = JSON.parse(line);
4
+ return object(v) ? v : undefined;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ } };
9
+ export class CodexFileInventory {
10
+ sendToCodex;
11
+ emitToClient;
12
+ checks = new Map();
13
+ sequence = 0;
14
+ constructor(sendToCodex, emitToClient) {
15
+ this.sendToCodex = sendToCodex;
16
+ this.emitToClient = emitToClient;
17
+ }
18
+ /** Hold only a turn/start; unrelated tools/configuration are preserved. */
19
+ observeClientLine(line) {
20
+ const message = parse(line);
21
+ if (message?.method !== 'turn/start')
22
+ return true;
23
+ const threadId = object(message.params) ? message.params.threadId : undefined;
24
+ if (typeof threadId !== 'string' || !threadId || message.id === undefined || this.checks.size >= 8) {
25
+ this.reject(message);
26
+ return false;
27
+ }
28
+ const check = { request: message, threadId, servers: [], cursors: new Set(), timer: setTimeout(() => {
29
+ for (const [id, c] of this.checks)
30
+ if (c === check)
31
+ this.checks.delete(id);
32
+ this.reject(message);
33
+ }, 30_000) };
34
+ this.query(check);
35
+ return false;
36
+ }
37
+ observeServerLine(line) {
38
+ const message = parse(line), id = message?.id;
39
+ if (typeof id !== 'string')
40
+ return true;
41
+ const check = this.checks.get(id);
42
+ if (!check)
43
+ return true;
44
+ this.checks.delete(id);
45
+ const result = message?.result;
46
+ if (!object(result) || !Array.isArray(result.data) || result.data.some(s => !object(s)) || check.servers.length + result.data.length > 1000) {
47
+ this.finish(check, false);
48
+ return false;
49
+ }
50
+ check.servers.push(...result.data);
51
+ if (result.nextCursor !== null) {
52
+ const cursor = result.nextCursor;
53
+ if (typeof cursor !== 'string' || !cursor || check.cursors.has(cursor) || check.cursors.size >= 10)
54
+ this.finish(check, false);
55
+ else {
56
+ check.cursors.add(cursor);
57
+ this.query(check, cursor);
58
+ }
59
+ return false;
60
+ }
61
+ this.finish(check, this.qualified(check.servers));
62
+ return false;
63
+ }
64
+ close() { for (const c of this.checks.values())
65
+ clearTimeout(c.timer); this.checks.clear(); }
66
+ query(check, cursor) {
67
+ const id = `ours-fleet-file-inventory-${++this.sequence}`;
68
+ this.checks.set(id, check);
69
+ this.sendToCodex(JSON.stringify({ id, method: 'mcpServerStatus/list', params: { threadId: check.threadId, detail: 'full', limit: 100, ...(cursor ? { cursor } : {}) } }));
70
+ }
71
+ qualified(servers) {
72
+ let managed = 0;
73
+ for (const server of servers) {
74
+ // Unknown or failed discovery cannot establish absence of another connector.
75
+ if (server.toolsError !== null || !object(server.tools))
76
+ return false;
77
+ if (!object(server.serverInfo) || typeof server.serverInfo.name !== 'string')
78
+ return false;
79
+ const tools = Object.values(server.tools).filter(object);
80
+ const names = new Set(tools.map(t => t.name));
81
+ if (server.name !== 'ours') {
82
+ if (server.serverInfo.name === 'ours')
83
+ return false;
84
+ if (tools.some(t => t.name === 'send_file' && object(t.inputSchema) && object(t.inputSchema.properties) && 'contact' in t.inputSchema.properties))
85
+ return false;
86
+ if (names.has('current_identity') && names.has('send_file') && names.has('get_messages'))
87
+ return false;
88
+ continue;
89
+ }
90
+ managed++;
91
+ if (server.serverInfo.name !== 'ours' || server.runtimeStatus !== 'connected')
92
+ return false;
93
+ const files = tools.filter(t => t.name === 'send_file');
94
+ if (files.length !== 1 || !object(files[0].inputSchema))
95
+ return false;
96
+ const schema = files[0].inputSchema;
97
+ if (!object(schema.properties) || !object(schema.properties.contact) || schema.additionalProperties !== false
98
+ || (Array.isArray(schema.required) && schema.required.includes('contact')))
99
+ return false;
100
+ }
101
+ return managed === 1;
102
+ }
103
+ finish(check, allowed) {
104
+ clearTimeout(check.timer);
105
+ if (allowed)
106
+ this.sendToCodex(JSON.stringify(check.request));
107
+ else
108
+ this.reject(check.request);
109
+ }
110
+ reject(request) {
111
+ this.emitToClient(JSON.stringify({ id: request.id, error: { code: -32000, message: 'CURRENT_CHAT_DELIVERY_UNAVAILABLE: unqualified MCP inventory' } }));
112
+ }
113
+ }
@@ -32,7 +32,7 @@ export class CodexAgentSessionAdapter {
32
32
  if (role.session === 'codex-app-server')
33
33
  return this.nativeTransport({
34
34
  name: role.name, argv: launch.argv, cwd: options.cwd, env: launch.env,
35
- stateDir: options.stateDir, mode: options.mode, permissions: options.permissions,
35
+ stateDir: options.stateDir, mode: options.mode, requireResume: options.requireResume, permissions: options.permissions,
36
36
  permissionMode: options.permissionMode,
37
37
  model: role.model, effort: role.effort,
38
38
  approvalPolicy: this.strategy.approvalPolicy(role),
@@ -44,7 +44,7 @@ export class CodexAgentSessionAdapter {
44
44
  return this.transport({
45
45
  name: role.name, harness: role.harness,
46
46
  argv: launch.argv, cwd: options.cwd, env: launch.env,
47
- stateDir: options.stateDir, mode: options.mode, permissions: options.permissions,
47
+ stateDir: options.stateDir, mode: options.mode, requireResume: options.requireResume, permissions: options.permissions,
48
48
  modeId: this.strategy.permissionModeId(role),
49
49
  mcpServers: options.managedOurs ? [...(this.strategy.mcpServers(role) ?? []).filter(s => s.name !== 'ours'), options.managedOurs.server] : this.strategy.mcpServers(role),
50
50
  inheritEnvironment: options.managedOurs ? false : undefined,
@@ -262,6 +262,7 @@ function codexAcpEnvironment(role, dirs) {
262
262
  if (!source)
263
263
  throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
264
264
  writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
265
+ writeFileSync(join(runtimeDir, 'codex-file-inventory.js'), readFileSync(join(dirname(source), 'codex-file-inventory.js'), 'utf8'), { mode: 0o600 });
265
266
  const windows = process.platform === 'win32';
266
267
  const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
267
268
  const script = windows
@@ -482,7 +483,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
482
483
  catch (error) {
483
484
  throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
484
485
  }
485
- const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
486
+ const minimum = role.file_delivery?.enabled ? '0.159.0' : role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
486
487
  ? '0.153.3' : '0.145.0';
487
488
  if (!codexVersionAtLeast(runtime.version, minimum))
488
489
  throw new Error(`${role.model ?? 'Codex ACP'} requires a newer Codex (>=${minimum}): ACP selects ${runtime.executable} (${runtime.version}); upgrade Fleet or set CODEX_PATH to a supported executable in the agent/service environment`);
@@ -1,3 +1,4 @@
1
+ import { stateRoot } from '../paths.js';
1
2
  import { realpathSync } from 'node:fs';
2
3
  import { basename, dirname, join, resolve, sep } from 'node:path';
3
4
  import { BACKENDS, ON_UNAVAILABLE, NETWORK_MODES, } from './types.js';
@@ -57,7 +58,7 @@ const SYSTEM_RO = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
57
58
  /** Ephemeral scratch mounts. */
58
59
  const scratchTmpfs = (home) => ['/tmp', join(home, '.cache')];
59
60
  /** Home-relative sensitive paths never exposed (the blocklist's teeth). */
60
- const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', 'fleet.yaml', 'fleet.d'];
61
+ const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', '.ours-fleet/private-ours', '.ours-fleet/private-file-delivery', 'fleet.yaml', 'fleet.d'];
61
62
  const ISOLATION_KEYS = ['backend', 'on_unavailable', 'fs', 'network', 'allow_hosts', 'resources', 'secrets'];
62
63
  const FS_KEYS = ['read', 'write'];
63
64
  const RESOURCE_KEYS = ['cpu', 'mem', 'pids'];
@@ -189,6 +190,8 @@ export function resolveIsolation(cfg, ctx) {
189
190
  const agentsRoot = dirname(stateDir);
190
191
  const blocklist = [
191
192
  ...SENSITIVE_HOME.map(p => join(home, p)),
193
+ join(stateRoot(), 'private-ours'),
194
+ join(stateRoot(), 'private-file-delivery'),
192
195
  agentsRoot, // sibling agents' state dirs (this agent's own is explicitly mounted)
193
196
  ];
194
197
  // ENFORCE the list, before anything builds a backend argv. Until now it was
@@ -0,0 +1,13 @@
1
+ /** Body-free recovery cursor. Dispatching is uncertain after a crash, never
2
+ * permission to replay an initial task which may already have caused effects. */
3
+ export interface ManagedRecovery {
4
+ version: 1;
5
+ session: 'pending' | 'established';
6
+ readiness: boolean;
7
+ initial: 'pending' | 'dispatching' | 'completed';
8
+ active: boolean;
9
+ }
10
+ export declare const managedRecoveryPath: (dir: string) => string;
11
+ export declare function readManagedRecovery(dir: string): ManagedRecovery | undefined;
12
+ export declare function writeManagedRecovery(dir: string, value: ManagedRecovery): void;
13
+ export declare function firstManagedSessionMayStart(dir: string): boolean;
@@ -0,0 +1,31 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically } from './atomic-file.js';
4
+ export const managedRecoveryPath = (dir) => join(dir, '.managed-recovery.json');
5
+ export function readManagedRecovery(dir) {
6
+ const path = managedRecoveryPath(dir);
7
+ let stat;
8
+ try {
9
+ stat = lstatSync(path);
10
+ }
11
+ catch (error) {
12
+ if (error.code === 'ENOENT')
13
+ return;
14
+ throw error;
15
+ }
16
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096)
17
+ throw Error('MANAGED_RECOVERY_UNSAFE_PROOF');
18
+ const value = JSON.parse(readFileSync(path, 'utf8'));
19
+ if (value.version !== 1 || !['pending', 'established'].includes(value.session)
20
+ || !['pending', 'dispatching', 'completed'].includes(value.initial)
21
+ || typeof value.readiness !== 'boolean' || typeof value.active !== 'boolean')
22
+ throw Error('MANAGED_RECOVERY_INVALID_PROOF');
23
+ return value;
24
+ }
25
+ export function writeManagedRecovery(dir, value) {
26
+ replaceFileAtomically(managedRecoveryPath(dir), JSON.stringify(value) + '\n');
27
+ }
28
+ export function firstManagedSessionMayStart(dir) {
29
+ const proof = readManagedRecovery(dir);
30
+ return Boolean(proof?.session === 'pending' && proof.initial === 'pending' && !proof.readiness && !proof.active);
31
+ }
package/dist/ops.d.ts CHANGED
@@ -54,5 +54,7 @@ export declare function up(cfg: FleetConfig, names: string[], deps: OpsDeps, con
54
54
  export declare function down(cfg: FleetConfig, names: string[], deps: OpsDeps): Promise<void>;
55
55
  /** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
56
56
  export declare function restartRoles(cfg: FleetConfig, names: string[], deps: OpsDeps, mode: 'keep' | 'fresh', configPath?: string): Promise<void>;
57
+ /** Read-only admission check shared by CLI removal and web preview, before any archive. */
58
+ export declare function assertStandaloneRoleRemoval(cfg: FleetConfig, name: string): void;
57
59
  /** Stop + forget a role: unit, state dir, and its fleet.d file when spawned. */
58
60
  export declare function rmRole(cfg: FleetConfig, name: string, deps: OpsDeps): Promise<void>;
package/dist/ops.js CHANGED
@@ -7,8 +7,8 @@ import { findRole } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { generateBriefing } from './briefing.js';
9
9
  import { managedCliBriefing } from './managed-cli.js';
10
- import { resetRestartLedger } from './runner.js';
11
- import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, } from './temp-lifecycle.js';
10
+ import { resetRestartLedger, loadTempRole } from './runner.js';
11
+ import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, readTempSupervisor, } from './temp-lifecycle.js';
12
12
  import { realExec } from './exec.js';
13
13
  import { daemonIdentityProvisioner, reconcilePermanentRoleIdentities, } from './creation.js';
14
14
  import { provesGeneratedAgentSource } from './generated-agent-source.js';
@@ -223,8 +223,21 @@ export async function restartRoles(cfg, names, deps, mode, configPath) {
223
223
  : `↻ ${role.name} — restarted (resumes; briefing re-synced)`);
224
224
  }
225
225
  }
226
+ /** Read-only admission check shared by CLI removal and web preview, before any archive. */
227
+ export function assertStandaloneRoleRemoval(cfg, name) {
228
+ if (cfg.roles.some(role => role.name === name) || !/^[A-Za-z0-9_-]+$/.test(name))
229
+ return;
230
+ const dir = agentDir(name, true);
231
+ if (!existsSync(dir))
232
+ return;
233
+ const taskId = readTempSupervisor(dir)?.taskOwner?.taskId
234
+ ?? (existsSync(join(dir, 'role.yaml')) ? loadTempRole(name).roomMemberStartup?.task_id : undefined);
235
+ if (taskId)
236
+ throw Error(`TASK_MEMBER_IS_TASK_OWNED: ${name} belongs to task ${taskId}; use the owning task lifecycle; if its record is missing or corrupt, preserve evidence and restore the original records before retrying task delete`);
237
+ }
226
238
  /** Stop + forget a role: unit, state dir, and its fleet.d file when spawned. */
227
239
  export async function rmRole(cfg, name, deps) {
240
+ assertStandaloneRoleRemoval(cfg, name);
228
241
  const temporaryDir = /^[A-Za-z0-9_-]+$/.test(name) ? agentDir(name, true) : '';
229
242
  const configured = cfg.roles.find(role => role.name === name);
230
243
  if (!configured && temporaryDir && existsSync(temporaryDir)) {
@@ -891,6 +891,27 @@ export function registerTaskCommands(parent, cOpt) {
891
891
  dieTaskRoom(e);
892
892
  }
893
893
  });
894
+ cOpt(taskCmd.command('recover-members <id>'))
895
+ .description('resume proven stopped task members in-place; never create identities or replay invites')
896
+ .option('--json', 'JSON output')
897
+ .action(async (id, opts) => {
898
+ try {
899
+ const { recoverTaskMembers } = await import('./recovery.js');
900
+ const task = getTask(id), cfg = loadConfig(opts.configuration);
901
+ const members = await recoverTaskMembers({ taskId: id, binPath: getBinPath(),
902
+ cowork: task.layout ? undefined : createCoworkAdapter({ configPath: cfg.rooms?.cowork?.config }) });
903
+ if (opts.json)
904
+ console.log(JSON.stringify({ schema_version: 1, task_id: id, members }, null, 2));
905
+ else
906
+ for (const member of members)
907
+ console.log(`${member.name}: ${member.status}`);
908
+ }
909
+ catch (e) {
910
+ if (opts.json)
911
+ die(e);
912
+ dieTaskRoom(e);
913
+ }
914
+ });
894
915
  cOpt(taskCmd.command('start <id>'))
895
916
  .description('idempotently select a plan, provision, and start a task')
896
917
  .option('--template <name>', 'room template')
@@ -1,3 +1,4 @@
1
+ import { retireTaskMemberService } from './task-service-retirement.js';
1
2
  import { withFileLock } from '../atomic-file.js';
2
3
  import { type TempLifecycleDeps } from '../temp-lifecycle.js';
3
4
  import { type CoworkAdapter } from './cowork-adapter.js';
@@ -5,6 +6,7 @@ import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
5
6
  export declare const CLOSE_LOCK_STALE_MS: number;
6
7
  export declare function roomCloseLockPath(roomId: string): string;
7
8
  export interface RoomCloseDeps {
9
+ retireTaskService?: typeof retireTaskMemberService;
8
10
  inspectMember?(seat: RoomMemberSeat): Promise<{
9
11
  launchId: string;
10
12
  }>;
@@ -1,11 +1,15 @@
1
1
  import { detachDeletedRoom } from './task-state.js';
2
2
  import { binderKey } from '../agent-ours/state.js';
3
+ import { assertMemberNotPermanent } from './member-ownership.js';
4
+ import { retireTaskMemberService } from './task-service-retirement.js';
3
5
  import { eraseMemberArtifacts } from './erasure.js';
4
6
  import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
5
7
  import { collectWorkspaceArchives } from './workspace-artifacts.js';
6
- import { existsSync, readFileSync } from 'node:fs';
8
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
7
9
  import { join } from 'node:path';
8
10
  import { randomUUID } from 'node:crypto';
11
+ import { parse } from 'yaml';
12
+ import { privateRuntimeRoot, releaseManagedAgent } from '../agent-ours/service.js';
9
13
  import { attachOursClient } from '@ours.network/sdk/client';
10
14
  import { withFileLock } from '../atomic-file.js';
11
15
  import { agentDir, stateRoot } from '../paths.js';
@@ -48,6 +52,7 @@ function exactMemberIdentity(seat) {
48
52
  }
49
53
  }
50
54
  export async function inspectMember(seat) {
55
+ assertMemberNotPermanent(seat.role_name);
51
56
  exactMemberIdentity(seat);
52
57
  const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
53
58
  if (!supervisor || supervisor.role !== seat.role_name
@@ -105,7 +110,46 @@ export async function identityCidPresent(cid) {
105
110
  return rows.some(row => 'cid' in row && row.cid.toLowerCase() === cid.toLowerCase());
106
111
  });
107
112
  }
113
+ /** A managed temporary identity belongs to its durable external lease even
114
+ * after worker exit. Retire that exact logical owner before generic removal. */
115
+ async function releaseRetainedMemberRuntime(seat) {
116
+ const live = agentDir(seat.role_name, true);
117
+ const source = existsSync(live) ? live
118
+ : (seat.launch?.launch_id ? tempArchiveForLaunch(seat.role_name, seat.launch.launch_id) : undefined);
119
+ if (!source) {
120
+ if (seat.launch?.task_supervised)
121
+ throw Error('MEMBER_RUNTIME_RETIREMENT_SOURCE_MISSING');
122
+ return;
123
+ }
124
+ const path = join(source, 'role.yaml');
125
+ if (!existsSync(path))
126
+ return; // pre-managed compatibility evidence has no runtime
127
+ const stat = lstatSync(path);
128
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
129
+ throw Error('MEMBER_RUNTIME_UNSAFE_PROOF');
130
+ const role = parse(readFileSync(path, 'utf8'));
131
+ const profile = readClientProfile({ ...process.env, ...role.env });
132
+ const runtime = join(privateRuntimeRoot(), binderKey(profile.expectedInstanceId, seat.role_name));
133
+ const metadata = readTempSupervisor(source);
134
+ if (!existsSync(join(runtime, 'instance.json'))) {
135
+ if (metadata?.kind === 'fleet-managed')
136
+ throw Error('MEMBER_RUNTIME_RETIREMENT_MISSING');
137
+ return;
138
+ }
139
+ const creationPath = join(source, 'creation.json'), creationStat = lstatSync(creationPath);
140
+ if (!creationStat.isFile() || creationStat.isSymbolicLink() || creationStat.size > 256 * 1024)
141
+ throw Error('MEMBER_RUNTIME_UNSAFE_PROOF');
142
+ const creation = JSON.parse(readFileSync(creationPath, 'utf8'));
143
+ if (role.name !== seat.role_name || role.identity !== seat.role_name || !seat.identity_cid
144
+ || !seat.launch?.action_id || !seat.launch.launch_id || creation.role !== seat.role_name
145
+ || creation.creationActionId !== seat.launch.action_id || metadata?.role !== seat.role_name
146
+ || metadata.launchId !== seat.launch.launch_id
147
+ || (metadata.taskOwner && metadata.taskOwner.creationActionId !== seat.launch.action_id))
148
+ throw Error('MEMBER_RUNTIME_RETIREMENT_MISMATCH');
149
+ await releaseManagedAgent(role, { cid: seat.identity_cid, action: seat.launch.action_id });
150
+ }
108
151
  export async function removeExactMemberIdentity(seat) {
152
+ assertMemberNotPermanent(seat.role_name);
109
153
  await withIdentityClient(async (client) => {
110
154
  const rows = await client.listIdentities();
111
155
  const before = listedIdentity(rows, seat.role_name);
@@ -113,6 +157,11 @@ export async function removeExactMemberIdentity(seat) {
113
157
  if (rows.some(row => row.name === seat.role_name || (seat.identity_cid && 'cid' in row
114
158
  && row.cid.toLowerCase() === seat.identity_cid.toLowerCase())))
115
159
  throw new Error(`room member '${seat.role_name}' identity absence is not proven`);
160
+ // A terminal release can remove the SDK identity before its response is
161
+ // recorded. Finish the exact retained launch's release journal on retry.
162
+ if (existsSync(agentDir(seat.role_name, true)) || (seat.launch?.launch_id
163
+ && tempArchiveForLaunch(seat.role_name, seat.launch.launch_id)))
164
+ await releaseRetainedMemberRuntime(seat);
116
165
  return;
117
166
  }
118
167
  if (!seat.identity_cid) {
@@ -121,7 +170,9 @@ export async function removeExactMemberIdentity(seat) {
121
170
  if (before.cid?.toLowerCase() !== seat.identity_cid.toLowerCase()) {
122
171
  throw new Error(`room member '${seat.role_name}' identity absence is not proven: CID mismatch: recorded ${seat.identity_cid}, found ${before.cid ?? 'none'}`);
123
172
  }
173
+ await releaseRetainedMemberRuntime(seat);
124
174
  try {
175
+ assertMemberNotPermanent(seat.role_name);
125
176
  await client.removeIdentity({ name: seat.role_name });
126
177
  }
127
178
  catch (error) {
@@ -150,9 +201,19 @@ export async function assertMemberIdentityAbsent(seat) {
150
201
  }
151
202
  });
152
203
  }
204
+ async function retireMemberRegistration(room, current, deps) {
205
+ if (room.task_id)
206
+ await (deps.retireTaskService ?? retireTaskMemberService)(current.role_name, {
207
+ taskId: room.task_id, creationActionId: current.launch?.action_id,
208
+ taskSupervised: current.launch?.task_supervised,
209
+ ...(current.launch?.launch_id ? { launchId: current.launch.launch_id } : {}),
210
+ });
211
+ }
153
212
  async function retireMember(roomId, seat, deps) {
213
+ assertMemberNotPermanent(seat.role_name);
154
214
  let room = getRoomRecord(roomId);
155
215
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
216
+ await retireMemberRegistration(room, current, deps);
156
217
  let retirement = current.retirement;
157
218
  if (retirement?.phase === 'identity_absent') {
158
219
  if (!deps.inspectMember) {
@@ -250,6 +311,10 @@ export async function closeManagedRoom(input) {
250
311
  return lock(roomCloseLockPath(input.roomId), async () => {
251
312
  let room = beginRoomClose(input.roomId);
252
313
  if (room.state === 'closed') {
314
+ for (const seat of room.member_seats) {
315
+ assertMemberNotPermanent(seat.role_name);
316
+ await retireMemberRegistration(room, seat, deps);
317
+ }
253
318
  if (!deps.inspectMember)
254
319
  for (const seat of room.member_seats) {
255
320
  if (existsSync(agentDir(seat.role_name, true)))
@@ -1,4 +1,7 @@
1
1
  import { eraseMemberArtifacts } from './erasure.js';
2
+ import { assertMemberNotPermanent } from './member-ownership.js';
3
+ import { retireTaskMemberService } from './task-service-retirement.js';
4
+ import { assertTaskRegistrationsAbsent } from '../supervisor/catalog.js';
2
5
  import { proveArchivedAbsence, verifyArchivedAbsence, verifyArchivedMemberStillAbsent } from './archived-absence.js';
3
6
  import { existsSync } from 'node:fs';
4
7
  import { withFileLock } from '../atomic-file.js';
@@ -64,6 +67,12 @@ function cursorSeat(cursor) {
64
67
  * the recorded CID surviving under any name blocks settlement.
65
68
  */
66
69
  async function retireCursorMember(taskId, cursor, deps) {
70
+ assertMemberNotPermanent(cursor.name);
71
+ await (deps.roomClose?.retireTaskService ?? retireTaskMemberService)(cursor.name, { taskId,
72
+ creationActionId: cursor.action_id,
73
+ taskSupervised: cursor.task_supervised,
74
+ ...((cursor.launch_id && !['never-launched', 'absent-verified'].includes(cursor.launch_id)) ? { launchId: cursor.launch_id } : {}),
75
+ });
67
76
  let phase = cursor.phase;
68
77
  let launchId = cursor.launch_id;
69
78
  if (phase === 'identity_absent') {
@@ -249,6 +258,7 @@ export async function settleTaskDeletion(input) {
249
258
  }
250
259
  if (cleanup.snapshotHash)
251
260
  releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
261
+ assertTaskRegistrationsAbsent(taskId);
252
262
  await eraseMemberArtifacts('task', taskId, task.deletion.members.map(cursorSeat), task.deletion.room_id ? [task.deletion.room_id] : []);
253
263
  if (task.workspace) {
254
264
  assertWorkspaceDeletable(task.workspace, 'task', taskId);
@@ -1,11 +1,12 @@
1
1
  import { eraseResourcePresentations } from '../erased-resources.js';
2
+ import { assertMemberNotPermanent } from './member-ownership.js';
2
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
4
  import { createHash, randomUUID } from 'node:crypto';
4
5
  import { dirname, join, relative } from 'node:path';
5
6
  import { parse } from 'yaml';
6
7
  import { replaceFileAtomically } from '../atomic-file.js';
7
8
  import { agentDir, stateRoot } from '../paths.js';
8
- import { readTempSupervisor, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
9
+ import { readTempSupervisorForScan, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
9
10
  import { assertSafeAncestors, auditWorkspaceGit, prepareOwnedDeletionTree } from './workspace.js';
10
11
  function entries(path) {
11
12
  try {
@@ -58,6 +59,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
58
59
  for (const name of names) {
59
60
  if (!/^[a-zA-Z0-9_-]{1,120}$/.test(name))
60
61
  throw new Error('Invalid erasure member');
62
+ assertMemberNotPermanent(name);
61
63
  if (existsSync(agentDir(name, true)))
62
64
  throw new Error(`Member '${name}' still has live state during erasure`);
63
65
  }
@@ -75,7 +77,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
75
77
  const stat = lstatSync(path);
76
78
  if (!stat.isDirectory() || stat.isSymbolicLink())
77
79
  continue;
78
- const supervisor = readTempSupervisor(path);
80
+ const supervisor = readTempSupervisorForScan(path);
79
81
  if (!supervisor || !names.has(supervisor.role))
80
82
  continue;
81
83
  const seat = seats.find(s => s.role_name === supervisor.role);
@@ -0,0 +1,10 @@
1
+ import { type TempLifecycleDeps } from '../temp-lifecycle.js';
2
+ import type { LayoutInstance, RoomLayoutState } from './layout.js';
3
+ import type { RoomMemberSeat } from './types.js';
4
+ export declare function layoutOwnedMemberName(runId: string, key: string): string;
5
+ /** Retire a local layout factory result, never a borrowed binding. The runtime
6
+ * instance and CID fence replacement supervisors even when already stopped.
7
+ */
8
+ export declare function retireOwnedLayoutMember(instance: LayoutInstance, runId: string, key: string, lifecycle?: TempLifecycleDeps, taskSupervised?: boolean): Promise<void>;
9
+ /** Exact archived launches used by the existing artifact erasure saga. */
10
+ export declare function ownedLayoutRetirementSeats(state: RoomLayoutState, runId: string): RoomMemberSeat[];
@@ -0,0 +1,90 @@
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { binderKey } from '../agent-ours/state.js';
6
+ import { readClientProfile } from '../client-profile.js';
7
+ import { readProvenance } from '../creation.js';
8
+ import { agentDir, stateRoot } from '../paths.js';
9
+ import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempArchiveForCreationAction } from '../temp-lifecycle.js';
10
+ import { assertMemberIdentityAbsent, removeExactMemberIdentity, waitForLivenessAbsent } from './close.js';
11
+ import { assertMemberNotPermanent } from './member-ownership.js';
12
+ import { retireTaskMemberService } from './task-service-retirement.js';
13
+ function proof(path) {
14
+ const stat = lstatSync(path);
15
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
16
+ throw Error('LAYOUT_MEMBER_UNSAFE_PROOF');
17
+ return readFileSync(path, 'utf8');
18
+ }
19
+ export function layoutOwnedMemberName(runId, key) {
20
+ return `layout-${createHash('sha256').update(runId + ':' + key).digest('hex').slice(0, 16)}`;
21
+ }
22
+ function assertOwned(instance, runId, key) {
23
+ if (instance.remote || !instance.agent || instance.temporary !== true
24
+ || instance.agent !== layoutOwnedMemberName(runId, key) || instance.supervisor !== stateRoot())
25
+ throw Error('LAYOUT_MEMBER_NOT_OWNED');
26
+ assertMemberNotPermanent(instance.agent);
27
+ }
28
+ function seatFor(instance, key, action, launchId) {
29
+ return { role_name: instance.agent, identity_cid: instance.cid, slot: key, cowork_role: key, seat_state: 'removed',
30
+ launch: { state: 'stopped', action_id: action, launch_id: launchId, attempt: 1, updated_at: '' } };
31
+ }
32
+ /** Retire a local layout factory result, never a borrowed binding. The runtime
33
+ * instance and CID fence replacement supervisors even when already stopped.
34
+ */
35
+ export async function retireOwnedLayoutMember(instance, runId, key, lifecycle = {}, taskSupervised = false) {
36
+ assertOwned(instance, runId, key);
37
+ const action = `${runId}:${key}`, dir = agentDir(instance.agent, true);
38
+ const archived = tempArchiveForCreationAction(instance.agent, action);
39
+ if (!existsSync(dir) && !archived) {
40
+ if (runId.startsWith('task-'))
41
+ await retireTaskMemberService(instance.agent, { taskId: runId.slice(5), creationActionId: action, taskSupervised }, lifecycle.exec);
42
+ // A prior erasure may already have consumed its archive. No live state plus
43
+ // authoritative identity absence is required before accepting completion.
44
+ await assertMemberIdentityAbsent({ role_name: instance.agent, identity_cid: instance.cid,
45
+ slot: key, cowork_role: key, seat_state: 'removed' });
46
+ return;
47
+ }
48
+ const source = existsSync(dir) ? dir : archived.path;
49
+ const supervisor = readTempSupervisor(source), provenance = readProvenance(source);
50
+ if (!supervisor || supervisor.role !== instance.agent || provenance?.role !== instance.agent
51
+ || provenance.creationActionId !== action || proof(join(source, '.identity')).trim() !== instance.agent)
52
+ throw Error('LAYOUT_MEMBER_LAUNCH_MISMATCH');
53
+ {
54
+ const role = parse(proof(join(source, 'role.yaml')));
55
+ if (role?.name !== instance.agent || role.identity !== instance.agent)
56
+ throw Error('LAYOUT_MEMBER_ROLE_MISMATCH');
57
+ const profile = readClientProfile({ ...process.env, ...role.env });
58
+ const privateDir = join(stateRoot(), 'private-ours', binderKey(profile.expectedInstanceId, instance.agent));
59
+ const runtime = JSON.parse(proof(join(privateDir, 'state.json')));
60
+ const runtimeInstance = JSON.parse(proof(join(privateDir, 'instance.json')));
61
+ if (runtime.instance !== instance.launch || runtimeInstance.instance !== instance.launch
62
+ || runtimeInstance.role !== instance.agent || runtimeInstance.temporary !== true
63
+ || runtime.name !== instance.agent || runtime.daemon !== profile.expectedInstanceId
64
+ || runtime.lifetime !== 'temporary' || runtime.action !== action
65
+ || runtime.cid?.toLowerCase() !== instance.cid.toLowerCase())
66
+ throw Error('LAYOUT_MEMBER_INSTANCE_MISMATCH');
67
+ if (runId.startsWith('task-'))
68
+ await retireTaskMemberService(instance.agent, { taskId: runId.slice(5), creationActionId: action, launchId: supervisor.launchId, taskSupervised }, lifecycle.exec);
69
+ if (existsSync(dir)) {
70
+ await stopTempSupervisor(instance.agent, lifecycle);
71
+ await waitForLivenessAbsent(instance.agent, supervisor.launchId, lifecycle);
72
+ }
73
+ }
74
+ await secureStoppedTempArchive(instance.agent, supervisor.launchId, lifecycle);
75
+ await removeExactMemberIdentity(seatFor(instance, key, action, supervisor.launchId));
76
+ await assertMemberIdentityAbsent(seatFor(instance, key, action, supervisor.launchId));
77
+ }
78
+ /** Exact archived launches used by the existing artifact erasure saga. */
79
+ export function ownedLayoutRetirementSeats(state, runId) {
80
+ return Object.entries(state.participants).flatMap(([key, participant]) => {
81
+ if (!participant.owned || !participant.instance)
82
+ return [];
83
+ const instance = participant.instance;
84
+ assertOwned(instance, runId, key);
85
+ if (!participant.retired)
86
+ throw Error('LAYOUT_MEMBER_NOT_RETIRED');
87
+ const action = `${runId}:${key}`, archive = tempArchiveForCreationAction(instance.agent, action);
88
+ return [seatFor(instance, key, action, archive?.launchId ?? 'absent-verified')];
89
+ });
90
+ }
@@ -5,6 +5,7 @@ export declare class NativeLayoutSupervisor implements LayoutSupervisor {
5
5
  private configPath;
6
6
  private runId;
7
7
  private templates;
8
+ readonly taskSupervised: boolean;
8
9
  readonly id: string;
9
10
  constructor(configPath: string | undefined, runId: string, templates: Record<string, AgentTemplateDefinition>);
10
11
  private request;