@ours.network/fleet 1.2.0-nightly.13 → 1.2.0-nightly.14

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.
@@ -282,6 +282,9 @@ export declare class TaskRoomApplicationService {
282
282
  actor: TaskRoomActor;
283
283
  roomId: string;
284
284
  }): Promise<{
285
+ room: undefined;
286
+ settlementRequired: true;
287
+ } | {
285
288
  room: RoomOrchestrationRecord;
286
289
  settlementRequired: true;
287
290
  }>;
@@ -638,7 +638,7 @@ export class TaskRoomApplicationService {
638
638
  createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
639
639
  const room = getRoomRecord(input.roomId);
640
640
  if (!room) {
641
- throw new TaskRoomApplicationError('room_record_not_found', 'room record not found', { room: input.roomId });
641
+ return { room: undefined, settlementRequired: true };
642
642
  }
643
643
  return { room: await acceptManagedRoomClose(input.roomId), settlementRequired: true };
644
644
  }
@@ -36,3 +36,8 @@ export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<
36
36
  * intact.
37
37
  */
38
38
  export declare function replaceFileAtomically(path: string, contents: string, mode?: number, deps?: WriteDeps): void;
39
+ /** Synchronous Lamport bakery lock: unique claims are never reused, so stale
40
+ * reclamation cannot remove a successor's lock. Atomic claim replacement keeps
41
+ * choosing/ticket reads complete. Dead claims can be removed by any contender.
42
+ */
43
+ export declare function withSynchronousFileLock<T>(path: string, work: () => T): T;
@@ -1,4 +1,4 @@
1
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  const DEFAULT_STALE_MS = 10_000;
@@ -136,3 +136,64 @@ export function replaceFileAtomically(path, contents, mode = 0o600, deps = {}) {
136
136
  }
137
137
  catch { /* platform does not allow fsync on a directory */ }
138
138
  }
139
+ /** Synchronous Lamport bakery lock: unique claims are never reused, so stale
140
+ * reclamation cannot remove a successor's lock. Atomic claim replacement keeps
141
+ * choosing/ticket reads complete. Dead claims can be removed by any contender.
142
+ */
143
+ export function withSynchronousFileLock(path, work) {
144
+ mkdirSync(path, { recursive: true, mode: 0o700 });
145
+ const token = `${process.pid}-${randomUUID()}.json`;
146
+ const own = join(path, token);
147
+ const deadline = Date.now() + 10_000;
148
+ const claims = () => {
149
+ const result = [];
150
+ for (const name of readdirSync(path).filter(name => /^[0-9]+-[a-f0-9-]{36}\.json$/.test(name))) {
151
+ const file = join(path, name);
152
+ let value;
153
+ try {
154
+ value = JSON.parse(readFileSync(file, 'utf8'));
155
+ }
156
+ catch (error) {
157
+ if (error.code === 'ENOENT')
158
+ continue;
159
+ throw error;
160
+ }
161
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || !Number.isSafeInteger(value.ticket) || value.ticket < 0)
162
+ throw new Error('Invalid synchronous lock claim');
163
+ if (name !== token) {
164
+ try {
165
+ process.kill(value.pid, 0);
166
+ }
167
+ catch (error) {
168
+ if (error.code === 'ESRCH') {
169
+ // This dead contender alone owned this UUID path. Never remove
170
+ // the namespace or a successor's differently named claim.
171
+ rmSync(file, { force: true });
172
+ continue;
173
+ }
174
+ }
175
+ }
176
+ result.push({ name, value });
177
+ }
178
+ return result;
179
+ };
180
+ replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: true, ticket: 0 }));
181
+ try {
182
+ const ticket = Math.max(0, ...claims().map(claim => claim.value.ticket)) + 1;
183
+ if (!Number.isSafeInteger(ticket))
184
+ throw new Error('Synchronous lock ticket overflow');
185
+ replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: false, ticket }));
186
+ for (;;) {
187
+ const preceding = claims().some(claim => claim.name !== token && (claim.value.choosing
188
+ || claim.value.ticket < ticket || (claim.value.ticket === ticket && claim.name < token)));
189
+ if (!preceding)
190
+ return work();
191
+ if (Date.now() >= deadline)
192
+ throw new Error('Timed out acquiring synchronous journal lock');
193
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
194
+ }
195
+ }
196
+ finally {
197
+ rmSync(own, { force: true });
198
+ }
199
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.13",
3
- "buildId": "3385b7b09b31",
4
- "commit": "76ea214e69c007a06469b0defee3634779434657",
2
+ "version": "1.2.0-nightly.14",
3
+ "buildId": "b88d0d158cc7",
4
+ "commit": "717f6d8a09e6173b9b8a017fc5efb08d71927524",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-24T13:41:37.102Z",
6
+ "builtAt": "2026-09-24T14:15:57.206Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -0,0 +1,9 @@
1
+ export declare const erasedArg: (value: string) => string;
2
+ export declare function redactErasedContent<T>(value: T): T;
3
+ /** Every cooperative writer filters stale in-memory snapshots under the same lock as erasure. */
4
+ export declare function writePrivacyFilteredLedger(path: string, value: unknown): void;
5
+ /** Content-free resource IDs persist to prevent active writers resurrecting erased labels. */
6
+ export declare function eraseResourcePresentations(resources: ReadonlyArray<{
7
+ kind: 'task' | 'room' | 'agent';
8
+ id: string;
9
+ }>): void;
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
5
+ import { stateRoot } from './paths.js';
6
+ const registryPath = () => join(stateRoot(), 'erased-resources.json');
7
+ const lockPath = () => join(stateRoot(), 'locks', 'audit-erasure');
8
+ const ledgerNames = ['.fleet-command-audit.json', '.owner-channel-command-audit.json', '.owner-channel-lifecycle-outbox.json'];
9
+ export const erasedArg = (value) => `[erased:${createHash('sha256').update(value).digest('hex')}]`;
10
+ function registry() {
11
+ if (!existsSync(registryPath()))
12
+ return new Set();
13
+ const value = JSON.parse(readFileSync(registryPath(), 'utf8'));
14
+ if (!Array.isArray(value) || value.some(v => typeof v !== 'string'))
15
+ throw new Error('Invalid erasure registry');
16
+ return new Set(value);
17
+ }
18
+ /** Preserve delivery/deduplication metadata while removing owned presentation content. */
19
+ function redact(value, erased) {
20
+ if (Array.isArray(value))
21
+ return value.map(item => redact(item, erased));
22
+ if (!value || typeof value !== 'object')
23
+ return value;
24
+ const next = { ...value };
25
+ const kind = value.kind === 'lifecycle_failure' ? value.resource?.toLowerCase() : value.kind === 'agent_started' ? 'agent' : value.kind;
26
+ if (typeof value.id === 'string' && erased.has(`${kind}:${value.id}`)) {
27
+ for (const key of ['title', 'reason', 'roomName', 'label', 'template', 'configuration', 'model', 'permissions'])
28
+ delete next[key];
29
+ if (kind === 'task')
30
+ next.agents = [];
31
+ if (kind === 'room') {
32
+ delete next.name;
33
+ next.participants = [];
34
+ }
35
+ if (kind === 'agent') {
36
+ next.name = 'erased';
37
+ next.brain = '[erased]';
38
+ next.role = '[erased]';
39
+ next.inherited = [];
40
+ }
41
+ }
42
+ if (typeof value.roomId === 'string' && erased.has(`room:${value.roomId}`)) {
43
+ delete next.roomName;
44
+ if (kind === 'task')
45
+ next.agents = [];
46
+ }
47
+ const resources = value.outcome?.resourceIds;
48
+ const ownsAttempt = resources && Object.entries(resources).some(([key, id]) => erased.has(`${key}:${id}`));
49
+ const ownsPresentation = value.outcome?.presentations?.some((p) => erased.has(`${p.kind === 'agent_started' ? 'agent' : p.kind}:${p.id}`));
50
+ if ((ownsAttempt || ownsPresentation) && Array.isArray(value.argv) && !value.erased) {
51
+ next.argv = value.argv.map(erasedArg);
52
+ next.erased = true;
53
+ }
54
+ for (const key of Object.keys(next))
55
+ if (typeof next[key] === 'object')
56
+ next[key] = redact(next[key], erased);
57
+ return next;
58
+ }
59
+ export function redactErasedContent(value) { return redact(value, registry()); }
60
+ /** Every cooperative writer filters stale in-memory snapshots under the same lock as erasure. */
61
+ export function writePrivacyFilteredLedger(path, value) {
62
+ withSynchronousFileLock(lockPath(), () => replaceFileAtomically(path, JSON.stringify(redact(value, registry())) + '\n'));
63
+ }
64
+ /** Content-free resource IDs persist to prevent active writers resurrecting erased labels. */
65
+ export function eraseResourcePresentations(resources) {
66
+ withSynchronousFileLock(lockPath(), () => {
67
+ const erased = registry();
68
+ for (const resource of resources)
69
+ erased.add(`${resource.kind}:${resource.id}`);
70
+ replaceFileAtomically(registryPath(), JSON.stringify([...erased]));
71
+ for (const root of [join(stateRoot(), 'agents'), join(stateRoot(), 'tmp'), join(stateRoot(), 'recovery', 'temporary')]) {
72
+ if (!existsSync(root))
73
+ continue;
74
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
75
+ if (!entry.isDirectory() || entry.isSymbolicLink())
76
+ continue;
77
+ for (const name of ledgerNames) {
78
+ const path = join(root, entry.name, name);
79
+ if (!existsSync(path) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())
80
+ continue;
81
+ const old = JSON.parse(readFileSync(path, 'utf8'));
82
+ replaceFileAtomically(path, JSON.stringify(redact(old, erased)) + '\n');
83
+ }
84
+ }
85
+ }
86
+ });
87
+ }
@@ -113,6 +113,7 @@ export interface FleetCommandClassification {
113
113
  decision: FleetCommandDecision;
114
114
  }
115
115
  export interface FleetAuditAttempt {
116
+ erased?: boolean;
116
117
  version: 1;
117
118
  correlationId: string;
118
119
  requestId: string;
@@ -1,7 +1,7 @@
1
+ import { erasedArg, redactErasedContent, writePrivacyFilteredLedger } from './erased-resources.js';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { Buffer } from 'node:buffer';
4
- import { replaceFileAtomically } from './atomic-file.js';
5
5
  import { isSensitiveConfigKey } from './sensitive-config.js';
6
6
  import { mandatoryConfigurationFits } from './lifecycle-summary.js';
7
7
  import { MARKDOWN_MAX_BYTES, MARKDOWN_MAX_CODE_POINTS, markdownCode, markdownProse, } from './rooms-tasks/markdown.js';
@@ -91,7 +91,7 @@ export function recordFleetAuditPresentation(value) {
91
91
  : value.kind === 'lifecycle_failure'
92
92
  ? { label: fleetPresentationLabel(value.label) }
93
93
  : {};
94
- const presentation = structuredClone({ ...value, ...labels, eventId: basis });
94
+ const presentation = redactErasedContent(structuredClone({ ...value, ...labels, eventId: basis }));
95
95
  const digest = lifecycleEventDigestBasis(presentation);
96
96
  if (!collection.presentations.some(existing => lifecycleEventDigestBasis(existing) === digest))
97
97
  collection.presentations.push(presentation);
@@ -305,11 +305,12 @@ export class FleetCommandAuditStore {
305
305
  if (recovered)
306
306
  this.persist();
307
307
  }
308
- list() { return this.attempts.map(item => structuredClone(item)); }
308
+ list() { return redactErasedContent(this.attempts).map(item => structuredClone(item)); }
309
309
  begin(requestId, caller, argv) {
310
+ this.attempts = redactErasedContent(this.attempts);
310
311
  const existing = this.attempts.find(item => item.caller === caller && item.requestId === requestId);
311
312
  if (existing) {
312
- if (JSON.stringify(existing.argv) !== JSON.stringify(redactFleetArgv(argv)))
313
+ if (JSON.stringify(existing.argv) !== JSON.stringify(existing.erased ? redactFleetArgv(argv).map(erasedArg) : redactFleetArgv(argv)))
313
314
  throw new Error('fleet command request ID was reused with different argv');
314
315
  return structuredClone(existing);
315
316
  }
@@ -329,6 +330,8 @@ export class FleetCommandAuditStore {
329
330
  return structuredClone(attempt);
330
331
  }
331
332
  finish(correlationId, caller, outcome) {
333
+ this.attempts = redactErasedContent(this.attempts);
334
+ outcome = redactErasedContent({ outcome }).outcome;
332
335
  const attempt = this.owned(correlationId, caller);
333
336
  if (!attempt.outcome)
334
337
  attempt.outcome = { ...outcome, completedAt: this.deps.now().toISOString(), delivery: 'sending' };
@@ -360,7 +363,8 @@ export class FleetCommandAuditStore {
360
363
  return attempt;
361
364
  }
362
365
  persist() {
363
- replaceFileAtomically(this.path, `${JSON.stringify({ version: 1, attempts: this.attempts })}\n`, 0o600);
366
+ this.attempts = redactErasedContent(this.attempts);
367
+ writePrivacyFilteredLedger(this.path, { version: 1, attempts: this.attempts });
364
368
  }
365
369
  }
366
370
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
@@ -1,6 +1,6 @@
1
+ import { redactErasedContent, writePrivacyFilteredLedger } from '../erased-resources.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { chmodSync, existsSync, readFileSync } from 'node:fs';
3
- import { replaceFileAtomically } from '../atomic-file.js';
4
4
  import { lifecycleEventDigestBasis, validateFleetAuditPresentations, } from '../fleet-command-audit.js';
5
5
  const DIGEST = /^[a-f0-9]{64}$/u;
6
6
  const LIMIT = 5_000;
@@ -63,7 +63,7 @@ export class FleetLifecycleOutbox {
63
63
  }
64
64
  pending() {
65
65
  this.assertHealthy();
66
- return this.entries.filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
66
+ return redactErasedContent(this.entries).filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
67
67
  }
68
68
  finish(digest, delivery) {
69
69
  this.assertHealthy();
@@ -82,6 +82,7 @@ export class FleetLifecycleOutbox {
82
82
  throw new Error(this.corruptReason);
83
83
  }
84
84
  persist() {
85
- replaceFileAtomically(this.path, `${JSON.stringify({ version: 1, entries: this.entries })}\n`, 0o600);
85
+ this.entries = redactErasedContent(this.entries);
86
+ writePrivacyFilteredLedger(this.path, { version: 1, entries: this.entries });
86
87
  }
87
88
  }
@@ -27,7 +27,7 @@ export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrch
27
27
  export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
28
28
  export declare function closeManagedRoom(input: {
29
29
  roomId: string;
30
- cowork: Pick<CoworkAdapter, 'closeRoom'>;
30
+ cowork: Pick<CoworkAdapter, 'closeRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
31
31
  deps?: RoomCloseDeps;
32
32
  }): Promise<RoomOrchestrationRecord>;
33
33
  export interface ManagedRoomDeleteResult {
@@ -41,6 +41,6 @@ export declare function deleteLegacyClosedRooms(input: {
41
41
  /** Retire live resources through the existing cursor, then delete retained state. */
42
42
  export declare function deleteManagedRoom(input: {
43
43
  roomId: string;
44
- cowork: Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
44
+ cowork: Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
45
45
  deps?: RoomCloseDeps;
46
46
  }): Promise<ManagedRoomDeleteResult>;
@@ -1,3 +1,6 @@
1
+ import { detachDeletedRoom } from './task-state.js';
2
+ import { binderKey } from '../agent-ours/state.js';
3
+ import { eraseMemberArtifacts } from './erasure.js';
1
4
  import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
2
5
  import { collectWorkspaceArchives } from './workspace-artifacts.js';
3
6
  import { existsSync, readFileSync } from 'node:fs';
@@ -9,7 +12,7 @@ import { agentDir, stateRoot } from '../paths.js';
9
12
  import { readClientProfile } from '../client-profile.js';
10
13
  import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, tempArchiveForLaunch, tempArchiveForCreationAction, } from '../temp-lifecycle.js';
11
14
  import { CoworkProtocolError } from './cowork-adapter.js';
12
- import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
15
+ import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, recordRetirementMemberCids, } from './room-state.js';
13
16
  export const CLOSE_LOCK_STALE_MS = 5 * 60_000;
14
17
  const STOP_POLLS = 50;
15
18
  const STOP_POLL_MS = 100;
@@ -22,6 +25,20 @@ function errorText(error) {
22
25
  function exactMemberIdentity(seat) {
23
26
  const dir = agentDir(seat.role_name, true);
24
27
  const identityPath = join(dir, '.identity');
28
+ if (existsSync(dir) && !existsSync(identityPath) && seat.launch?.action_id && seat.launch.launch_id) {
29
+ const supervisor = readTempSupervisor(dir);
30
+ let creation;
31
+ try {
32
+ creation = JSON.parse(readFileSync(join(dir, 'creation.json'), 'utf8'));
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 'ENOENT')
36
+ throw error;
37
+ }
38
+ if (supervisor?.role === seat.role_name && supervisor.launchId === seat.launch.launch_id
39
+ && creation?.role === seat.role_name && creation.creationActionId === seat.launch.action_id)
40
+ return;
41
+ }
25
42
  if (!existsSync(dir) || !existsSync(identityPath)) {
26
43
  throw new Error(`room member '${seat.role_name}' has no live Fleet temp-state identity proof; refusing retirement`);
27
44
  }
@@ -33,7 +50,8 @@ function exactMemberIdentity(seat) {
33
50
  export async function inspectMember(seat) {
34
51
  exactMemberIdentity(seat);
35
52
  const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
36
- if (!supervisor || supervisor.role !== seat.role_name) {
53
+ if (!supervisor || supervisor.role !== seat.role_name
54
+ || (seat.launch?.launch_id && supervisor.launchId !== seat.launch.launch_id)) {
37
55
  throw new Error(`room member '${seat.role_name}' has no exact Fleet supervisor ownership proof`);
38
56
  }
39
57
  return { launchId: supervisor.launchId };
@@ -93,14 +111,19 @@ export async function identityCidPresent(cid) {
93
111
  }
94
112
  export async function removeExactMemberIdentity(seat) {
95
113
  await withIdentityClient(async (client) => {
96
- const before = listedIdentity(await client.listIdentities(), seat.role_name);
97
- if (!before)
114
+ const rows = await client.listIdentities();
115
+ const before = listedIdentity(rows, seat.role_name);
116
+ if (!before) {
117
+ if (rows.some(row => row.name === seat.role_name || (seat.identity_cid && 'cid' in row
118
+ && row.cid.toLowerCase() === seat.identity_cid.toLowerCase())))
119
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven`);
98
120
  return;
121
+ }
99
122
  if (!seat.identity_cid) {
100
- throw new Error(`room member '${seat.role_name}' exists without a recorded authenticated CID; refusing removal`);
123
+ throw new Error(`room member '${seat.role_name}' exists without a recorded authenticated CID; identity absence is not proven; refusing removal`);
101
124
  }
102
125
  if (before.cid?.toLowerCase() !== seat.identity_cid.toLowerCase()) {
103
- throw new Error(`room member '${seat.role_name}' identity CID mismatch: recorded ${seat.identity_cid}, found ${before.cid ?? 'none'}`);
126
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven: CID mismatch: recorded ${seat.identity_cid}, found ${before.cid ?? 'none'}`);
104
127
  }
105
128
  try {
106
129
  await client.removeIdentity({ name: seat.role_name });
@@ -115,8 +138,9 @@ export async function removeExactMemberIdentity(seat) {
115
138
  if (after)
116
139
  throw error;
117
140
  }
118
- const after = listedIdentity(await client.listIdentities(), seat.role_name);
119
- if (after) {
141
+ const after = await client.listIdentities();
142
+ if (after.some(row => row.name === seat.role_name || (seat.identity_cid && 'cid' in row
143
+ && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
120
144
  throw new Error(`room member '${seat.role_name}' identity still exists after remove_identity`);
121
145
  }
122
146
  });
@@ -135,9 +159,9 @@ async function retireMember(roomId, seat, deps) {
135
159
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
136
160
  let retirement = current.retirement;
137
161
  if (retirement?.phase === 'identity_absent') {
138
- if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
162
+ if (!deps.inspectMember) {
139
163
  if (existsSync(agentDir(current.role_name, true)))
140
- throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
164
+ throw new Error('Retired member has replacement launch live state');
141
165
  await assertMemberIdentityAbsent(current);
142
166
  }
143
167
  return;
@@ -146,6 +170,8 @@ async function retireMember(roomId, seat, deps) {
146
170
  if (!existsSync(agentDir(current.role_name, true)) && current.launch?.launch_id && current.launch.action_id) {
147
171
  const archived = tempArchiveForLaunch(current.role_name, current.launch.launch_id);
148
172
  const created = tempArchiveForCreationAction(current.role_name, current.launch.action_id);
173
+ if (archived && (!created || created.path !== archived || created.launchId !== current.launch.launch_id))
174
+ throw new Error('Archived member creation ownership mismatch');
149
175
  if (archived && created?.path === archived && created.launchId === current.launch.launch_id) {
150
176
  if (readFileSync(join(archived, '.identity'), 'utf8').trim() !== current.role_name)
151
177
  throw new Error(`room member '${current.role_name}' archive identity mismatch`);
@@ -153,6 +179,7 @@ async function retireMember(roomId, seat, deps) {
153
179
  throw new Error(`room member '${current.role_name}' archived supervisor is not proven stopped`);
154
180
  // A terminated launch can archive itself before room retirement begins.
155
181
  // Accept its exact durable provenance only when no identity needs removal.
182
+ await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
156
183
  await assertMemberIdentityAbsent(current);
157
184
  const latest = getRoomRecord(roomId)?.member_seats.find(seat => seat.role_name === current.role_name);
158
185
  if (existsSync(agentDir(current.role_name, true))
@@ -164,14 +191,6 @@ async function retireMember(roomId, seat, deps) {
164
191
  return;
165
192
  }
166
193
  }
167
- if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
168
- // A failure before applyRole (for example invite-secret validation) has
169
- // no supervisor to stop or archive. Settle only proven absence; this
170
- // path never removes an identity based on missing local evidence.
171
- await assertMemberIdentityAbsent(current);
172
- advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
173
- return;
174
- }
175
194
  if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
176
195
  if (existsSync(agentDir(current.role_name, true))) {
177
196
  throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
@@ -180,12 +199,31 @@ async function retireMember(roomId, seat, deps) {
180
199
  advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'never-launched');
181
200
  return;
182
201
  }
202
+ if (!deps.inspectMember && !existsSync(agentDir(current.role_name, true))) {
203
+ await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
204
+ if (existsSync(agentDir(current.role_name, true)))
205
+ throw new Error(`room member '${current.role_name}' acquired replacement state during retirement`);
206
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'absent-verified');
207
+ return;
208
+ }
183
209
  const ownership = await (deps.inspectMember ?? inspectMember)(current);
184
210
  room = advanceMemberRetirement(roomId, current.role_name, 'stop_requested', ownership.launchId);
185
211
  current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
186
212
  retirement = current.retirement;
187
213
  }
214
+ if (['stop_requested', 'liveness_absent', 'archive_secured'].includes(retirement.phase) && !deps.requestStop
215
+ && !existsSync(agentDir(current.role_name, true))) {
216
+ const archive = tempArchiveForLaunch(current.role_name, retirement.launch_id);
217
+ if (archive && await tempSupervisorLiveness(archive) !== 'stopped')
218
+ throw new Error('Archived supervisor is not proven stopped');
219
+ await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
220
+ await assertMemberIdentityAbsent(current);
221
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', retirement.launch_id, archive, !archive);
222
+ return;
223
+ }
188
224
  if (retirement.phase === 'stop_requested') {
225
+ if (!deps.inspectMember && (await inspectMember(current)).launchId !== retirement.launch_id)
226
+ throw new Error('Member launch changed before stop');
189
227
  await (deps.requestStop ?? (async (role) => { await stopTempSupervisor(role); }))(current.role_name);
190
228
  await (deps.waitForLivenessAbsent ?? waitForLivenessAbsent)(current.role_name, retirement.launch_id);
191
229
  room = advanceMemberRetirement(roomId, current.role_name, 'liveness_absent', retirement.launch_id);
@@ -215,17 +253,31 @@ export async function closeManagedRoom(input) {
215
253
  const lock = deps.lock ?? withFileLock;
216
254
  return lock(roomCloseLockPath(input.roomId), async () => {
217
255
  let room = beginRoomClose(input.roomId);
218
- if (room.state === 'closed')
256
+ if (room.state === 'closed') {
257
+ if (!deps.inspectMember)
258
+ for (const seat of room.member_seats) {
259
+ if (existsSync(agentDir(seat.role_name, true)))
260
+ throw new Error('Closed room has replacement live state');
261
+ await assertMemberIdentityAbsent(seat);
262
+ }
219
263
  return room;
264
+ }
220
265
  try {
221
266
  if (room.close?.phase === 'retire_members') {
267
+ room = await recoverMemberCids(room, input.cowork);
222
268
  for (const seat of room.member_seats) {
223
269
  await retireMember(input.roomId, seat, deps);
224
270
  }
225
271
  room = advanceRoomClose(input.roomId, 'close_cowork');
226
272
  }
227
273
  if (room.close?.phase === 'close_cowork') {
228
- await input.cowork.closeRoom(input.roomId);
274
+ try {
275
+ await input.cowork.closeRoom(input.roomId);
276
+ }
277
+ catch (error) {
278
+ if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
279
+ throw error;
280
+ }
229
281
  }
230
282
  return closeRoom(input.roomId);
231
283
  }
@@ -258,7 +310,8 @@ export async function deleteLegacyClosedRooms(input) {
258
310
  }
259
311
  /** Retire live resources through the existing cursor, then delete retained state. */
260
312
  export async function deleteManagedRoom(input) {
261
- await closeManagedRoom(input);
313
+ if (getRoomRecord(input.roomId))
314
+ await closeManagedRoom(input);
262
315
  return withFileLock(roomCloseLockPath(input.roomId), async () => {
263
316
  try {
264
317
  try {
@@ -276,12 +329,65 @@ export async function deleteManagedRoom(input) {
276
329
  collectWorkspaceArchives(retained.workspace);
277
330
  deleteWorkspace(retained.workspace, 'room', input.roomId);
278
331
  }
332
+ if (retained)
333
+ await eraseMemberArtifacts('room', input.roomId, retained.member_seats, [input.roomId]);
334
+ if (retained?.task_id)
335
+ detachDeletedRoom(retained.task_id, input.roomId);
279
336
  deleteRoomRecord(input.roomId);
280
337
  return { room_id: input.roomId, deleted: true };
281
338
  }
282
339
  catch (error) {
283
- setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
340
+ if (getRoomRecord(input.roomId))
341
+ setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
284
342
  throw error;
285
343
  }
286
344
  }, {}, CLOSE_LOCK_STALE_MS);
287
345
  }
346
+ /** Recover only an authenticated seat tied to this exact room and issued invite.
347
+ * Display names and the current daemon name inventory are never binding proof.
348
+ */
349
+ async function recoverMemberCids(room, cowork) {
350
+ if (!room.member_seats.some(seat => !seat.identity_cid))
351
+ return room;
352
+ const profile = room.member_seats.some(seat => seat.launch?.action_id) ? readClientProfile(process.env) : undefined;
353
+ let remote;
354
+ if (cowork.getRoom && room.room_identity_cid) {
355
+ try {
356
+ remote = await cowork.getRoom(room.room_id);
357
+ }
358
+ catch (error) {
359
+ if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
360
+ throw error;
361
+ }
362
+ if (remote && (remote.room_id !== room.room_id || remote.identity_cid.toLowerCase() !== room.room_identity_cid.toLowerCase()))
363
+ throw new Error('Room identity mismatch during deletion recovery');
364
+ }
365
+ const seats = room.member_seats.map(seat => {
366
+ let runtimeCid;
367
+ if (profile && seat.launch?.action_id) {
368
+ const dir = join(stateRoot(), 'private-ours', binderKey(profile.expectedInstanceId, seat.role_name));
369
+ try {
370
+ const state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'));
371
+ const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
372
+ if (state.name === seat.role_name && state.daemon === profile.expectedInstanceId
373
+ && state.lifetime === 'temporary' && state.action === seat.launch.action_id
374
+ && instance.role === seat.role_name && instance.temporary === true && instance.instance === state.instance)
375
+ runtimeCid = state.cid;
376
+ }
377
+ catch (error) {
378
+ if (error.code !== 'ENOENT')
379
+ throw error;
380
+ }
381
+ }
382
+ const matches = seat.invite_id ? remote?.seats.filter(value => value.invite_id === seat.invite_id && value.role === seat.cowork_role) ?? [] : [];
383
+ if (matches.length > 1)
384
+ throw new Error('Ambiguous authenticated member identity during deletion recovery');
385
+ const candidates = [seat.identity_cid, runtimeCid, matches[0]?.identity_cid].filter((v) => !!v);
386
+ if (candidates.some(cid => !/^[a-f0-9]{64}$/i.test(cid)))
387
+ throw new Error('Invalid authenticated member CID');
388
+ if (new Set(candidates.map(cid => cid.toLowerCase())).size > 1)
389
+ throw new Error('Contradictory authenticated member CID evidence');
390
+ return candidates.length ? { ...seat, identity_cid: candidates[0] } : seat;
391
+ });
392
+ return recordRetirementMemberCids(room.room_id, seats);
393
+ }
@@ -3,7 +3,7 @@ import { type CoworkAdapter } from './cowork-adapter.js';
3
3
  import { type TaskDeletionAcceptance } from './task-state.js';
4
4
  import type { TaskDeletionActor, TaskRecord } from './types.js';
5
5
  export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
6
- type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
6
+ type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
7
7
  export interface TaskDeletionSettleDeps {
8
8
  roomClose?: RoomCloseDeps;
9
9
  /** Test seam for the temp-state existence proof. */