@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
|
@@ -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
|
-
|
|
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(`^
|
|
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
|
-
|
|
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
|
}
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -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(`^
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1048
|
-
|
|
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({
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|