@ours.network/fleet 1.2.0-nightly.12 → 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.
Files changed (108) hide show
  1. package/README.md +2 -0
  2. package/dist/application/model-catalog.d.ts +1 -1
  3. package/dist/application/model-catalog.js +5 -2
  4. package/dist/application/task-room-service.d.ts +3 -0
  5. package/dist/application/task-room-service.js +1 -1
  6. package/dist/atomic-file.d.ts +5 -0
  7. package/dist/atomic-file.js +62 -1
  8. package/dist/build-info.json +4 -4
  9. package/dist/erased-resources.d.ts +9 -0
  10. package/dist/erased-resources.js +87 -0
  11. package/dist/fleet-command-audit.d.ts +1 -0
  12. package/dist/fleet-command-audit.js +9 -5
  13. package/dist/init-wizard.js +10 -3
  14. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  15. package/dist/rooms-tasks/close.d.ts +2 -2
  16. package/dist/rooms-tasks/close.js +128 -22
  17. package/dist/rooms-tasks/deletion.d.ts +1 -1
  18. package/dist/rooms-tasks/deletion.js +41 -9
  19. package/dist/rooms-tasks/erasure.d.ts +6 -0
  20. package/dist/rooms-tasks/erasure.js +169 -0
  21. package/dist/rooms-tasks/room-state.d.ts +3 -1
  22. package/dist/rooms-tasks/room-state.js +37 -7
  23. package/dist/rooms-tasks/task-state.d.ts +10 -4
  24. package/dist/rooms-tasks/task-state.js +54 -12
  25. package/dist/rooms-tasks/types.d.ts +2 -0
  26. package/dist/temp-lifecycle.d.ts +5 -0
  27. package/dist/temp-lifecycle.js +28 -12
  28. package/docs/task-workspaces.md +34 -2
  29. package/package.json +1 -1
  30. package/presets/README.md +66 -0
  31. package/presets/brain-catalog.json +7 -2
  32. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  33. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  34. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  35. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  36. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  37. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  38. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  39. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  40. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  41. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  42. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  43. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  44. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  45. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  46. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  47. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  48. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  49. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  50. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  51. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  52. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  53. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  54. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  55. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  56. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  57. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  58. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  59. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  60. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  61. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  62. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  63. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  64. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  65. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  66. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  67. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  68. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  69. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  70. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  71. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  72. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  73. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  74. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  75. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  76. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  77. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  78. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  79. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  80. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  81. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  82. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  83. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  84. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  85. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  86. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  87. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  88. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  89. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  90. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  91. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  92. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  93. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  94. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  95. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  96. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  97. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  98. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  99. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  100. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  101. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  102. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  103. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  104. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  105. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  106. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  107. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  108. package/presets/manifest.json +2 -2
@@ -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. */
@@ -1,3 +1,4 @@
1
+ import { eraseMemberArtifacts } from './erasure.js';
1
2
  import { proveArchivedAbsence, verifyArchivedAbsence, verifyArchivedMemberStillAbsent } from './archived-absence.js';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { withFileLock } from '../atomic-file.js';
@@ -5,12 +6,12 @@ import { agentDir } from '../paths.js';
5
6
  import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
6
7
  import { collectWorkspaceArchives } from './workspace-artifacts.js';
7
8
  import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
8
- import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
9
+ import { closeManagedRoom, identityCidPresent, inspectMember, assertMemberIdentityAbsent, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
9
10
  import { CoworkProtocolError } from './cowork-adapter.js';
10
11
  import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
11
12
  import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
12
13
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
13
- import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, beginTaskWorkspaceCleanup, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
14
+ import { advanceTaskDeletionMember, settleAbsentTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, beginTaskArchiveCleanup, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
14
15
  export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
15
16
  import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
16
17
  /**
@@ -43,6 +44,7 @@ function tolerantCowork(cowork) {
43
44
  }
44
45
  };
45
46
  return {
47
+ ...(cowork.getRoom ? { getRoom: cowork.getRoom.bind(cowork) } : {}),
46
48
  closeRoom: roomId => tolerate(() => cowork.closeRoom(roomId)),
47
49
  deleteRoom: roomId => tolerate(() => cowork.deleteRoom(roomId)),
48
50
  };
@@ -51,6 +53,7 @@ function cursorSeat(cursor) {
51
53
  return {
52
54
  role_name: cursor.name, identity_cid: cursor.identity_cid,
53
55
  slot: cursor.name, cowork_role: 'member', seat_state: 'active',
56
+ ...(cursor.launch_id ? { retirement: { phase: 'identity_absent', launch_id: cursor.launch_id, archive_path: cursor.archive_path, updated_at: cursor.updated_at } } : {}),
54
57
  };
55
58
  }
56
59
  /**
@@ -63,8 +66,15 @@ function cursorSeat(cursor) {
63
66
  async function retireCursorMember(taskId, cursor, deps) {
64
67
  let phase = cursor.phase;
65
68
  let launchId = cursor.launch_id;
66
- if (phase === 'identity_absent')
69
+ if (phase === 'identity_absent') {
70
+ if (!deps.roomClose?.inspectMember) {
71
+ if ((deps.hasTempState ?? (name => existsSync(agentDir(name, true))))(cursor.name))
72
+ throw new Error('Retired deletion member has replacement live state');
73
+ if (await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid))
74
+ throw new Error('Retired deletion member identity reappeared');
75
+ }
67
76
  return;
77
+ }
68
78
  const seat = cursorSeat(cursor);
69
79
  const roomClose = deps.roomClose ?? {};
70
80
  if (phase === 'pending') {
@@ -88,7 +98,16 @@ async function retireCursorMember(taskId, cursor, deps) {
88
98
  phase = 'stop_requested';
89
99
  launchId = ownership.launchId;
90
100
  }
101
+ if (!roomClose.inspectMember && !existsSync(agentDir(cursor.name, true))) {
102
+ await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
103
+ if (existsSync(agentDir(cursor.name, true)))
104
+ throw new Error('Deletion member acquired replacement state');
105
+ settleAbsentTaskDeletionMember(taskId, cursor.name);
106
+ return;
107
+ }
91
108
  if (phase === 'stop_requested') {
109
+ if (!roomClose.inspectMember && (await inspectMember(seat)).launchId !== launchId)
110
+ throw new Error('Deletion member launch changed before stop');
92
111
  await (roomClose.requestStop
93
112
  ?? (async (role) => { await stopTempSupervisor(role); }))(cursor.name);
94
113
  await (roomClose.waitForLivenessAbsent ?? waitForLivenessAbsent)(cursor.name, launchId);
@@ -140,8 +159,6 @@ export async function settleTaskDeletion(input) {
140
159
  // aborts settlement (fail closed).
141
160
  ensureTaskDeletionReceipt(taskId);
142
161
  const records = listRoomRecords().filter(room => room.task_id === taskId);
143
- if (task.deletion.workspace_cleanup_started_at && records.length)
144
- throw new TaskStateError(`task ${taskId} room records reappeared after workspace cleanup began`);
145
162
  const recordIds = new Set(records.map(room => room.room_id));
146
163
  const recordedRoomId = task.deletion.room_id ?? task.room_id;
147
164
  const needsCowork = records.length > 0 || recordedRoomId !== undefined;
@@ -153,8 +170,15 @@ export async function settleTaskDeletion(input) {
153
170
  const seats = closed?.member_seats ?? record.member_seats;
154
171
  const proofs = [];
155
172
  for (const seat of seats) {
156
- if (!seat.identity_cid && seat.retirement?.launch_id !== 'never-launched')
157
- proofs.push(await proveArchivedAbsence(seat));
173
+ if (!seat.identity_cid && !seat.retirement?.absence_verified && !['never-launched', 'absent-verified'].includes(seat.retirement?.launch_id ?? '')) {
174
+ const prior = task.deletion.archived_absences?.find(proof => proof.name === seat.role_name);
175
+ if (task.deletion.workspace_cleanup_started_at && prior && !existsSync(prior.archive_path)) {
176
+ await verifyArchivedMemberStillAbsent(prior);
177
+ proofs.push(prior);
178
+ }
179
+ else
180
+ proofs.push(await proveArchivedAbsence(seat));
181
+ }
158
182
  }
159
183
  // No await between the last live-state fence and durable checkpoint.
160
184
  for (const proof of proofs) {
@@ -166,7 +190,9 @@ export async function settleTaskDeletion(input) {
166
190
  importTaskDeletionRetirementEvidence(taskId, seats, proofs);
167
191
  await cowork.deleteRoom(record.room_id);
168
192
  for (const proof of proofs)
169
- await verifyArchivedAbsence(proof);
193
+ await (task.deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
194
+ beginTaskArchiveCleanup(taskId);
195
+ await eraseMemberArtifacts('room', record.room_id, seats, [record.room_id]);
170
196
  for (const proof of proofs)
171
197
  if (existsSync(agentDir(proof.name, true)))
172
198
  throw new Error('Archived member replacement before room unlink');
@@ -216,11 +242,17 @@ export async function settleTaskDeletion(input) {
216
242
  for (const proof of task.deletion.archived_absences ?? [])
217
243
  if (existsSync(agentDir(proof.name, true)))
218
244
  throw new Error('Archived member replacement before task unlink');
245
+ for (const name of task.deletion.absent_members ?? []) {
246
+ if (existsSync(agentDir(name, true)))
247
+ throw new Error('Absent member acquired replacement state');
248
+ await assertMemberIdentityAbsent({ role_name: name, slot: name, cowork_role: 'member', seat_state: 'removed' });
249
+ }
219
250
  if (cleanup.snapshotHash)
220
251
  releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
252
+ await eraseMemberArtifacts('task', taskId, task.deletion.members.map(cursorSeat), task.deletion.room_id ? [task.deletion.room_id] : []);
221
253
  if (task.workspace) {
222
254
  assertWorkspaceDeletable(task.workspace, 'task', taskId);
223
- beginTaskWorkspaceCleanup(taskId);
255
+ beginTaskArchiveCleanup(taskId);
224
256
  collectWorkspaceArchives(task.workspace);
225
257
  deleteWorkspace(task.workspace, 'task', taskId);
226
258
  }
@@ -0,0 +1,6 @@
1
+ import type { RoomMemberSeat } from './types.js';
2
+ /** Erase only proven launch artifacts. Each atomic rename transfers ownership to
3
+ * a random, durable tombstone before recursive removal can destroy its proof.
4
+ * The caller holds the task/room lifecycle lock and has retired all writers.
5
+ */
6
+ export declare function eraseMemberArtifacts(owner: 'room' | 'task', id: string, seats: readonly RoomMemberSeat[], roomIds: readonly string[]): Promise<void>;
@@ -0,0 +1,169 @@
1
+ import { eraseResourcePresentations } from '../erased-resources.js';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { dirname, join, relative } from 'node:path';
5
+ import { parse } from 'yaml';
6
+ import { replaceFileAtomically } from '../atomic-file.js';
7
+ import { agentDir, stateRoot } from '../paths.js';
8
+ import { readTempSupervisor, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
9
+ import { assertSafeAncestors, auditWorkspaceGit } from './workspace.js';
10
+ function entries(path) {
11
+ try {
12
+ return readdirSync(path);
13
+ }
14
+ catch (error) {
15
+ if (error.code === 'ENOENT')
16
+ return [];
17
+ throw error;
18
+ }
19
+ }
20
+ function json(path) {
21
+ try {
22
+ if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())
23
+ return undefined;
24
+ return JSON.parse(readFileSync(path, 'utf8'));
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT')
28
+ return undefined;
29
+ throw error;
30
+ }
31
+ }
32
+ function fingerprint(path) {
33
+ const stat = lstatSync(path);
34
+ const files = stat.isDirectory() ? ['creation.json', '.temp-supervisor.json', '.identity', 'role.yaml', 'state.json', 'instance.json'] : [''];
35
+ const hash = createHash('sha256');
36
+ for (const file of files) {
37
+ const target = file ? join(path, file) : path;
38
+ if (existsSync(target)) {
39
+ if (!lstatSync(target).isFile() || lstatSync(target).isSymbolicLink())
40
+ throw new Error('Unsafe erasure proof');
41
+ hash.update(file).update(readFileSync(target));
42
+ }
43
+ }
44
+ return hash.digest('hex');
45
+ }
46
+ /** Erase only proven launch artifacts. Each atomic rename transfers ownership to
47
+ * a random, durable tombstone before recursive removal can destroy its proof.
48
+ * The caller holds the task/room lifecycle lock and has retired all writers.
49
+ */
50
+ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
51
+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id))
52
+ throw new Error('Invalid erasure owner');
53
+ const root = stateRoot();
54
+ const manifestPath = join(root, 'erasure', `${owner}-${id}.json`);
55
+ assertSafeAncestors(dirname(manifestPath));
56
+ let manifest = json(manifestPath);
57
+ const names = new Set(seats.map(s => s.role_name));
58
+ for (const name of names) {
59
+ if (!/^[a-zA-Z0-9_-]{1,120}$/.test(name))
60
+ throw new Error('Invalid erasure member');
61
+ if (existsSync(agentDir(name, true)))
62
+ throw new Error(`Member '${name}' still has live state during erasure`);
63
+ }
64
+ if (!manifest) {
65
+ const paths = [];
66
+ const launches = seats.flatMap(seat => {
67
+ const launchId = seat.launch?.launch_id ?? seat.retirement?.launch_id;
68
+ return launchId ? [{ role: seat.role_name, launchId }] : [];
69
+ });
70
+ const actions = new Map(seats.map(s => [s.role_name, new Set([s.launch?.action_id].filter((v) => !!v))]));
71
+ const recovery = join(root, 'recovery', 'temporary');
72
+ assertSafeAncestors(recovery);
73
+ for (const entry of entries(recovery)) {
74
+ const path = join(recovery, entry);
75
+ const stat = lstatSync(path);
76
+ if (!stat.isDirectory() || stat.isSymbolicLink())
77
+ continue;
78
+ const supervisor = readTempSupervisor(path);
79
+ if (!supervisor || !names.has(supervisor.role))
80
+ continue;
81
+ const seat = seats.find(s => s.role_name === supervisor.role);
82
+ const creation = json(join(path, 'creation.json'));
83
+ let room;
84
+ const roleFile = join(path, 'role.yaml');
85
+ if (existsSync(roleFile) && lstatSync(roleFile).isFile() && !lstatSync(roleFile).isSymbolicLink()) {
86
+ const role = parse(readFileSync(roleFile, 'utf8'));
87
+ if (role?.name === supervisor.role)
88
+ room = role.roomMemberStartup?.room_id;
89
+ }
90
+ const exactLaunch = seat.launch?.launch_id === supervisor.launchId || seat.retirement?.launch_id === supervisor.launchId;
91
+ const exactAction = creation?.role === supervisor.role && creation.creationActionId === seat.launch?.action_id;
92
+ // Older attempts belong only when the launch descriptor pins the room.
93
+ if (!(exactLaunch && (exactAction || !seat.launch?.action_id)) && !(room && roomIds.includes(room)))
94
+ continue;
95
+ if (existsSync(join(path, '.identity')) && readFileSync(join(path, '.identity'), 'utf8').trim() !== supervisor.role)
96
+ throw new Error('Archive identity mismatch during erasure');
97
+ if (await tempSupervisorLiveness(path) !== 'stopped')
98
+ throw new Error('Archive writer is not stopped');
99
+ if (creation?.role === supervisor.role && typeof creation.creationActionId === 'string')
100
+ actions.get(supervisor.role).add(creation.creationActionId);
101
+ auditWorkspaceGit(path);
102
+ launches.push({ role: supervisor.role, launchId: supervisor.launchId });
103
+ paths.push(path);
104
+ }
105
+ const privateRoot = join(root, 'private-ours');
106
+ assertSafeAncestors(privateRoot);
107
+ for (const entry of entries(privateRoot)) {
108
+ const path = join(privateRoot, entry);
109
+ if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink())
110
+ continue;
111
+ if (entry === 'launches' || entry === 'room-inputs') {
112
+ for (const file of entries(path)) {
113
+ const child = join(path, file), value = json(child);
114
+ if (!value)
115
+ continue;
116
+ const launchOwned = entry === 'launches' && value.role === value.identity
117
+ && actions.get(value.role)?.has(value.action);
118
+ const roomOwned = entry === 'room-inputs' && roomIds.includes(value.room_id)
119
+ && names.has(value.identity_name);
120
+ const readyOwned = entry === 'room-inputs' && file.endsWith('.ready.json')
121
+ && roomIds.includes(value.room) && seats.some(seat => seat.invite_id === value.invite && (!seat.identity_cid || seat.identity_cid === value.cid));
122
+ if (launchOwned || roomOwned || readyOwned)
123
+ paths.push(child);
124
+ }
125
+ continue;
126
+ }
127
+ const state = json(join(path, 'state.json')), instance = json(join(path, 'instance.json'));
128
+ if (state?.lifetime === 'temporary' && actions.get(state.name)?.has(state.action)
129
+ && instance?.role === state.name && instance?.temporary === true && instance?.instance === state.instance) {
130
+ auditWorkspaceGit(path);
131
+ paths.push(path);
132
+ }
133
+ }
134
+ manifest = { launches, version: 1, token: randomUUID(), paths, phases: {}, stamps: Object.fromEntries(paths.map(path => {
135
+ const stat = lstatSync(path);
136
+ return [path, { dev: stat.dev, ino: stat.ino, proof: fingerprint(path) }];
137
+ })) };
138
+ mkdirSync(dirname(manifestPath), { recursive: true, mode: 0o700 });
139
+ replaceFileAtomically(manifestPath, JSON.stringify(manifest));
140
+ }
141
+ if (manifest.version !== 1 || !/^[a-f0-9-]{36}$/.test(manifest.token) || !Array.isArray(manifest.paths))
142
+ throw new Error('Invalid erasure manifest');
143
+ for (const path of manifest.paths) {
144
+ const rel = relative(root, path);
145
+ if (!rel.startsWith('recovery/temporary/') && !rel.startsWith('private-ours/'))
146
+ throw new Error('Invalid erasure artifact path');
147
+ if (rel.split('/').includes('..'))
148
+ throw new Error('Invalid erasure artifact traversal');
149
+ assertSafeAncestors(dirname(path));
150
+ const tombstone = `${path}.erasing-${manifest.token}`;
151
+ if (manifest.phases[path] === 'removed')
152
+ continue;
153
+ if (existsSync(path) && !existsSync(tombstone) && manifest.phases[path] !== 'renamed') {
154
+ const stat = lstatSync(path), stamp = manifest.stamps[path];
155
+ if (stat.isSymbolicLink() || !stamp || stamp.dev !== stat.dev || stamp.ino !== stat.ino || stamp.proof !== fingerprint(path))
156
+ throw new Error('Erasure source ownership changed');
157
+ renameSync(path, tombstone);
158
+ }
159
+ manifest.phases[path] = 'renamed';
160
+ replaceFileAtomically(manifestPath, JSON.stringify(manifest));
161
+ rmSync(tombstone, { recursive: true, force: true });
162
+ manifest.phases[path] = 'removed';
163
+ replaceFileAtomically(manifestPath, JSON.stringify(manifest));
164
+ }
165
+ eraseResourcePresentations([{ kind: owner, id }, ...roomIds.map(id => ({ kind: 'room', id })),
166
+ ...seats.map(seat => ({ kind: 'agent', id: seat.role_name }))]);
167
+ eraseTerminationEvents(manifest.launches);
168
+ rmSync(manifestPath, { force: true });
169
+ }
@@ -31,6 +31,8 @@ export declare function updateMemberStartup(id: string, roleName: string, update
31
31
  export declare function activateRoom(id: string): RoomOrchestrationRecord;
32
32
  export declare function closeRoom(id: string): RoomOrchestrationRecord;
33
33
  export declare function beginRoomClose(id: string): RoomOrchestrationRecord;
34
- export declare function advanceMemberRetirement(id: string, roleName: string, phase: MemberRetirementPhase, launchId: string, archivePath?: string): RoomOrchestrationRecord;
34
+ export declare function advanceMemberRetirement(id: string, roleName: string, phase: MemberRetirementPhase, launchId: string, archivePath?: string, absenceVerified?: boolean): RoomOrchestrationRecord;
35
35
  export declare function advanceRoomClose(id: string, phase: RoomClosePhase): RoomOrchestrationRecord;
36
36
  export declare function setRoomCloseError(id: string, error: string, recoveryHint: string): RoomOrchestrationRecord;
37
+ /** Settlement-only binding recovery; never publishes a seat or changes a launch. */
38
+ export declare function recordRetirementMemberCids(id: string, recovered: ReadonlyArray<RoomMemberSeat>): RoomOrchestrationRecord;