@ours.network/fleet 1.1.0-nightly.12 → 1.1.0-nightly.13

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.
@@ -17,6 +17,8 @@ export interface OwnerFleetOps {
17
17
  closeRoom(roomId: string): Promise<void>;
18
18
  /** Resume a task terminal intent outside the caller role's supervisor lifecycle. */
19
19
  settleTask(taskId: string): Promise<void>;
20
+ /** Settle an accepted task deletion outside the caller role's supervisor lifecycle. */
21
+ settleTaskDeletion(taskId: string): Promise<void>;
20
22
  recoverTask(taskId: string): Promise<void>;
21
23
  }
22
24
  /**
@@ -84,7 +86,8 @@ export interface OwnerCommandContext {
84
86
  blockTask(taskId: string, reason: string): TaskRecord;
85
87
  unblockTask(taskId: string): TaskRecord;
86
88
  reviewTask(taskId: string): TaskRecord;
87
- deleteTask(taskId: string): boolean;
89
+ /** Accept a permanent any-state deletion, acknowledge it, then launch the external delete worker. */
90
+ deleteTask(taskId: string): Promise<void>;
88
91
  listRoomQueries(filter?: {
89
92
  state?: 'active' | 'provisioning';
90
93
  }): ReturnType<TaskRoomApplicationService['listRooms']>;
@@ -36,7 +36,7 @@ function isKnownOwnerTaskState(message) {
36
36
  /^task is not blocked$/u,
37
37
  new RegExp(`^task ${SAFE_ID_WORD} already has a conflicting '(?:done|cancelled)' terminal intent$`, 'u'),
38
38
  new RegExp(`^task ${SAFE_ID_WORD} is already in terminal state '${TASK_STATE_WORD}'$`, 'u'),
39
- new RegExp(`^cannot delete a '${TASK_STATE_WORD}' task; only 'done' tasks can be deleted$`, 'u'),
39
+ new RegExp(`^task ${SAFE_ID_WORD} is pending deletion; run 'ours-fleet task delete ${SAFE_ID_WORD} ${SAFE_ID_WORD}' to retry cleanup$`, 'u'),
40
40
  ].some(pattern => pattern.test(message));
41
41
  }
42
42
  function isKnownOwnerRoomState(message) {
@@ -461,11 +461,7 @@ export const ownerCommands = [
461
461
  case 'delete': {
462
462
  if (rest.length !== 2 || rest[0] !== rest[1])
463
463
  throw new OwnerCommandUsageError('destructive: /task delete <id> <id> — provide the task ID twice');
464
- const deleted = ctx.deleteTask(rest[0]);
465
- await ctx.reply(renderMarkdownResult({
466
- icon: '🗑️', title: deleted ? 'Task deleted' : 'Task already absent',
467
- fields: [{ label: 'ID', value: rest[0], kind: 'code' }],
468
- }));
464
+ await ctx.deleteTask(rest[0]);
469
465
  break;
470
466
  }
471
467
  case 'recover': {
@@ -734,6 +730,7 @@ export function fleetCliOps(role, configPath) {
734
730
  }),
735
731
  closeRoom: roomId => launchFleetWorker(['room', '_delete', roomId], `room-delete-${roomId}`, configPath),
736
732
  settleTask: taskId => launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath),
733
+ settleTaskDeletion: taskId => launchFleetWorker(['task', '_settle_delete', taskId], `task-delete-${taskId}`, configPath),
737
734
  recoverTask: taskId => launchFleetWorker(['task', '_recover', taskId], `task-recover-${taskId}`, configPath),
738
735
  };
739
736
  }
@@ -29,13 +29,14 @@ function commandArgv(command) {
29
29
  root = root.parent;
30
30
  return root.rawArgs ?? process.argv.slice(2);
31
31
  }
32
- import { getTask, activateTask, TaskStateError, } from './task-state.js';
32
+ import { getTask, getDeletingTask, activateTask, TaskStateError, } from './task-state.js';
33
33
  import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
34
34
  import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
35
35
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from './markdown.js';
36
36
  import { TaskRoomApplicationError, TaskRoomApplicationService } from '../application/task-room-service.js';
37
37
  import { TaskListError } from './task-lists.js';
38
38
  import { recordFleetAuditFailure, recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
39
+ import { renderAgentConfiguration } from '../lifecycle-summary.js';
39
40
  class TaskRoomPublicError extends Error {
40
41
  code;
41
42
  fields;
@@ -152,7 +153,7 @@ function isKnownTaskStateMessage(message) {
152
153
  new RegExp(`^task ${SAFE_ID_WORD} is not tied to room ${SAFE_ID_WORD}$`, 'u'),
153
154
  new RegExp(`^task ${SAFE_ID_WORD} has no terminal intent$`, 'u'),
154
155
  new RegExp(`^task ${SAFE_ID_WORD} reached terminal state '${TASK_STATE_WORD}' outside its intent$`, 'u'),
155
- new RegExp(`^cannot delete a '${TASK_STATE_WORD}' task; only 'done' tasks can be deleted$`, 'u'),
156
+ new RegExp(`^task ${SAFE_ID_WORD} is pending deletion; run 'ours-fleet task delete ${SAFE_ID_WORD} ${SAFE_ID_WORD}' to retry cleanup$`, 'u'),
156
157
  ].some(pattern => pattern.test(message));
157
158
  }
158
159
  function isKnownRoomStateMessage(message) {
@@ -240,6 +241,14 @@ const taskActionMarkdown = (title, task, fields = []) => {
240
241
  ],
241
242
  });
242
243
  };
244
+ /**
245
+ * Shared launch-configuration line for CLI member listings. Pre-upgrade seats
246
+ * without a captured presentation keep the minimal listing rather than
247
+ * claiming resolved facts. Output is already escaped (markdownItems boundary).
248
+ */
249
+ function seatConfigurationSuffix(presentation) {
250
+ return presentation ? ` — ${renderAgentConfiguration(presentation)}` : '';
251
+ }
243
252
  function safeSelectionSummary(definition, kind) {
244
253
  const selected = definition?.[kind];
245
254
  if (!selected || typeof selected !== 'object' || Array.isArray(selected))
@@ -279,6 +288,7 @@ function auditTask(operation, task, previousState, newState = task.state, revisi
279
288
  recordFleetAuditResource('room', task.room_id);
280
289
  const room = task.room_id ? getRoomRecord(task.room_id) : undefined;
281
290
  const definitions = new Map(room?.member_seats.map(seat => [seat.role_name, seat.launch?.agent_definition]) ?? []);
291
+ const presentations = new Map(room?.member_seats.map(seat => [seat.role_name, seat.launch?.presentation]) ?? []);
282
292
  const semanticOperation = operation === 'recover'
283
293
  ? newState === 'active' ? 'work' : newState === 'done' ? 'done'
284
294
  : newState === 'cancelled' ? 'cancel' : undefined
@@ -294,7 +304,8 @@ function auditTask(operation, task, previousState, newState = task.state, revisi
294
304
  brain: safeSelectionSummary(definitions.get(member.name), 'brain'),
295
305
  role: safeSelectionSummary(definitions.get(member.name), 'role') === 'unresolved'
296
306
  ? member.cowork_role : safeSelectionSummary(definitions.get(member.name), 'role'),
297
- permissions: safePermissionsSummary(definitions.get(member.name)) })) });
307
+ permissions: safePermissionsSummary(definitions.get(member.name)),
308
+ configuration: presentations.get(member.name) })) });
298
309
  }
299
310
  function auditRoom(operation, room, previousState, newState = room.state) {
300
311
  if (previousState === newState)
@@ -314,7 +325,8 @@ function auditRoom(operation, room, previousState, newState = room.state) {
314
325
  brain: safeSelectionSummary(member.launch?.agent_definition, 'brain'),
315
326
  role: safeSelectionSummary(member.launch?.agent_definition, 'role') === 'unresolved'
316
327
  ? member.cowork_role : safeSelectionSummary(member.launch?.agent_definition, 'role'),
317
- permissions: safePermissionsSummary(member.launch?.agent_definition) })) });
328
+ permissions: safePermissionsSummary(member.launch?.agent_definition),
329
+ configuration: member.launch?.presentation })) });
318
330
  }
319
331
  const roomActionMarkdown = (title, room, fields = []) => {
320
332
  recordFleetAuditResource('room', room.room_id);
@@ -365,6 +377,49 @@ async function launchTaskSettleWorker(taskId, configPath) {
365
377
  eventId: task.terminal_intent?.accepted_at ?? task.created_at });
366
378
  return { task, timedOut: true };
367
379
  }
380
+ /** Launch the deletion settle worker and wait boundedly for physical absence. */
381
+ async function launchTaskDeleteWorker(taskId, configPath) {
382
+ const readDeletion = () => {
383
+ try {
384
+ const task = getDeletingTask(taskId);
385
+ return { present: true, errorAt: task.deletion?.error_at, error: task.deletion?.error };
386
+ }
387
+ catch (error) {
388
+ // Only proven physical absence counts as deleted; anything else propagates.
389
+ if (error instanceof TaskStateError && /task not found/.test(error.message))
390
+ return { present: false };
391
+ throw error;
392
+ }
393
+ };
394
+ const before = readDeletion();
395
+ if (!before.present)
396
+ return { deleted: true, timedOut: false };
397
+ try {
398
+ await launchFleetWorker(['task', '_settle_delete', taskId], `task-delete-${taskId}`, configPath);
399
+ }
400
+ catch (error) {
401
+ await taskRoomService(configPath).recordDeletionError({
402
+ actor: { kind: 'local_control', surface: 'cli' }, taskId, error: errorText(error),
403
+ recoveryHint: `External delete worker failed to start. Retry task delete ${taskId} ${taskId}.`,
404
+ }).catch(() => { });
405
+ recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Task', id: taskId,
406
+ state: 'deleting', category: 'settlement_failed', eventId: new Date().toISOString() });
407
+ throw error;
408
+ }
409
+ const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
410
+ while (Date.now() < deadline) {
411
+ const current = readDeletion();
412
+ if (!current.present)
413
+ return { deleted: true, timedOut: false };
414
+ if (current.errorAt !== before.errorAt && current.error) {
415
+ throw new TaskStateError(current.error);
416
+ }
417
+ await sleep(PUBLIC_SETTLE_POLL_MS);
418
+ }
419
+ recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Task', id: taskId,
420
+ state: 'deleting', category: 'settlement_pending', eventId: new Date().toISOString() });
421
+ return { deleted: false, timedOut: true };
422
+ }
368
423
  async function launchRoomDeleteWorker(roomId, configPath) {
369
424
  const previousErrorAt = getRoomRecord(roomId)?.close?.error_at;
370
425
  try {
@@ -831,7 +886,9 @@ export function registerTaskCommands(parent, cOpt) {
831
886
  ],
832
887
  sections: [
833
888
  ...(t.member_roles.length ? [{
834
- heading: 'Members', markdownItems: t.member_roles.map(m => `${markdownCode(m.name)} — ${markdownProse(m.cowork_role)} — ${markdownCode(m.identity_cid)}`),
889
+ heading: 'Members', markdownItems: t.member_roles.map(m => `${markdownCode(m.name)} — ${markdownProse(m.cowork_role)} — ${markdownCode(m.identity_cid)}`
890
+ + seatConfigurationSuffix(room?.member_seats
891
+ .find(seat => seat.role_name === m.name)?.launch?.presentation)),
835
892
  }] : []),
836
893
  ...(room ? roomStartupSections(room) : []),
837
894
  ],
@@ -1030,28 +1087,46 @@ export function registerTaskCommands(parent, cOpt) {
1030
1087
  }
1031
1088
  });
1032
1089
  cOpt(taskCmd.command('delete <id> <confirm-id>'))
1033
- .description('delete a done task from the backlog (requires ID twice for confirmation)')
1090
+ .description('permanently delete a task in any lifecycle state (requires ID twice for confirmation)')
1034
1091
  .option('--json', 'JSON output')
1035
- .action((id, confirmId, opts) => {
1092
+ .action(async (id, confirmId, opts) => {
1036
1093
  try {
1037
1094
  if (id !== confirmId)
1038
1095
  throw taskRoomPublicError('task_confirmation_mismatch');
1039
- let prior;
1040
- try {
1041
- prior = getTask(id);
1042
- }
1043
- catch { /* idempotent already-absent delete */ }
1044
- const deleted = taskRoomService().deleteTask({
1096
+ const accepted = await taskRoomService(opts.configuration).requestTaskDeletion({
1045
1097
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
1046
1098
  });
1047
- if (prior)
1048
- auditTask('delete', prior, prior.state, 'deleted');
1099
+ if (accepted.status === 'already_absent') {
1100
+ if (opts.json) {
1101
+ console.log(JSON.stringify({
1102
+ schema_version: 1, task_id: id, deleted: false, already_absent: true,
1103
+ }, null, 2));
1104
+ return;
1105
+ }
1106
+ console.log(renderMarkdownResult({
1107
+ icon: '🗑️', title: 'Task already absent',
1108
+ fields: [{ label: 'ID', value: id, kind: 'code' }],
1109
+ }));
1110
+ return;
1111
+ }
1112
+ const settled = await launchTaskDeleteWorker(id, opts.configuration);
1049
1113
  if (opts.json) {
1050
- console.log(JSON.stringify({ schema_version: 1, task_id: id, deleted }, null, 2));
1114
+ console.log(JSON.stringify({
1115
+ schema_version: 1, task_id: id, accepted: true,
1116
+ deleted: settled.deleted, pending: settled.timedOut,
1117
+ }, null, 2));
1118
+ return;
1119
+ }
1120
+ if (!settled.deleted) {
1121
+ console.log(renderMarkdownFailure({
1122
+ kind: 'pending', subject: `task delete ${id} ${id}`,
1123
+ detail: 'The deletion was accepted and cleanup is still settling.',
1124
+ action: `Re-run ours-fleet task delete ${id} ${id} or ours-fleet task recover ${id}.`,
1125
+ }));
1051
1126
  return;
1052
1127
  }
1053
1128
  console.log(renderMarkdownResult({
1054
- icon: '🗑️', title: deleted ? 'Task deleted' : 'Task already absent',
1129
+ icon: '🗑️', title: 'Task deleted',
1055
1130
  fields: [{ label: 'ID', value: id, kind: 'code' }],
1056
1131
  }));
1057
1132
  }
@@ -1070,6 +1145,25 @@ export function registerTaskCommands(parent, cOpt) {
1070
1145
  const app = taskRoomService(opts.configuration);
1071
1146
  const actor = { kind: 'local_control', surface: 'cli' };
1072
1147
  const begin = await app.beginTaskRecovery({ actor, taskId: id });
1148
+ if (begin.kind === 'deletion_worker_required') {
1149
+ const settled = await launchTaskDeleteWorker(id, opts.configuration);
1150
+ const payload = { schema_version: 1, task_id: id, deleted: settled.deleted };
1151
+ if (opts.json) {
1152
+ console.log(JSON.stringify(payload, null, 2));
1153
+ return;
1154
+ }
1155
+ console.log(settled.deleted
1156
+ ? renderMarkdownResult({
1157
+ icon: '🗑️', title: 'Task deletion completed by recovery',
1158
+ fields: [{ label: 'ID', value: id, kind: 'code' }],
1159
+ })
1160
+ : renderMarkdownFailure({
1161
+ kind: 'pending', subject: `task recover ${id}`,
1162
+ detail: 'The task is pending deletion and cleanup is still settling.',
1163
+ action: `Run ours-fleet task delete ${id} ${id} to retry.`,
1164
+ }));
1165
+ return;
1166
+ }
1073
1167
  const recovered = begin.kind === 'terminal_worker_required'
1074
1168
  ? await (async () => {
1075
1169
  const settled = await launchTaskSettleWorker(id, opts.configuration);
@@ -1148,6 +1242,35 @@ export function registerTaskCommands(parent, cOpt) {
1148
1242
  dieTaskRoom(e);
1149
1243
  }
1150
1244
  });
1245
+ cOpt(taskCmd.command('_settle_delete <id>', { hidden: true }))
1246
+ .description('internal: settle a previously accepted task deletion')
1247
+ .option('--json', 'JSON output')
1248
+ .action(async (id, opts) => {
1249
+ try {
1250
+ const app = taskRoomService(opts.configuration);
1251
+ const result = await app.settleTaskDeletion({
1252
+ actor: { kind: 'internal_worker', surface: 'cli' }, taskId: id,
1253
+ });
1254
+ if (opts.json) {
1255
+ console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
1256
+ return;
1257
+ }
1258
+ console.log(renderMarkdownResult({
1259
+ icon: '🗑️', title: result.deleted ? 'Task deletion settled' : 'Task already absent',
1260
+ fields: [{ label: 'ID', value: id, kind: 'code' }],
1261
+ }));
1262
+ }
1263
+ catch (e) {
1264
+ await taskRoomService(opts.configuration).recordDeletionError({
1265
+ actor: { kind: 'internal_worker', surface: 'cli' }, taskId: id,
1266
+ error: errorText(e),
1267
+ recoveryHint: `External delete worker failed. Retry task delete ${id} ${id}.`,
1268
+ }).catch(() => { });
1269
+ if (opts.json)
1270
+ die(e);
1271
+ dieTaskRoom(e);
1272
+ }
1273
+ });
1151
1274
  cOpt(taskCmd.command('_recover <id>', { hidden: true }))
1152
1275
  .description('internal: settle and continue task recovery')
1153
1276
  .option('--json', 'JSON output')
@@ -1156,6 +1279,11 @@ export function registerTaskCommands(parent, cOpt) {
1156
1279
  const actor = { kind: 'internal_worker', surface: 'cli' };
1157
1280
  try {
1158
1281
  const begin = await app.beginTaskRecovery({ actor, taskId: id });
1282
+ if (begin.kind === 'deletion_worker_required') {
1283
+ const settled = await app.settleTaskDeletion({ actor, taskId: id });
1284
+ console.log(JSON.stringify({ schema_version: 1, deletion: settled }, null, 2));
1285
+ return;
1286
+ }
1159
1287
  const result = begin.kind === 'terminal_worker_required'
1160
1288
  ? await (async () => {
1161
1289
  try {
@@ -1375,7 +1503,9 @@ export function registerRoomCommands(parent, cOpt) {
1375
1503
  ],
1376
1504
  sections: [
1377
1505
  ...(cowork.seats.length ? [{
1378
- heading: 'Members', markdownItems: cowork.seats.map(s => `${markdownCode(s.identity_cid)} — ${markdownProse(s.role)} — ${markdownProse(s.seat_state)}`),
1506
+ heading: 'Members', markdownItems: cowork.seats.map(s => `${markdownCode(s.identity_cid)} — ${markdownProse(s.role)} — ${markdownProse(s.seat_state)}`
1507
+ + seatConfigurationSuffix(r?.member_seats
1508
+ .find(seat => seat.identity_cid === s.identity_cid)?.launch?.presentation)),
1379
1509
  }] : []),
1380
1510
  ...(r ? roomStartupSections(r) : []),
1381
1511
  ],
@@ -1437,7 +1567,9 @@ export function registerRoomCommands(parent, cOpt) {
1437
1567
  ],
1438
1568
  sections: [{
1439
1569
  heading: 'Members',
1440
- ...(members.length ? { markdownItems: members.map(s => `${markdownCode(s.identity_cid)} — ${markdownProse(s.role)} — ${markdownProse(s.seat_state)}`) }
1570
+ ...(members.length ? { markdownItems: members.map(s => `${markdownCode(s.identity_cid)} — ${markdownProse(s.role)} — ${markdownProse(s.seat_state)}`
1571
+ + seatConfigurationSuffix(r?.member_seats
1572
+ .find(seat => seat.identity_cid === s.identity_cid)?.launch?.presentation)) }
1441
1573
  : { items: ['No members found.'] }),
1442
1574
  }],
1443
1575
  }));
@@ -1,4 +1,5 @@
1
1
  import { withFileLock } from '../atomic-file.js';
2
+ import { type TempLifecycleDeps } from '../temp-lifecycle.js';
2
3
  import { type CoworkAdapter } from './cowork-adapter.js';
3
4
  import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
4
5
  export interface RoomCloseDeps {
@@ -11,6 +12,13 @@ export interface RoomCloseDeps {
11
12
  removeIdentity?(seat: RoomMemberSeat): Promise<void>;
12
13
  lock?: typeof withFileLock;
13
14
  }
15
+ export declare function inspectMember(seat: RoomMemberSeat): Promise<{
16
+ launchId: string;
17
+ }>;
18
+ export declare function waitForLivenessAbsent(role: string, launchId: string, lifecycleDeps?: TempLifecycleDeps): Promise<void>;
19
+ /** Report whether any daemon identity — under any name — carries this exact CID. */
20
+ export declare function identityCidPresent(cid: string): Promise<boolean>;
21
+ export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
14
22
  /** One forward-only room close saga shared by every Fleet entry point. */
15
23
  export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
16
24
  export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
@@ -27,7 +27,7 @@ function exactMemberIdentity(seat) {
27
27
  throw new Error(`room member '${seat.role_name}' temp state binds identity '${identity}'; refusing mismatched retirement`);
28
28
  }
29
29
  }
30
- async function inspectMember(seat) {
30
+ export async function inspectMember(seat) {
31
31
  exactMemberIdentity(seat);
32
32
  const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
33
33
  if (!supervisor || supervisor.role !== seat.role_name) {
@@ -35,7 +35,7 @@ async function inspectMember(seat) {
35
35
  }
36
36
  return { launchId: supervisor.launchId };
37
37
  }
38
- async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
38
+ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
39
39
  const dir = agentDir(role, true);
40
40
  const sleep = lifecycleDeps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
41
41
  for (let attempt = 0; attempt < STOP_POLLS; attempt++) {
@@ -69,7 +69,14 @@ async function withIdentityClient(work) {
69
69
  function listedIdentity(rows, name) {
70
70
  return rows.find(row => row.name === name);
71
71
  }
72
- async function removeExactMemberIdentity(seat) {
72
+ /** Report whether any daemon identity — under any name — carries this exact CID. */
73
+ export async function identityCidPresent(cid) {
74
+ return withIdentityClient(async (client) => {
75
+ const rows = await client.listIdentities();
76
+ return rows.some(row => row.cid?.toLowerCase() === cid.toLowerCase());
77
+ });
78
+ }
79
+ export async function removeExactMemberIdentity(seat) {
73
80
  await withIdentityClient(async (client) => {
74
81
  const before = listedIdentity(await client.listIdentities(), seat.role_name);
75
82
  if (!before)
@@ -0,0 +1,51 @@
1
+ import { type RoomCloseDeps } from './close.js';
2
+ import { type CoworkAdapter } from './cowork-adapter.js';
3
+ import { type TaskDeletionAcceptance } from './task-state.js';
4
+ import type { TaskDeletionActor, TaskRecord } from './types.js';
5
+ export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
6
+ type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
7
+ export interface TaskDeletionSettleDeps {
8
+ roomClose?: RoomCloseDeps;
9
+ /** Test seam for the temp-state existence proof. */
10
+ hasTempState?(name: string): boolean;
11
+ /** Test seam for the CID-wide daemon identity scan. */
12
+ identityCidPresent?(cid: string): Promise<boolean>;
13
+ }
14
+ export interface TaskDeletionSettleResult {
15
+ task_id: string;
16
+ deleted: boolean;
17
+ /** Lifecycle state observed before unlink, for completion audit. */
18
+ previous_state?: TaskRecord['state'];
19
+ /** Title observed before unlink, for completion audit. */
20
+ title?: string;
21
+ }
22
+ /**
23
+ * Accept a permanent task deletion under the common task-operation lock so it
24
+ * serializes against terminal settlement, recovery, and the room-publication
25
+ * window. Acceptance is durable before any side effect and never requires
26
+ * Cowork availability; settlement runs separately and converges through
27
+ * retries.
28
+ */
29
+ export declare function acceptTaskDeletion(taskId: string, actor: TaskDeletionActor): Promise<TaskDeletionAcceptance>;
30
+ /** Persist a settlement failure under the same task-operation lock. */
31
+ export declare function recordTaskDeletionError(taskId: string, error: string, recoveryHint: string): Promise<TaskRecord>;
32
+ /**
33
+ * Converge an accepted deletion to physical absence in two lock-ordered
34
+ * stages. Cleanup stage (task-operation lock): per surviving room record,
35
+ * register late seats → close saga (retires members) → durable evidence
36
+ * checkpoint → tolerant remote delete → local record delete; then
37
+ * cursor-based retirement for members with no room record and tolerant remote
38
+ * close+delete of a recorded room whose local record is gone. Finalization
39
+ * stage (launch-snapshot lock → task-operation lock, matching the global
40
+ * provisioning order): re-verify quiescence, delete the owned launch snapshot
41
+ * while excluding only this deleting task from the reference scan, and unlink
42
+ * the task record LAST — every crash seam leaves the hidden intent
43
+ * recoverable. Failures are recorded on the intent; the task stays hidden and
44
+ * recoverable, never falsely settled.
45
+ */
46
+ export declare function settleTaskDeletion(input: {
47
+ taskId: string;
48
+ /** Lazy: resolved only when room work exists, so no-room tasks settle without Cowork config. */
49
+ cowork: () => DeletionCowork;
50
+ deps?: TaskDeletionSettleDeps;
51
+ }): Promise<TaskDeletionSettleResult>;
@@ -0,0 +1,217 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { withFileLock } from '../atomic-file.js';
3
+ import { agentDir } from '../paths.js';
4
+ import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
5
+ import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
6
+ import { CoworkProtocolError } from './cowork-adapter.js';
7
+ import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
8
+ import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
9
+ import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
10
+ import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
11
+ export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
12
+ import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
13
+ /**
14
+ * Accept a permanent task deletion under the common task-operation lock so it
15
+ * serializes against terminal settlement, recovery, and the room-publication
16
+ * window. Acceptance is durable before any side effect and never requires
17
+ * Cowork availability; settlement runs separately and converges through
18
+ * retries.
19
+ */
20
+ export function acceptTaskDeletion(taskId, actor) {
21
+ return withFileLock(taskOperationLockPath(taskId), () => beginTaskDeletionIntent(taskId, actor), {}, TASK_OPERATION_LOCK_STALE_MS);
22
+ }
23
+ /** Persist a settlement failure under the same task-operation lock. */
24
+ export function recordTaskDeletionError(taskId, error, recoveryHint) {
25
+ return withFileLock(taskOperationLockPath(taskId), () => setTaskDeletionError(taskId, error, recoveryHint), {}, TASK_OPERATION_LOCK_STALE_MS);
26
+ }
27
+ function errorText(error) {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+ /** Already-missing remote rooms are a settled outcome, not a failure. */
31
+ function tolerantCowork(cowork) {
32
+ const tolerate = async (work) => {
33
+ try {
34
+ await work();
35
+ }
36
+ catch (error) {
37
+ if (error instanceof CoworkProtocolError && error.code === 'not_found')
38
+ return;
39
+ throw error;
40
+ }
41
+ };
42
+ return {
43
+ closeRoom: roomId => tolerate(() => cowork.closeRoom(roomId)),
44
+ deleteRoom: roomId => tolerate(() => cowork.deleteRoom(roomId)),
45
+ };
46
+ }
47
+ function cursorSeat(cursor) {
48
+ return {
49
+ role_name: cursor.name, identity_cid: cursor.identity_cid,
50
+ slot: cursor.name, cowork_role: 'member', seat_state: 'active',
51
+ };
52
+ }
53
+ /**
54
+ * Retire one member from its durable deletion cursor when no room record
55
+ * carries the seat: the same evidence chain as close.ts, resumed at the
56
+ * recorded phase. A member with no temp state is verified absent through the
57
+ * CID-wide identity scan; a same-name stranger identity is never touched, and
58
+ * the recorded CID surviving under any name blocks settlement.
59
+ */
60
+ async function retireCursorMember(taskId, cursor, deps) {
61
+ let phase = cursor.phase;
62
+ let launchId = cursor.launch_id;
63
+ if (phase === 'identity_absent')
64
+ return;
65
+ const seat = cursorSeat(cursor);
66
+ const roomClose = deps.roomClose ?? {};
67
+ if (phase === 'pending') {
68
+ const hasState = (deps.hasTempState ?? (name => existsSync(agentDir(name, true))))(cursor.name);
69
+ if (!hasState) {
70
+ const present = await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid);
71
+ if (present) {
72
+ // Remove only an exact name+CID match; a stranger under the same name
73
+ // stays untouched and removeExactMemberIdentity refuses the mismatch.
74
+ // The recorded CID surviving under a different name throws below on
75
+ // its post-removal verification and blocks settlement.
76
+ await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
77
+ if (await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid))
78
+ throw new TaskStateError(`deletion member '${cursor.name}' identity CID survives under another name; refusing to claim retirement`);
79
+ }
80
+ advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent', DELETION_MEMBER_ABSENT_VERIFIED);
81
+ return;
82
+ }
83
+ const ownership = await (roomClose.inspectMember ?? inspectMember)(seat);
84
+ advanceTaskDeletionMember(taskId, cursor.name, 'stop_requested', ownership.launchId);
85
+ phase = 'stop_requested';
86
+ launchId = ownership.launchId;
87
+ }
88
+ if (phase === 'stop_requested') {
89
+ await (roomClose.requestStop
90
+ ?? (async (role) => { await stopTempSupervisor(role); }))(cursor.name);
91
+ await (roomClose.waitForLivenessAbsent ?? waitForLivenessAbsent)(cursor.name, launchId);
92
+ advanceTaskDeletionMember(taskId, cursor.name, 'liveness_absent');
93
+ phase = 'liveness_absent';
94
+ }
95
+ if (phase === 'liveness_absent') {
96
+ const archivePath = await (roomClose.secureArchive ?? secureStoppedTempArchive)(cursor.name, launchId);
97
+ advanceTaskDeletionMember(taskId, cursor.name, 'archive_secured', undefined, archivePath);
98
+ phase = 'archive_secured';
99
+ }
100
+ if (phase === 'archive_secured') {
101
+ await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
102
+ advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent');
103
+ }
104
+ }
105
+ /**
106
+ * Converge an accepted deletion to physical absence in two lock-ordered
107
+ * stages. Cleanup stage (task-operation lock): per surviving room record,
108
+ * register late seats → close saga (retires members) → durable evidence
109
+ * checkpoint → tolerant remote delete → local record delete; then
110
+ * cursor-based retirement for members with no room record and tolerant remote
111
+ * close+delete of a recorded room whose local record is gone. Finalization
112
+ * stage (launch-snapshot lock → task-operation lock, matching the global
113
+ * provisioning order): re-verify quiescence, delete the owned launch snapshot
114
+ * while excluding only this deleting task from the reference scan, and unlink
115
+ * the task record LAST — every crash seam leaves the hidden intent
116
+ * recoverable. Failures are recorded on the intent; the task stays hidden and
117
+ * recoverable, never falsely settled.
118
+ */
119
+ export async function settleTaskDeletion(input) {
120
+ const { taskId } = input;
121
+ const deps = input.deps ?? {};
122
+ const recoveryHint = `Re-run 'ours-fleet task delete ${taskId} ${taskId}' or 'ours-fleet task recover ${taskId}'.`;
123
+ const cleanup = await withFileLock(taskOperationLockPath(taskId), async () => {
124
+ let task;
125
+ try {
126
+ task = getDeletingTask(taskId);
127
+ }
128
+ catch (error) {
129
+ if (error instanceof TaskStateError && /task not found/.test(error.message))
130
+ return { kind: 'already_absent' };
131
+ throw error;
132
+ }
133
+ if (task.deletion?.status !== 'pending')
134
+ throw new TaskStateError(`task ${taskId} has no pending deletion`);
135
+ try {
136
+ // Audit evidence precedes every side effect; a receipt write failure
137
+ // aborts settlement (fail closed).
138
+ ensureTaskDeletionReceipt(taskId);
139
+ const records = listRoomRecords().filter(room => room.task_id === taskId);
140
+ const recordIds = new Set(records.map(room => room.room_id));
141
+ const recordedRoomId = task.deletion.room_id ?? task.room_id;
142
+ const needsCowork = records.length > 0 || recordedRoomId !== undefined;
143
+ const cowork = needsCowork ? tolerantCowork(input.cowork()) : undefined;
144
+ for (const record of records) {
145
+ upsertTaskDeletionMembersFromSeats(taskId, record.member_seats);
146
+ await closeManagedRoom({ roomId: record.room_id, cowork: cowork, deps: deps.roomClose });
147
+ const closed = getRoomRecord(record.room_id);
148
+ importTaskDeletionRetirementEvidence(taskId, closed?.member_seats ?? record.member_seats);
149
+ await cowork.deleteRoom(record.room_id);
150
+ deleteRoomRecord(record.room_id);
151
+ }
152
+ // Members whose room record is gone (crash after record deletion, or
153
+ // legacy state): resume from the durable cursors.
154
+ for (const cursor of getDeletingTask(taskId).deletion.members) {
155
+ await retireCursorMember(taskId, cursor, deps);
156
+ }
157
+ // A recorded room without a local record may still be live remotely.
158
+ if (recordedRoomId && !recordIds.has(recordedRoomId) && !getRoomRecord(recordedRoomId)) {
159
+ await cowork.closeRoom(recordedRoomId);
160
+ await cowork.deleteRoom(recordedRoomId);
161
+ }
162
+ return {
163
+ kind: 'cleaned',
164
+ snapshotHash: task.execution_plan?.snapshot.launch_snapshot_hash,
165
+ previousState: task.state,
166
+ title: task.title,
167
+ };
168
+ }
169
+ catch (error) {
170
+ setTaskDeletionError(taskId, errorText(error), recoveryHint);
171
+ throw error;
172
+ }
173
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
174
+ if (cleanup.kind === 'already_absent') {
175
+ // Heal a crash between unlink and receipt completion.
176
+ completeTaskDeletionReceipt(taskId);
177
+ return { task_id: taskId, deleted: false };
178
+ }
179
+ const finalize = () => withFileLock(taskOperationLockPath(taskId), () => {
180
+ try {
181
+ const task = getDeletingTask(taskId);
182
+ if (task.deletion?.status !== 'pending')
183
+ throw new TaskStateError(`task ${taskId} has no pending deletion`);
184
+ if (listRoomRecords().some(room => room.task_id === taskId))
185
+ throw new TaskStateError(`task ${taskId} room records reappeared during deletion finalization`);
186
+ if (task.deletion.members.some(member => member.phase !== 'identity_absent'))
187
+ throw new TaskStateError(`task ${taskId} has unretired members at deletion finalization`);
188
+ if (cleanup.snapshotHash)
189
+ releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
190
+ unlinkDeletedTask(taskId);
191
+ }
192
+ catch (error) {
193
+ setTaskDeletionError(taskId, errorText(error), recoveryHint);
194
+ throw error;
195
+ }
196
+ // After unlink the task can no longer carry errors; receipt completion is
197
+ // idempotent and the already-absent retry path heals a crash here.
198
+ completeTaskDeletionReceipt(taskId);
199
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
200
+ if (cleanup.snapshotHash) {
201
+ // Global lock order: launch-snapshot lock → task-operation lock.
202
+ const releaseSnapshotLock = acquireLaunchSnapshotLock();
203
+ try {
204
+ await finalize();
205
+ }
206
+ finally {
207
+ releaseSnapshotLock();
208
+ }
209
+ }
210
+ else {
211
+ await finalize();
212
+ }
213
+ return {
214
+ task_id: taskId, deleted: true,
215
+ previous_state: cleanup.previousState, title: cleanup.title,
216
+ };
217
+ }