@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.
@@ -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;
@@ -7,14 +7,23 @@ import { getTask } from './task-state.js';
7
7
  import { storedRoomLaunchPolicy } from './types.js';
8
8
  import { releaseLaunchSnapshot } from './launch-snapshot.js';
9
9
  export const roomsDir = () => join(stateRoot(), 'rooms');
10
- function roomPath(id) { return join(roomsDir(), `${id}.json`); }
10
+ function roomPath(id) {
11
+ if (!/^[a-zA-Z0-9_-]{1,120}$/.test(id))
12
+ throw new RoomStateError('Invalid room ID');
13
+ return join(roomsDir(), `${id}.json`);
14
+ }
11
15
  export class RoomStateError extends Error {
12
16
  }
13
17
  function readRoom(id) {
14
18
  const p = roomPath(id);
15
- if (!existsSync(p))
16
- throw new RoomStateError(`room not found: ${id}`);
17
- return JSON.parse(readFileSync(p, 'utf8'));
19
+ try {
20
+ return JSON.parse(readFileSync(p, 'utf8'));
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT')
24
+ throw new RoomStateError(`room not found: ${id}`);
25
+ throw error;
26
+ }
18
27
  }
19
28
  function writeRoom(record) {
20
29
  mkdirSync(roomsDir(), { recursive: true });
@@ -54,8 +63,10 @@ export function getRoomRecord(id) {
54
63
  try {
55
64
  return readRoom(id);
56
65
  }
57
- catch {
58
- return undefined;
66
+ catch (error) {
67
+ if (error instanceof RoomStateError && error.message === `room not found: ${id}`)
68
+ return undefined;
69
+ throw error;
59
70
  }
60
71
  }
61
72
  /** Remove a terminal orchestration record from the live room inventory. */
@@ -211,7 +222,7 @@ export function beginRoomClose(id) {
211
222
  const RETIREMENT_ORDER = [
212
223
  'stop_requested', 'liveness_absent', 'archive_secured', 'identity_absent',
213
224
  ];
214
- export function advanceMemberRetirement(id, roleName, phase, launchId, archivePath) {
225
+ export function advanceMemberRetirement(id, roleName, phase, launchId, archivePath, absenceVerified = false) {
215
226
  const r = readRoom(id);
216
227
  if (r.state !== 'closing')
217
228
  throw new RoomStateError(`room ${id} is not closing`);
@@ -228,6 +239,7 @@ export function advanceMemberRetirement(id, roleName, phase, launchId, archivePa
228
239
  throw new RoomStateError(`room ${id} member ${roleName} retirement cannot move backward to ${phase}`);
229
240
  }
230
241
  seat.retirement = {
242
+ ...(absenceVerified || previous?.absence_verified ? { absence_verified: true } : {}),
231
243
  phase,
232
244
  launch_id: launchId,
233
245
  updated_at: new Date().toISOString(),
@@ -280,3 +292,21 @@ export function setRoomCloseError(id, error, recoveryHint) {
280
292
  writeRoom(r);
281
293
  return r;
282
294
  }
295
+ /** Settlement-only binding recovery; never publishes a seat or changes a launch. */
296
+ export function recordRetirementMemberCids(id, recovered) {
297
+ const room = readRoom(id);
298
+ if (room.state !== 'closing')
299
+ throw new RoomStateError('Room is not closing');
300
+ for (const proof of recovered) {
301
+ const seat = room.member_seats.find(value => value.role_name === proof.role_name);
302
+ if (!seat || seat.launch?.action_id !== proof.launch?.action_id || seat.invite_id !== proof.invite_id)
303
+ throw new RoomStateError('Member changed during identity recovery');
304
+ if (!proof.identity_cid)
305
+ continue;
306
+ if (seat.identity_cid && seat.identity_cid.toLowerCase() !== proof.identity_cid.toLowerCase())
307
+ throw new RoomStateError('Member identity changed during recovery');
308
+ seat.identity_cid = proof.identity_cid;
309
+ }
310
+ writeRoom(room);
311
+ return room;
312
+ }
@@ -62,9 +62,8 @@ export declare function getDeletingTask(id: string): TaskRecord;
62
62
  export declare function taskDeletionState(id: string): 'none' | 'pending' | 'absent';
63
63
  /**
64
64
  * Durable, surface-independent audit evidence for a permanent deletion. The
65
- * receipt outlives the task record: written with the acceptance intent, and
66
- * completed before settlement is reported. Metadata only — never brief or
67
- * room content.
65
+ * receipt supports in-flight recovery only: written with acceptance intent,
66
+ * then erased before settlement is reported. It may contain a bounded title.
68
67
  */
69
68
  export interface TaskDeletionReceipt {
70
69
  schema_version: 1;
@@ -88,7 +87,7 @@ export declare function readTaskDeletionReceipt(id: string): TaskDeletionReceipt
88
87
  * settlement rather than deleting resources without audit evidence.
89
88
  */
90
89
  export declare function ensureTaskDeletionReceipt(id: string): void;
91
- /** Record settlement on the receipt; idempotent, tolerant of legacy absence. */
90
+ /** Erase the in-flight receipt; also heals a crash after task unlink. */
92
91
  export declare function completeTaskDeletionReceipt(id: string): void;
93
92
  export type TaskDeletionAcceptance = {
94
93
  status: 'accepted' | 'pending';
@@ -129,6 +128,7 @@ interface SeatEvidence {
129
128
  action_id?: string;
130
129
  };
131
130
  retirement?: {
131
+ absence_verified?: boolean;
132
132
  phase: TaskDeletionMemberPhase;
133
133
  launch_id: string;
134
134
  archive_path?: string;
@@ -166,4 +166,10 @@ export declare function beginTaskWorkspaceCleanup(id: string): void;
166
166
  * records are the idempotent already-settled outcome.
167
167
  */
168
168
  export declare function unlinkDeletedTask(id: string): boolean;
169
+ /** Called only after current temp and exact identity absence have been checked. */
170
+ export declare function settleAbsentTaskDeletionMember(id: string, name: string): void;
171
+ /** Room-only erasure keeps the parent Task but removes its dead room/member links. */
172
+ export declare function detachDeletedRoom(taskId: string, roomId: string): void;
173
+ /** A completed room may consume its archives while other task members still retire. */
174
+ export declare function beginTaskArchiveCleanup(id: string): void;
169
175
  export {};
@@ -535,14 +535,10 @@ export function ensureTaskDeletionReceipt(id) {
535
535
  writeDeletionReceiptForIntent(stored);
536
536
  });
537
537
  }
538
- /** Record settlement on the receipt; idempotent, tolerant of legacy absence. */
538
+ /** Erase the in-flight receipt; also heals a crash after task unlink. */
539
539
  export function completeTaskDeletionReceipt(id) {
540
- const receipt = readTaskDeletionReceipt(id);
541
- if (!receipt || receipt.settled_at)
542
- return;
543
- receipt.settled_at = new Date().toISOString();
544
- receipt.result = 'deleted';
545
- replaceFileAtomically(deletionReceiptPath(id), JSON.stringify(receipt, null, 2) + '\n');
540
+ assertCanonicalTaskId(id);
541
+ rmSync(deletionReceiptPath(id), { force: true });
546
542
  }
547
543
  /**
548
544
  * Persist the first-wins durable deletion intent. Accepts every lifecycle
@@ -561,8 +557,10 @@ export function beginTaskDeletionIntent(id, actor) {
561
557
  stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
562
558
  }
563
559
  catch (error) {
564
- if (isNotFoundError(error))
560
+ if (isNotFoundError(error)) {
561
+ completeTaskDeletionReceipt(id);
565
562
  return { status: 'already_absent' };
563
+ }
566
564
  throw error;
567
565
  }
568
566
  if (stored.deletion?.status === 'pending') {
@@ -712,12 +710,17 @@ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
712
710
  const evidence = seat.retirement;
713
711
  if (evidence?.phase !== 'identity_absent')
714
712
  throw new TaskStateError(`task ${id} deletion cannot checkpoint member '${seat.role_name}' before completed retirement`);
715
- const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED;
713
+ const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED
714
+ || evidence.launch_id === DELETION_MEMBER_ABSENT_VERIFIED || evidence.absence_verified === true;
716
715
  if (!neverLaunched && evidence.archive_path === undefined)
717
716
  throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' retirement evidence lacks its archive`);
718
717
  if (!seat.identity_cid) {
719
- if (neverLaunched)
720
- continue; // provably never held a managed identity
718
+ if (neverLaunched) {
719
+ stored.deletion.absent_members ??= [];
720
+ if (!stored.deletion.absent_members.includes(seat.role_name))
721
+ stored.deletion.absent_members.push(seat.role_name);
722
+ continue;
723
+ }
721
724
  const proof = proofs.find(value => value.name === seat.role_name);
722
725
  if (proof && proof.launch_id === evidence.launch_id && proof.archive_path === evidence.archive_path
723
726
  && proof.launch_id === seat.launch?.launch_id && proof.action_id === seat.launch?.action_id) {
@@ -761,7 +764,7 @@ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
761
764
  export function beginTaskWorkspaceCleanup(id) {
762
765
  withTaskLock(id, () => {
763
766
  const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
764
- if (stored.deletion?.status !== 'pending' || !stored.workspace
767
+ if (stored.deletion?.status !== 'pending'
765
768
  || stored.deletion.members.some(member => member.phase !== 'identity_absent'))
766
769
  throw new TaskStateError('Task is not ready for workspace cleanup');
767
770
  stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
@@ -798,3 +801,42 @@ export function unlinkDeletedTask(id) {
798
801
  return true;
799
802
  });
800
803
  }
804
+ /** Called only after current temp and exact identity absence have been checked. */
805
+ export function settleAbsentTaskDeletionMember(id, name) {
806
+ withTaskLock(id, () => {
807
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
808
+ if (stored.deletion?.status !== 'pending')
809
+ throw new TaskStateError('Task is not deleting');
810
+ const member = stored.deletion.members.find(value => value.name === name);
811
+ if (!member)
812
+ throw new TaskStateError('Unknown deletion member');
813
+ member.phase = 'identity_absent';
814
+ member.launch_id ??= DELETION_MEMBER_ABSENT_VERIFIED;
815
+ member.updated_at = new Date().toISOString();
816
+ writeTask(stored);
817
+ });
818
+ }
819
+ /** Room-only erasure keeps the parent Task but removes its dead room/member links. */
820
+ export function detachDeletedRoom(taskId, roomId) {
821
+ withTaskLock(taskId, () => {
822
+ if (!existsSync(taskPath(taskId)))
823
+ return;
824
+ const stored = JSON.parse(readFileSync(taskPath(taskId), 'utf8'));
825
+ if (stored.room_id !== roomId || stored.deletion?.status === 'pending')
826
+ return;
827
+ delete stored.room_id;
828
+ delete stored.room_identity_cid;
829
+ stored.member_roles = [];
830
+ writeTask(stored);
831
+ });
832
+ }
833
+ /** A completed room may consume its archives while other task members still retire. */
834
+ export function beginTaskArchiveCleanup(id) {
835
+ withTaskLock(id, () => {
836
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
837
+ if (stored.deletion?.status !== 'pending')
838
+ throw new TaskStateError('Task is not deleting');
839
+ stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
840
+ writeTask(stored);
841
+ });
842
+ }
@@ -88,6 +88,7 @@ export interface TaskDeletionIntent {
88
88
  /** Snapshot of managed members at acceptance; the missing-room retirement evidence. */
89
89
  members: TaskDeletionMemberCursor[];
90
90
  archived_absences?: ArchivedMemberAbsence[];
91
+ absent_members?: string[];
91
92
  /** Archive proofs were verified before owned workspace cleanup may consume them. */
92
93
  workspace_cleanup_started_at?: string;
93
94
  error?: string;
@@ -212,6 +213,7 @@ export type RoomHistoryEvidence = {
212
213
  };
213
214
  export type MemberRetirementPhase = 'stop_requested' | 'liveness_absent' | 'archive_secured' | 'identity_absent';
214
215
  export interface MemberRetirement {
216
+ absence_verified?: boolean;
215
217
  phase: MemberRetirementPhase;
216
218
  launch_id: string;
217
219
  updated_at: string;
@@ -44,6 +44,11 @@ export declare function makeTempSupervisorLauncher(options?: {
44
44
  }): SupervisorLauncher;
45
45
  export declare function markTempSupervisorActive(dir: string, pid?: number): Promise<void>;
46
46
  export declare function requestedTempStopReason(dir: string): 'operator-stop' | undefined;
47
+ /** Explicit erasure removes only exact retired role/launch events. */
48
+ export declare function eraseTerminationEvents(launches: ReadonlyArray<{
49
+ role: string;
50
+ launchId: string;
51
+ }>): void;
47
52
  /** Move retired state out of the live roster without deleting any evidence. */
48
53
  export declare function archiveTempState(role: string, reason: TempTerminationReason, outcome: TempTerminationRecord['outcome'], detail: string, now?: Date): string | undefined;
49
54
  /** Resolve only an exact role+launch archive; names and timestamps are never proof. */
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { basename, join } from 'node:path';
4
- import { replaceFileAtomically, withFileLock } from './atomic-file.js';
4
+ import { replaceFileAtomically, withFileLock, withSynchronousFileLock } from './atomic-file.js';
5
5
  import { realExec } from './exec.js';
6
6
  import { stateRoot, tmpRoot } from './paths.js';
7
7
  export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
@@ -133,18 +133,34 @@ function appendTermination(dir, record) {
133
133
  replaceFileAtomically(join(dir, TEMP_GLOBAL_TERMINATION_MARKER), line);
134
134
  }
135
135
  function appendGlobalTermination(line, checkExisting = false) {
136
- mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
137
- const path = join(archiveRoot(), 'terminations.jsonl');
138
- // Recovery may revisit a .retiring directory after a crash between the
139
- // global append and final rename. Avoid duplicating that exact event.
140
- if (checkExisting) {
141
- try {
142
- if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
143
- return;
136
+ withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
137
+ mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
138
+ const path = join(archiveRoot(), 'terminations.jsonl');
139
+ // Recovery may revisit a .retiring directory after a crash between the
140
+ // global append and final rename. Avoid duplicating that exact event.
141
+ if (checkExisting) {
142
+ try {
143
+ if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
144
+ return;
145
+ }
146
+ catch { /* the journal does not exist yet */ }
144
147
  }
145
- catch { /* the journal does not exist yet */ }
146
- }
147
- appendFileSync(path, line, { mode: 0o600 });
148
+ appendFileSync(path, line, { mode: 0o600 });
149
+ });
150
+ }
151
+ /** Explicit erasure removes only exact retired role/launch events. */
152
+ export function eraseTerminationEvents(launches) {
153
+ withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
154
+ const path = join(archiveRoot(), 'terminations.jsonl');
155
+ if (!existsSync(path))
156
+ return;
157
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
158
+ const kept = lines.filter(line => {
159
+ const row = JSON.parse(line);
160
+ return !launches.some(launch => launch.role === row.role && launch.launchId === row.launchId);
161
+ });
162
+ replaceFileAtomically(path, kept.length ? kept.join('\n') + '\n' : '');
163
+ });
148
164
  }
149
165
  /** Pick a sibling path without overwriting evidence from an earlier attempt. */
150
166
  function collisionSafeArchivePaths(targetBase, retiringBase) {