@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.
@@ -116,6 +116,15 @@ function assertNoPendingTerminalIntent(task) {
116
116
  throw new TaskStateError(`task ${task.task_id} has a pending '${task.terminal_intent.kind}' terminal intent`);
117
117
  }
118
118
  }
119
+ /**
120
+ * Accepted deletion is the per-task epoch guard: once pending, every lifecycle
121
+ * mutation, terminal settlement, and room publication must fail boundedly.
122
+ */
123
+ export function assertNoPendingDeletion(task) {
124
+ if (task.deletion?.status === 'pending') {
125
+ throw new TaskStateError(`task ${task.task_id} is pending deletion; run 'ours-fleet task delete ${task.task_id} ${task.task_id}' to retry cleanup`);
126
+ }
127
+ }
119
128
  export function createTask(input) {
120
129
  const key = input.idempotency_key ?? randomUUID();
121
130
  const existing = findByIdempotencyKey(key);
@@ -151,6 +160,7 @@ export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
151
160
  export function updateTaskExecutionPlan(id, executionPlan) {
152
161
  return withTaskLock(id, () => {
153
162
  const task = readTask(id);
163
+ assertNoPendingDeletion(task);
154
164
  if (task.execution_plan && task.execution_plan.plan_hash !== executionPlan.plan_hash)
155
165
  throw new TaskStateError(`task ${id} execution plan mismatch`);
156
166
  task.execution_plan = executionPlan;
@@ -172,6 +182,8 @@ export function listTasks(filter) {
172
182
  try {
173
183
  const id = f.slice(0, -5);
174
184
  const t = withTaskLock(id, () => presentTask(JSON.parse(readFileSync(join(dir, f), 'utf8'))));
185
+ if (t.deletion?.status === 'pending' && !filter?.includeDeleting)
186
+ continue;
175
187
  if ((!states || states.includes(t.state)) && (!filter?.listId || t.list_id === filter.listId))
176
188
  tasks.push(t);
177
189
  }
@@ -184,6 +196,7 @@ export function listTasks(filter) {
184
196
  export function moveTaskToList(id, listId) {
185
197
  return withTaskLock(id, () => {
186
198
  const task = readTask(id);
199
+ assertNoPendingDeletion(task);
187
200
  task.list_id = listId;
188
201
  writeTask(task);
189
202
  return readTask(id);
@@ -195,6 +208,7 @@ export function findByIdempotencyKey(key) {
195
208
  export function startTask(id) {
196
209
  return withTaskLock(id, () => {
197
210
  const t = readTask(id);
211
+ assertNoPendingDeletion(t);
198
212
  assertNoPendingTerminalIntent(t);
199
213
  assertTransition(t.state, 'provisioning');
200
214
  t.state = 'provisioning';
@@ -206,6 +220,7 @@ export function startTask(id) {
206
220
  export function activateTask(id) {
207
221
  return withTaskLock(id, () => {
208
222
  const t = readTask(id);
223
+ assertNoPendingDeletion(t);
209
224
  assertNoPendingTerminalIntent(t);
210
225
  assertTransition(t.state, 'active');
211
226
  t.state = 'active';
@@ -217,6 +232,7 @@ export function activateTask(id) {
217
232
  export function blockTask(id, reason) {
218
233
  return withTaskLock(id, () => {
219
234
  const t = readTask(id);
235
+ assertNoPendingDeletion(t);
220
236
  assertNoPendingTerminalIntent(t);
221
237
  if (TASK_TERMINAL_STATES.includes(t.state))
222
238
  throw new TaskStateError(`cannot block a '${t.state}' task`);
@@ -228,6 +244,7 @@ export function blockTask(id, reason) {
228
244
  export function unblockTask(id) {
229
245
  return withTaskLock(id, () => {
230
246
  const t = readTask(id);
247
+ assertNoPendingDeletion(t);
231
248
  assertNoPendingTerminalIntent(t);
232
249
  if (!t.blocked)
233
250
  throw new TaskStateError('task is not blocked');
@@ -239,6 +256,7 @@ export function unblockTask(id) {
239
256
  export function reviewTask(id) {
240
257
  return withTaskLock(id, () => {
241
258
  const t = readTask(id);
259
+ assertNoPendingDeletion(t);
242
260
  assertNoPendingTerminalIntent(t);
243
261
  assertTransition(t.state, 'review');
244
262
  t.state = 'review';
@@ -250,6 +268,7 @@ export function reviewTask(id) {
250
268
  export function completeTask(id, outcome) {
251
269
  return withTaskLock(id, () => {
252
270
  const t = readTask(id);
271
+ assertNoPendingDeletion(t);
253
272
  assertNoPendingTerminalIntent(t);
254
273
  assertTransition(t.state, 'done');
255
274
  t.state = 'done';
@@ -264,6 +283,7 @@ export function completeTask(id, outcome) {
264
283
  export function cancelTask(id) {
265
284
  return withTaskLock(id, () => {
266
285
  const t = readTask(id);
286
+ assertNoPendingDeletion(t);
267
287
  assertNoPendingTerminalIntent(t);
268
288
  if (!TASK_CANCELLABLE_STATES.includes(t.state))
269
289
  throw new TaskStateError(`cannot cancel a '${t.state}' task`);
@@ -281,6 +301,7 @@ function sameOutcome(left, right) {
281
301
  export function beginTaskTerminalIntent(id, input) {
282
302
  return withTaskLock(id, () => {
283
303
  const t = readTask(id);
304
+ assertNoPendingDeletion(t);
284
305
  const existing = t.terminal_intent;
285
306
  if (existing) {
286
307
  if (existing.kind !== input.kind || existing.room_id !== input.roomId
@@ -334,6 +355,7 @@ export function setTaskTerminalIntentError(id, error, recoveryHint) {
334
355
  export function finishTaskTerminalIntent(id) {
335
356
  return withTaskLock(id, () => {
336
357
  const t = readTask(id);
358
+ assertNoPendingDeletion(t);
337
359
  const intent = t.terminal_intent;
338
360
  if (!intent)
339
361
  throw new TaskStateError(`task ${id} has no terminal intent`);
@@ -364,6 +386,7 @@ export function finishTaskTerminalIntent(id) {
364
386
  export function failTask(id, error) {
365
387
  return withTaskLock(id, () => {
366
388
  const t = readTask(id);
389
+ assertNoPendingDeletion(t);
367
390
  assertNoPendingTerminalIntent(t);
368
391
  assertTransition(t.state, 'failed');
369
392
  t.state = 'failed';
@@ -377,6 +400,7 @@ export function failTask(id, error) {
377
400
  export function updateTaskRoom(id, roomId, roomIdentityCid) {
378
401
  return withTaskLock(id, () => {
379
402
  const t = readTask(id);
403
+ assertNoPendingDeletion(t);
380
404
  assertNoPendingTerminalIntent(t);
381
405
  t.room_id = roomId;
382
406
  t.room_identity_cid = roomIdentityCid;
@@ -387,6 +411,7 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
387
411
  export function updateTaskTemplate(id, template) {
388
412
  return withTaskLock(id, () => {
389
413
  const t = readTask(id);
414
+ assertNoPendingDeletion(t);
390
415
  assertNoPendingTerminalIntent(t);
391
416
  t.template = template;
392
417
  writeTask(t);
@@ -396,31 +421,330 @@ export function updateTaskTemplate(id, template) {
396
421
  export function updateTaskMembers(id, members) {
397
422
  return withTaskLock(id, () => {
398
423
  const t = readTask(id);
424
+ assertNoPendingDeletion(t);
399
425
  assertNoPendingTerminalIntent(t);
400
426
  t.member_roles = members;
401
427
  writeTask(t);
402
428
  return t;
403
429
  });
404
430
  }
405
- /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
406
- export function deleteTask(id) {
431
+ function presentTaskLenient(record) {
432
+ try {
433
+ return presentTask(record);
434
+ }
435
+ catch {
436
+ // A broken list reference must never make a task undeletable.
437
+ const listId = record.list_id ?? DEFAULT_TASK_LIST_ID;
438
+ return { ...record, list_id: listId, list_name: listId === DEFAULT_TASK_LIST_ID ? 'default' : listId };
439
+ }
440
+ }
441
+ /**
442
+ * Lenient read for deletion settlement and status surfaces: a broken list
443
+ * reference must never make a task unreadable or undeletable. Missing records
444
+ * throw the canonical task-not-found error; other read failures propagate.
445
+ */
446
+ export function getDeletingTask(id) {
407
447
  return withTaskLock(id, () => {
408
448
  assertCanonicalTaskId(id);
409
- const p = taskPath(id);
410
- let task;
411
449
  try {
412
- task = JSON.parse(readFileSync(p, 'utf8'));
450
+ return presentTaskLenient(JSON.parse(readFileSync(taskPath(id), 'utf8')));
413
451
  }
414
452
  catch (error) {
415
453
  if (isNotFoundError(error))
416
- return false;
454
+ throw new TaskStateError(`task not found: ${id}`);
455
+ throw error;
456
+ }
457
+ });
458
+ }
459
+ /** Cheap durable deletion-epoch probe for room/member publication guards. */
460
+ export function taskDeletionState(id) {
461
+ return withTaskLock(id, () => {
462
+ assertCanonicalTaskId(id);
463
+ try {
464
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
465
+ return stored.deletion?.status === 'pending' ? 'pending' : 'none';
466
+ }
467
+ catch (error) {
468
+ if (isNotFoundError(error))
469
+ return 'absent';
470
+ throw error;
471
+ }
472
+ });
473
+ }
474
+ export const deletionReceiptsDir = () => join(stateRoot(), 'deletion-receipts');
475
+ function deletionReceiptPath(id) {
476
+ return join(deletionReceiptsDir(), `${id}.json`);
477
+ }
478
+ export function readTaskDeletionReceipt(id) {
479
+ assertCanonicalTaskId(id);
480
+ try {
481
+ return JSON.parse(readFileSync(deletionReceiptPath(id), 'utf8'));
482
+ }
483
+ catch (error) {
484
+ if (isNotFoundError(error))
485
+ return undefined;
486
+ throw error;
487
+ }
488
+ }
489
+ function writeDeletionReceiptForIntent(stored) {
490
+ const deletion = stored.deletion;
491
+ mkdirSync(deletionReceiptsDir(), { recursive: true });
492
+ const receipt = {
493
+ schema_version: 1,
494
+ task_id: stored.task_id,
495
+ title: stored.title.slice(0, 256),
496
+ accepted_at: deletion.accepted_at,
497
+ actor: deletion.actor,
498
+ original_state: stored.state,
499
+ room_id: deletion.room_id,
500
+ member_count: deletion.members.length,
501
+ };
502
+ replaceFileAtomically(deletionReceiptPath(stored.task_id), JSON.stringify(receipt, null, 2) + '\n');
503
+ }
504
+ /**
505
+ * Ensure the acceptance receipt exists before any cleanup side effect,
506
+ * backfilling it from the durable intent (heals a crash between the intent
507
+ * write and the receipt write). Fails closed: a receipt write error aborts
508
+ * settlement rather than deleting resources without audit evidence.
509
+ */
510
+ export function ensureTaskDeletionReceipt(id) {
511
+ return withTaskLock(id, () => {
512
+ assertCanonicalTaskId(id);
513
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
514
+ if (stored.deletion?.status !== 'pending')
515
+ throw new TaskStateError(`task ${id} has no pending deletion`);
516
+ if (!readTaskDeletionReceipt(id))
517
+ writeDeletionReceiptForIntent(stored);
518
+ });
519
+ }
520
+ /** Record settlement on the receipt; idempotent, tolerant of legacy absence. */
521
+ export function completeTaskDeletionReceipt(id) {
522
+ const receipt = readTaskDeletionReceipt(id);
523
+ if (!receipt || receipt.settled_at)
524
+ return;
525
+ receipt.settled_at = new Date().toISOString();
526
+ receipt.result = 'deleted';
527
+ replaceFileAtomically(deletionReceiptPath(id), JSON.stringify(receipt, null, 2) + '\n');
528
+ }
529
+ /**
530
+ * Persist the first-wins durable deletion intent. Accepts every lifecycle
531
+ * state, takes precedence over a pending terminal intent, and never touches
532
+ * remote resources. Repeat requests while pending re-arm settlement; a request
533
+ * for a missing record reports the idempotent already-absent outcome.
534
+ *
535
+ * Low-level record mutation only. Callers serialize acceptance with settlement
536
+ * through deletion.ts, which holds the common task-operation lock.
537
+ */
538
+ export function beginTaskDeletionIntent(id, actor) {
539
+ return withTaskLock(id, () => {
540
+ assertCanonicalTaskId(id);
541
+ let stored;
542
+ try {
543
+ stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
544
+ }
545
+ catch (error) {
546
+ if (isNotFoundError(error))
547
+ return { status: 'already_absent' };
417
548
  throw error;
418
549
  }
419
- if (task.state !== 'done') {
420
- throw new TaskStateError(`cannot delete a '${task.state}' task; only 'done' tasks can be deleted`);
550
+ if (stored.deletion?.status === 'pending') {
551
+ // Heal a crash between the intent write and the receipt write.
552
+ if (!readTaskDeletionReceipt(id))
553
+ writeDeletionReceiptForIntent(stored);
554
+ return { status: 'pending', task: presentTaskLenient(stored) };
555
+ }
556
+ const now = new Date().toISOString();
557
+ stored.deletion = {
558
+ status: 'pending',
559
+ accepted_at: now,
560
+ actor,
561
+ room_id: stored.room_id,
562
+ members: (stored.member_roles ?? []).map(member => ({
563
+ name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
564
+ })),
565
+ };
566
+ writeTask(stored);
567
+ writeDeletionReceiptForIntent(stored);
568
+ return { status: 'accepted', task: presentTaskLenient(stored) };
569
+ });
570
+ }
571
+ /** Record an actionable settlement failure; the task stays hidden and recoverable. */
572
+ export function setTaskDeletionError(id, error, recoveryHint) {
573
+ return withTaskLock(id, () => {
574
+ assertCanonicalTaskId(id);
575
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
576
+ if (stored.deletion?.status !== 'pending')
577
+ throw new TaskStateError(`task ${id} has no pending deletion`);
578
+ stored.deletion.first_failure ??= error;
579
+ stored.deletion.first_recovery_hint ??= recoveryHint;
580
+ stored.deletion.error = error;
581
+ const previousErrorAt = Date.parse(stored.deletion.error_at ?? '');
582
+ stored.deletion.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
583
+ stored.deletion.recovery_hint = recoveryHint;
584
+ writeTask(stored);
585
+ return presentTaskLenient(stored);
586
+ });
587
+ }
588
+ const DELETION_MEMBER_PHASE_ORDER = {
589
+ pending: 0, stop_requested: 1, liveness_absent: 2, archive_secured: 3, identity_absent: 4,
590
+ };
591
+ /** Marker proving a member never launched, mirroring close.ts's short path. */
592
+ export const DELETION_MEMBER_NEVER_LAUNCHED = 'never-launched';
593
+ /** Marker proving absence verified against temp state AND the CID-wide identity scan. */
594
+ export const DELETION_MEMBER_ABSENT_VERIFIED = 'absent-verified';
595
+ /**
596
+ * Advance a member retirement cursor carried by the deletion intent. Only
597
+ * adjacent forward transitions are legal — plus the explicit
598
+ * pending → identity_absent short path proved by the 'never-launched' marker —
599
+ * so no managed agent can be claimed retired without the full evidence chain.
600
+ * launch_id and archive_path are immutable once recorded; same-phase retries
601
+ * are idempotent but must not change evidence.
602
+ */
603
+ export function advanceTaskDeletionMember(id, name, phase, launchId, archivePath) {
604
+ return withTaskLock(id, () => {
605
+ assertCanonicalTaskId(id);
606
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
607
+ if (stored.deletion?.status !== 'pending')
608
+ throw new TaskStateError(`task ${id} has no pending deletion`);
609
+ const cursor = stored.deletion.members.find(member => member.name === name);
610
+ if (!cursor)
611
+ throw new TaskStateError(`task ${id} deletion has no member cursor '${name}'`);
612
+ if (cursor.launch_id !== undefined && launchId !== undefined && launchId !== cursor.launch_id)
613
+ throw new TaskStateError(`task ${id} deletion member '${name}' launch ownership proof is immutable`);
614
+ if (cursor.archive_path !== undefined && archivePath !== undefined && archivePath !== cursor.archive_path)
615
+ throw new TaskStateError(`task ${id} deletion member '${name}' archive evidence is immutable`);
616
+ const step = DELETION_MEMBER_PHASE_ORDER[phase] - DELETION_MEMBER_PHASE_ORDER[cursor.phase];
617
+ const launch = cursor.launch_id ?? launchId;
618
+ if (step < 0)
619
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot move back from '${cursor.phase}' to '${phase}'`);
620
+ if (step === 0)
621
+ return presentTaskLenient(stored); // idempotent retry, evidence already verified
622
+ const neverLaunched = cursor.phase === 'pending' && phase === 'identity_absent'
623
+ && (launch === DELETION_MEMBER_NEVER_LAUNCHED || launch === DELETION_MEMBER_ABSENT_VERIFIED);
624
+ if (step !== 1 && !neverLaunched)
625
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot skip from '${cursor.phase}' to '${phase}' without evidence`);
626
+ if (launch === undefined)
627
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot reach '${phase}' without a launch ownership proof`);
628
+ if (phase === 'archive_secured' && (cursor.archive_path ?? archivePath) === undefined)
629
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot reach 'archive_secured' without archive evidence`);
630
+ cursor.phase = phase;
631
+ cursor.launch_id = launch;
632
+ if (archivePath !== undefined)
633
+ cursor.archive_path = archivePath;
634
+ cursor.updated_at = new Date().toISOString();
635
+ writeTask(stored);
636
+ return presentTaskLenient(stored);
637
+ });
638
+ }
639
+ function findCursorForSeat(id, stored, seat) {
640
+ const cursor = stored.deletion.members.find(member => member.name === seat.role_name);
641
+ if (cursor && seat.identity_cid
642
+ && cursor.identity_cid.toLowerCase() !== seat.identity_cid.toLowerCase())
643
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' identity CID mismatch between cursor and room seat`);
644
+ return cursor;
645
+ }
646
+ /**
647
+ * Durably register cursors for late-provisioned members before retirement
648
+ * begins: provisioning publishes room seats before updateTaskMembers, so the
649
+ * acceptance snapshot can be empty while live seats exist. Seats without a
650
+ * recorded identity CID are not registered here — until a CID is recorded
651
+ * there is no managed identity to orphan, and the room record still carries
652
+ * those seats through the close saga that follows.
653
+ */
654
+ export function upsertTaskDeletionMembersFromSeats(id, seats) {
655
+ return withTaskLock(id, () => {
656
+ assertCanonicalTaskId(id);
657
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
658
+ if (stored.deletion?.status !== 'pending')
659
+ throw new TaskStateError(`task ${id} has no pending deletion`);
660
+ const now = new Date().toISOString();
661
+ for (const seat of seats) {
662
+ if (!seat.identity_cid)
663
+ continue;
664
+ if (!findCursorForSeat(id, stored, seat)) {
665
+ stored.deletion.members.push({
666
+ name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now,
667
+ });
668
+ }
669
+ }
670
+ writeTask(stored);
671
+ return presentTaskLenient(stored);
672
+ });
673
+ }
674
+ /**
675
+ * The pre-room-delete checkpoint: import completed retirement evidence from
676
+ * room seats into the deletion cursors, so a crash between room-record
677
+ * deletion and task unlink retries from durable cursors instead of
678
+ * reconstructing consumed evidence. Call ONLY after closeManagedRoom has
679
+ * completed retirement and BEFORE cowork.deleteRoom/deleteRoomRecord.
680
+ *
681
+ * The room saga is trusted for phase jumps, but partial or corrupt retained
682
+ * records must not become false success: every seat must be identity_absent;
683
+ * a real launch requires archive evidence; an identity-less seat is accepted
684
+ * only via the never-launched proof.
685
+ */
686
+ export function importTaskDeletionRetirementEvidence(id, seats) {
687
+ return withTaskLock(id, () => {
688
+ assertCanonicalTaskId(id);
689
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
690
+ if (stored.deletion?.status !== 'pending')
691
+ throw new TaskStateError(`task ${id} has no pending deletion`);
692
+ const now = new Date().toISOString();
693
+ for (const seat of seats) {
694
+ const evidence = seat.retirement;
695
+ if (evidence?.phase !== 'identity_absent')
696
+ throw new TaskStateError(`task ${id} deletion cannot checkpoint member '${seat.role_name}' before completed retirement`);
697
+ const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED;
698
+ if (!neverLaunched && evidence.archive_path === undefined)
699
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' retirement evidence lacks its archive`);
700
+ if (!seat.identity_cid) {
701
+ if (neverLaunched)
702
+ continue; // provably never held a managed identity
703
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' has retirement evidence but no identity CID`);
704
+ }
705
+ let cursor = findCursorForSeat(id, stored, seat);
706
+ if (!cursor) {
707
+ cursor = { name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now };
708
+ stored.deletion.members.push(cursor);
709
+ }
710
+ if (DELETION_MEMBER_PHASE_ORDER[evidence.phase] < DELETION_MEMBER_PHASE_ORDER[cursor.phase])
711
+ continue;
712
+ if (cursor.launch_id !== undefined && cursor.launch_id !== evidence.launch_id)
713
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' launch ownership proof is immutable`);
714
+ if (cursor.archive_path !== undefined && evidence.archive_path !== undefined
715
+ && cursor.archive_path !== evidence.archive_path)
716
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' archive evidence is immutable`);
717
+ cursor.phase = evidence.phase;
718
+ cursor.launch_id = evidence.launch_id;
719
+ if (evidence.archive_path !== undefined)
720
+ cursor.archive_path = evidence.archive_path;
721
+ cursor.updated_at = now;
722
+ }
723
+ writeTask(stored);
724
+ return presentTaskLenient(stored);
725
+ });
726
+ }
727
+ /**
728
+ * Physically remove a deletion-pending task record. Settlement-only: callers
729
+ * must have completed member retirement and room cleanup first. Missing
730
+ * records are the idempotent already-settled outcome.
731
+ */
732
+ export function unlinkDeletedTask(id) {
733
+ return withTaskLock(id, () => {
734
+ assertCanonicalTaskId(id);
735
+ let stored;
736
+ try {
737
+ stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
738
+ }
739
+ catch (error) {
740
+ if (isNotFoundError(error))
741
+ return false;
742
+ throw error;
421
743
  }
744
+ if (stored.deletion?.status !== 'pending')
745
+ throw new TaskStateError(`task ${id} has no pending deletion`);
422
746
  try {
423
- unlinkSync(p);
747
+ unlinkSync(taskPath(id));
424
748
  }
425
749
  catch (error) {
426
750
  if (isNotFoundError(error))
@@ -1,6 +1,9 @@
1
1
  import { type RoomCloseDeps } from './close.js';
2
2
  import type { CoworkAdapter } from './cowork-adapter.js';
3
3
  import type { TaskOutcome, TaskRecord, TaskTerminalIntent } from './types.js';
4
+ export declare const TASK_OPERATION_LOCK_STALE_MS: number;
5
+ /** Common per-task operation lock serializing terminal, deletion, and recovery acceptance. */
6
+ export declare function taskOperationLockPath(taskId: string): string;
4
7
  export interface TaskTerminalInput {
5
8
  taskId: string;
6
9
  kind: TaskTerminalIntent['kind'];
@@ -2,10 +2,11 @@ import { join } from 'node:path';
2
2
  import { withFileLock } from '../atomic-file.js';
3
3
  import { stateRoot } from '../paths.js';
4
4
  import { deleteManagedRoom } from './close.js';
5
- import { beginTaskTerminalIntent, finishTaskTerminalIntent, getTask, setTaskTerminalIntentError, } from './task-state.js';
5
+ import { assertNoPendingDeletion, beginTaskTerminalIntent, finishTaskTerminalIntent, getTask, setTaskTerminalIntentError, } from './task-state.js';
6
6
  import { getRoomRecord } from './room-state.js';
7
- const TASK_OPERATION_LOCK_STALE_MS = 10 * 60_000;
8
- function taskOperationLockPath(taskId) {
7
+ export const TASK_OPERATION_LOCK_STALE_MS = 10 * 60_000;
8
+ /** Common per-task operation lock serializing terminal, deletion, and recovery acceptance. */
9
+ export function taskOperationLockPath(taskId) {
9
10
  return join(stateRoot(), 'locks', 'task-terminal', encodeURIComponent(taskId));
10
11
  }
11
12
  function errorText(error) {
@@ -24,6 +25,9 @@ export function acceptTaskTerminalIntent(input) {
24
25
  export function settleTaskTerminalIntent(input) {
25
26
  return withFileLock(taskOperationLockPath(input.taskId), async () => {
26
27
  const current = getTask(input.taskId);
28
+ // Deletion supersedes a pending terminal intent: fail boundedly before any
29
+ // room side effect so the deletion worker owns all remaining cleanup.
30
+ assertNoPendingDeletion(current);
27
31
  const intent = current.terminal_intent;
28
32
  if (!intent)
29
33
  throw new Error(`task ${input.taskId} has no accepted terminal intent`);
@@ -5,6 +5,7 @@
5
5
  * membership, and archive. Messenger Server owns owner presentation.
6
6
  * Fleet stores only IDs, CIDs, orchestration state, and saga cursors.
7
7
  */
8
+ import type { AgentLaunchConfiguration } from '../lifecycle-summary.js';
8
9
  export type TaskState = 'backlog' | 'provisioning' | 'active' | 'review' | 'done' | 'cancelled' | 'failed';
9
10
  export declare const TASK_TERMINAL_STATES: readonly TaskState[];
10
11
  export declare const TASK_CANCELLABLE_STATES: readonly TaskState[];
@@ -44,6 +45,47 @@ export interface TaskTerminalIntent {
44
45
  first_failure?: string;
45
46
  first_recovery_hint?: string;
46
47
  }
48
+ /** Origin evidence for an accepted deletion; retained until the record is unlinked. */
49
+ export type TaskDeletionActor = {
50
+ kind: 'local_control';
51
+ surface: 'cli' | 'web';
52
+ } | {
53
+ kind: 'authenticated_owner';
54
+ surface: 'messenger';
55
+ cid: string;
56
+ };
57
+ export type TaskDeletionMemberPhase = 'pending' | 'stop_requested' | 'liveness_absent' | 'archive_secured' | 'identity_absent';
58
+ /**
59
+ * Durable retirement cursor for a managed member when no room orchestration
60
+ * record exists to carry the equivalent seat cursor.
61
+ */
62
+ export interface TaskDeletionMemberCursor {
63
+ name: string;
64
+ identity_cid: string;
65
+ phase: TaskDeletionMemberPhase;
66
+ launch_id?: string;
67
+ archive_path?: string;
68
+ updated_at: string;
69
+ }
70
+ /**
71
+ * First-wins durable deletion intent. There is no settled status: settlement
72
+ * ends by unlinking the task record, so physical absence is the completion
73
+ * evidence. While pending, the task is hidden from normal operation and every
74
+ * lifecycle mutation or room publication is rejected.
75
+ */
76
+ export interface TaskDeletionIntent {
77
+ status: 'pending';
78
+ accepted_at: string;
79
+ actor: TaskDeletionActor;
80
+ room_id?: string;
81
+ /** Snapshot of managed members at acceptance; the missing-room retirement evidence. */
82
+ members: TaskDeletionMemberCursor[];
83
+ error?: string;
84
+ error_at?: string;
85
+ recovery_hint?: string;
86
+ first_failure?: string;
87
+ first_recovery_hint?: string;
88
+ }
47
89
  export interface TaskRecord {
48
90
  task_id: string;
49
91
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
@@ -74,6 +116,7 @@ export interface TaskRecord {
74
116
  ended_at?: string;
75
117
  outcome?: TaskOutcome;
76
118
  terminal_intent?: TaskTerminalIntent;
119
+ deletion?: TaskDeletionIntent;
77
120
  }
78
121
  export interface TaskListRecord {
79
122
  list_id: string;
@@ -112,6 +155,11 @@ export interface RoomMemberLaunchState {
112
155
  agent_fingerprint?: string;
113
156
  agent_template?: string;
114
157
  agent_template_hash?: string;
158
+ /**
159
+ * Operator-facing launch configuration captured from the exact resolved
160
+ * launch state; the single source for later Task/Room lifecycle reports.
161
+ */
162
+ presentation?: AgentLaunchConfiguration;
115
163
  launch_id?: string;
116
164
  updated_at: string;
117
165
  error?: string;
@@ -183,12 +183,39 @@ export async function buildWebServer(services, boundary, options = {}) {
183
183
  throw new FleetError('invalid_request', 'invalid task state filter');
184
184
  if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
185
185
  throw new FleetError('invalid_request', 'groupByList must be true or false');
186
+ if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
187
+ throw new FleetError('invalid_request', 'includeDeleting must be true or false');
186
188
  const state = query.state && query.state !== 'all' ? query.state : undefined;
187
- const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}) };
189
+ const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
190
+ ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
188
191
  return taskApi(() => query.groupByList === 'true'
189
192
  ? { groups: requireTaskRooms().groupedTasks(filter) }
190
193
  : { tasks: requireTaskRooms().listTasks(filter) });
191
194
  });
195
+ app.delete('/api/v1/tasks/:id', async (request, reply) => {
196
+ auth.authenticate(request, true);
197
+ const taskId = request.params.id;
198
+ const confirm = request.query.confirm;
199
+ // Exact-ID-twice confirmation is validated before existence or idempotency.
200
+ if (confirm !== taskId)
201
+ throw new FleetError('invalid_request', 'confirm must exactly repeat the task id');
202
+ const accepted = await taskApi(() => requireTaskRooms().requestTaskDeletion({
203
+ actor: { kind: 'local_control', surface: 'web' }, taskId,
204
+ }));
205
+ if (accepted.status === 'already_absent')
206
+ return { task_id: taskId, deleted: false, already_absent: true };
207
+ // Cleanup runs in an external worker outside the request lifecycle; the
208
+ // request waits boundedly and reports pending — never fabricated success.
209
+ const outcome = await requireTaskRooms().launchTaskDeletionWorker({ taskId, waitMs: 5_000 });
210
+ if (outcome.deleted)
211
+ return { task_id: taskId, deleted: true };
212
+ reply.code(202);
213
+ return {
214
+ task_id: taskId, accepted: true, deletion: 'pending',
215
+ ...(outcome.error ? { error: outcome.error } : {}),
216
+ recovery: `Repeat DELETE /api/v1/tasks/${taskId}?confirm=${taskId} or run 'ours-fleet task recover ${taskId}'.`,
217
+ };
218
+ });
192
219
  app.post('/api/v1/tasks', async (request, reply) => {
193
220
  auth.authenticate(request, true);
194
221
  const body = request.body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.1.0-nightly.12",
3
+ "version": "1.1.0-nightly.13",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",