@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.
- package/dist/application/role-creation-service.js +8 -1
- package/dist/application/task-room-service.d.ts +44 -4
- package/dist/application/task-room-service.js +137 -20
- package/dist/build-info.json +4 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +18 -5
- package/dist/fleet-command-audit.d.ts +4 -0
- package/dist/fleet-command-audit.js +126 -22
- package/dist/fleet-proxy.d.ts +3 -0
- package/dist/lifecycle-summary.d.ts +118 -0
- package/dist/lifecycle-summary.js +161 -0
- package/dist/owner-channel/channel.d.ts +2 -0
- package/dist/owner-channel/channel.js +45 -9
- package/dist/owner-channel/commands.d.ts +4 -1
- package/dist/owner-channel/commands.js +3 -6
- package/dist/rooms-tasks/cli.js +151 -19
- package/dist/rooms-tasks/close.d.ts +8 -0
- package/dist/rooms-tasks/close.js +10 -3
- package/dist/rooms-tasks/deletion.d.ts +51 -0
- package/dist/rooms-tasks/deletion.js +217 -0
- package/dist/rooms-tasks/launch-snapshot.d.ts +9 -0
- package/dist/rooms-tasks/launch-snapshot.js +18 -1
- package/dist/rooms-tasks/provision.js +77 -8
- package/dist/rooms-tasks/task-state.d.ts +113 -3
- package/dist/rooms-tasks/task-state.js +333 -9
- package/dist/rooms-tasks/terminal.d.ts +3 -0
- package/dist/rooms-tasks/terminal.js +7 -3
- package/dist/rooms-tasks/types.d.ts +48 -0
- package/dist/web/server.js +28 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
450
|
+
return presentTaskLenient(JSON.parse(readFileSync(taskPath(id), 'utf8')));
|
|
413
451
|
}
|
|
414
452
|
catch (error) {
|
|
415
453
|
if (isNotFoundError(error))
|
|
416
|
-
|
|
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 (
|
|
420
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/dist/web/server.js
CHANGED
|
@@ -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.
|
|
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",
|