@ours.network/fleet 0.19.0-nightly.1 → 0.19.0-nightly.11

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.
Files changed (47) hide show
  1. package/README.md +30 -0
  2. package/dist/briefing.js +92 -6
  3. package/dist/build-info.json +4 -4
  4. package/dist/cli.js +5 -0
  5. package/dist/config.d.ts +22 -0
  6. package/dist/config.js +44 -3
  7. package/dist/docs.d.ts +1 -1
  8. package/dist/docs.js +28 -0
  9. package/dist/doctor.js +125 -1
  10. package/dist/harness/claude-code.js +2 -0
  11. package/dist/harness/codex.js +2 -0
  12. package/dist/harness/types.d.ts +2 -0
  13. package/dist/owner-channel/channel.d.ts +7 -0
  14. package/dist/owner-channel/channel.js +47 -0
  15. package/dist/owner-channel/commands.d.ts +9 -0
  16. package/dist/owner-channel/commands.js +393 -0
  17. package/dist/rooms-tasks/cli.d.ts +4 -0
  18. package/dist/rooms-tasks/cli.js +1172 -0
  19. package/dist/rooms-tasks/close.d.ts +21 -0
  20. package/dist/rooms-tasks/close.js +159 -0
  21. package/dist/rooms-tasks/config.d.ts +15 -0
  22. package/dist/rooms-tasks/config.js +174 -0
  23. package/dist/rooms-tasks/cowork-adapter.d.ts +98 -0
  24. package/dist/rooms-tasks/cowork-adapter.js +385 -0
  25. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  26. package/dist/rooms-tasks/external-worker.js +40 -0
  27. package/dist/rooms-tasks/index.d.ts +10 -0
  28. package/dist/rooms-tasks/index.js +10 -0
  29. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  30. package/dist/rooms-tasks/member-startup.js +151 -0
  31. package/dist/rooms-tasks/provision.d.ts +29 -0
  32. package/dist/rooms-tasks/provision.js +514 -0
  33. package/dist/rooms-tasks/room-state.d.ts +34 -0
  34. package/dist/rooms-tasks/room-state.js +263 -0
  35. package/dist/rooms-tasks/task-state.d.ts +43 -0
  36. package/dist/rooms-tasks/task-state.js +277 -0
  37. package/dist/rooms-tasks/templates.d.ts +6 -0
  38. package/dist/rooms-tasks/templates.js +94 -0
  39. package/dist/rooms-tasks/terminal.d.ts +23 -0
  40. package/dist/rooms-tasks/terminal.js +53 -0
  41. package/dist/rooms-tasks/types.d.ts +260 -0
  42. package/dist/rooms-tasks/types.js +20 -0
  43. package/dist/spawn.d.ts +3 -1
  44. package/dist/spawn.js +1 -0
  45. package/dist/temp-lifecycle.d.ts +14 -2
  46. package/dist/temp-lifecycle.js +90 -0
  47. package/package.json +1 -1
@@ -0,0 +1,1172 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { loadConfig, ConfigError } from '../config.js';
3
+ import { provisionMembers, getBinPath } from './provision.js';
4
+ import { acceptManagedRoomClose, closeManagedRoom, recordManagedRoomCloseError, } from './close.js';
5
+ import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, } from './terminal.js';
6
+ import { launchFleetWorker } from './external-worker.js';
7
+ import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
8
+ import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, updateTaskRoom, updateTaskTemplate, TaskStateError, } from './task-state.js';
9
+ import { createRoomRecord, getRoomRecord, listRoomRecords, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
10
+ import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
11
+ import { TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from './types.js';
12
+ function die(e) {
13
+ const msg = e instanceof Error ? e.message : String(e);
14
+ process.stderr.write(`error: ${msg}\n`);
15
+ process.exit(1);
16
+ }
17
+ const PUBLIC_SETTLE_WAIT_MS = 60_000;
18
+ const PUBLIC_SETTLE_POLL_MS = 100;
19
+ const sleep = (ms) => new Promise(resolve => { setTimeout(resolve, ms); });
20
+ function errorText(error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
23
+ async function launchTaskSettleWorker(taskId, configPath) {
24
+ const previousErrorAt = getTask(taskId).terminal_intent?.error_at;
25
+ try {
26
+ await launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath);
27
+ }
28
+ catch (error) {
29
+ await recordTaskTerminalIntentError(taskId, errorText(error), `External settle worker failed to start. Retry task recover ${taskId}.`);
30
+ throw error;
31
+ }
32
+ const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
33
+ while (Date.now() < deadline) {
34
+ const task = getTask(taskId);
35
+ if (task.terminal_intent?.status === 'settled')
36
+ return { task, timedOut: false };
37
+ if (task.terminal_intent?.error_at !== previousErrorAt && task.terminal_intent?.error) {
38
+ throw new TaskStateError(task.terminal_intent.error);
39
+ }
40
+ await sleep(PUBLIC_SETTLE_POLL_MS);
41
+ }
42
+ return { task: getTask(taskId), timedOut: true };
43
+ }
44
+ async function launchRoomCloseWorker(roomId, configPath) {
45
+ const previousErrorAt = getRoomRecord(roomId)?.close?.error_at;
46
+ try {
47
+ await launchFleetWorker(['room', '_close', roomId], `room-close-${roomId}`, configPath);
48
+ }
49
+ catch (error) {
50
+ await recordManagedRoomCloseError(roomId, errorText(error), `External close worker failed to start. Retry room recover ${roomId}.`);
51
+ throw error;
52
+ }
53
+ const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
54
+ while (Date.now() < deadline) {
55
+ const room = getRoomRecord(roomId);
56
+ if (room.state === 'closed')
57
+ return { room, timedOut: false };
58
+ if (room.close?.error_at !== previousErrorAt && room.close?.error) {
59
+ throw new RoomStateError(room.close.error);
60
+ }
61
+ await sleep(PUBLIC_SETTLE_POLL_MS);
62
+ }
63
+ return { room: getRoomRecord(roomId), timedOut: true };
64
+ }
65
+ function loadCfg(opts) {
66
+ return loadConfig(opts.configuration);
67
+ }
68
+ function allTemplates(cfg) {
69
+ return cfg.roomTemplates ?? {};
70
+ }
71
+ function coworkFor(cfg) {
72
+ if (!cfg.rooms)
73
+ throw new ConfigError('rooms: configuration is required before creating or querying rooms');
74
+ return createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
75
+ }
76
+ function resolveRoomTemplate(cfg, name) {
77
+ if (!name)
78
+ return undefined;
79
+ const template = resolveTemplate(name, allTemplates(cfg));
80
+ if (!template)
81
+ throw new Error(`template not found: ${name}`);
82
+ return snapshotTemplate(template);
83
+ }
84
+ function durableTaskRoomTemplate(task, room) {
85
+ const snapshot = room.template_snapshot;
86
+ if (!snapshot)
87
+ throw new Error(`room ${room.room_id} has no durable template snapshot`);
88
+ const ref = task.template;
89
+ if (!ref || ref.name !== snapshot.name || ref.version !== snapshot.version
90
+ || ref.content_hash !== snapshot.content_hash) {
91
+ throw new Error(`task ${task.task_id} template reference does not match room ${room.room_id}'s durable snapshot`);
92
+ }
93
+ return snapshot;
94
+ }
95
+ function printRoomStartupEvidence(room) {
96
+ const definitions = Object.values(room.role_briefings ?? {});
97
+ if (definitions.length) {
98
+ console.log('Role briefings:');
99
+ for (const definition of definitions.sort((a, b) => a.role.localeCompare(b.role)))
100
+ console.log(` ${definition.role} v${definition.version ?? '?'} ${definition.state} sha256:${definition.sha256}`);
101
+ }
102
+ if (room.member_seats.length) {
103
+ console.log('Fleet startup evidence:');
104
+ for (const seat of room.member_seats) {
105
+ const launch = seat.launch?.state ?? 'unrecorded';
106
+ const briefing = seat.briefing?.state ?? 'unrecorded';
107
+ console.log(` ${seat.role_name} ${seat.identity_cid} role=${seat.cowork_role} seat=${seat.seat_state} launch=${launch} briefing=${briefing}`);
108
+ if (seat.briefing?.message_id)
109
+ console.log(` briefing_message=${seat.briefing.message_id} relay=${seat.briefing.relay_result_record_id ?? 'pending'} ack=${seat.briefing.acknowledgement_message_id ?? 'pending'}`);
110
+ if (seat.briefing?.last_rejected_ack_reason)
111
+ console.log(` rejected_ack_seq=${seat.briefing.last_rejected_ack_seq} reason=${seat.briefing.last_rejected_ack_reason}`);
112
+ if (seat.launch?.error)
113
+ console.log(` launch_error=${seat.launch.error}`);
114
+ }
115
+ }
116
+ }
117
+ async function provisionRoom(cfg, input) {
118
+ const rooms = cfg.rooms;
119
+ if (!rooms)
120
+ throw new ConfigError('rooms: configuration is required');
121
+ const attachOwner = rooms.defaults?.attach_owner !== false;
122
+ if (attachOwner && !cfg.ownerInvite)
123
+ throw new ConfigError('rooms.owner: public_invite or public_invite_file is required when attach_owner is enabled');
124
+ const cowork = coworkFor(cfg);
125
+ const goal = input.goal?.trim() || input.name;
126
+ const briefing = input.brief?.trim() || input.template?.contract?.trim() || goal;
127
+ const created = await cowork.createRoom({
128
+ room_name: input.name,
129
+ goal,
130
+ briefing,
131
+ quiet_membership: input.template?.room?.quiet_membership,
132
+ anonymous: input.template?.room?.anonymous,
133
+ });
134
+ let record = createRoomRecord({
135
+ room_id: created.room_id,
136
+ room_name: input.name,
137
+ room_identity_cid: created.identity_cid,
138
+ task_id: input.taskId,
139
+ template_snapshot: input.template,
140
+ });
141
+ input.onCreated?.(record);
142
+ record = advanceSaga(record.room_id, 'create_room', 1);
143
+ if (attachOwner) {
144
+ try {
145
+ record = advanceSaga(record.room_id, 'attach_owner', 2);
146
+ const accepted = await cowork.acceptInvite(record.room_id, cfg.ownerInvite, {
147
+ role: rooms.owner.role,
148
+ expected_cid: rooms.owner.expected_cid,
149
+ });
150
+ record = setOwnerSeat(record.room_id, accepted.seat_cid, cfg.ownerInviteFingerprint ?? '');
151
+ }
152
+ catch (error) {
153
+ const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
154
+ setSagaError(record.room_id, error instanceof Error ? error.message : String(error), mismatch
155
+ ? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
156
+ : 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
157
+ throw error;
158
+ }
159
+ }
160
+ record = advanceSaga(record.room_id, 'create_members', 3);
161
+ if (input.template && input.template.members.length > 0) {
162
+ record = await provisionMembers({
163
+ cfg,
164
+ cowork,
165
+ roomId: record.room_id,
166
+ taskId: input.taskId,
167
+ template: input.template,
168
+ binPath: getBinPath(),
169
+ brief: input.brief,
170
+ goal: input.goal,
171
+ });
172
+ }
173
+ else {
174
+ record = activateRoom(record.room_id);
175
+ if (input.taskId)
176
+ activateTask(input.taskId);
177
+ }
178
+ return record;
179
+ }
180
+ export function registerTemplateCommands(parent, cOpt) {
181
+ const templateCmd = parent.command('template').description('room template operations');
182
+ cOpt(templateCmd.command('list'))
183
+ .description('list available room templates')
184
+ .option('--json', 'JSON output')
185
+ .action((opts) => {
186
+ try {
187
+ const cfg = loadCfg(opts);
188
+ const templates = listTemplates(allTemplates(cfg));
189
+ if (opts.json) {
190
+ console.log(JSON.stringify({ schema_version: 1, templates }, null, 2));
191
+ return;
192
+ }
193
+ if (!templates.length) {
194
+ console.log('No templates configured.');
195
+ return;
196
+ }
197
+ for (const t of templates) {
198
+ const tag = t.builtin ? ' (built-in)' : '';
199
+ console.log(`${t.name}@${t.version}${tag} ${t.description}`);
200
+ }
201
+ }
202
+ catch (e) {
203
+ die(e);
204
+ }
205
+ });
206
+ cOpt(templateCmd.command('show <name>'))
207
+ .description('show template details')
208
+ .option('--json', 'JSON output')
209
+ .action((name, opts) => {
210
+ try {
211
+ const cfg = loadCfg(opts);
212
+ const t = resolveTemplate(name, allTemplates(cfg));
213
+ if (!t)
214
+ die(new Error(`template not found: ${name}`));
215
+ if (opts.json) {
216
+ console.log(JSON.stringify({ schema_version: 1, template: { ...t, content_hash: hashTemplate(t) } }, null, 2));
217
+ return;
218
+ }
219
+ console.log(`${t.name}@${t.version} ${t.description}`);
220
+ if (t.contract)
221
+ console.log(`\nContract:\n${t.contract}`);
222
+ console.log(`\nMembers:`);
223
+ for (const m of t.members)
224
+ console.log(` ${m.slot}: ${m.count}× ${m.role} (ref: ${m.role_ref})`);
225
+ console.log(`\nContent hash: ${hashTemplate(t)}`);
226
+ }
227
+ catch (e) {
228
+ die(e);
229
+ }
230
+ });
231
+ cOpt(templateCmd.command('validate'))
232
+ .description('validate all room templates')
233
+ .option('--json', 'JSON output')
234
+ .action((opts) => {
235
+ try {
236
+ const cfg = loadCfg(opts);
237
+ const templates = listTemplates(allTemplates(cfg));
238
+ const problems = [];
239
+ for (const t of templates) {
240
+ const issues = [];
241
+ for (const m of t.members) {
242
+ if (!cfg.roles.some(r => r.name === m.role_ref))
243
+ issues.push(`member ${m.slot}: role_ref '${m.role_ref}' not found in fleet roles`);
244
+ }
245
+ if (issues.length)
246
+ problems.push({ template: `${t.name}@${t.version}`, issues });
247
+ }
248
+ if (opts.json) {
249
+ console.log(JSON.stringify({ schema_version: 1, valid: !problems.length, problems }, null, 2));
250
+ return;
251
+ }
252
+ if (!problems.length) {
253
+ console.log('All templates valid.');
254
+ return;
255
+ }
256
+ for (const p of problems) {
257
+ console.log(`${p.template}:`);
258
+ for (const i of p.issues)
259
+ console.log(` - ${i}`);
260
+ }
261
+ process.exit(1);
262
+ }
263
+ catch (e) {
264
+ die(e);
265
+ }
266
+ });
267
+ }
268
+ export function registerTaskCommands(parent, cOpt) {
269
+ const taskCmd = parent.command('task').description('task lifecycle operations');
270
+ cOpt(taskCmd.command('create'))
271
+ .description('create a new task')
272
+ .requiredOption('--title <title>', 'task title')
273
+ .option('--template <name>', 'room template')
274
+ .option('--brief <text>', 'task brief')
275
+ .option('--brief-file <path>', 'task brief from file')
276
+ .option('--backlog', 'create in backlog (do not start immediately)')
277
+ .option('--no-room', 'create task without a room')
278
+ .option('--idempotency-key <key>', 'idempotency key')
279
+ .option('--json', 'JSON output')
280
+ .action(async (opts) => {
281
+ try {
282
+ const cfg = loadCfg(opts);
283
+ let templateRef;
284
+ if (opts.template) {
285
+ const t = resolveTemplate(opts.template, allTemplates(cfg));
286
+ if (!t)
287
+ die(new Error(`template not found: ${opts.template}`));
288
+ const snap = snapshotTemplate(t);
289
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
290
+ }
291
+ else if (opts.room !== false) {
292
+ const defaultTpl = cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'team';
293
+ const t = resolveTemplate(defaultTpl, allTemplates(cfg));
294
+ if (t) {
295
+ const snap = snapshotTemplate(t);
296
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
297
+ }
298
+ }
299
+ let brief = opts.brief;
300
+ if (opts.briefFile) {
301
+ brief = readFileSync(opts.briefFile, 'utf8');
302
+ }
303
+ const origin = { type: 'cli' };
304
+ let record = createTask({
305
+ title: opts.title,
306
+ brief,
307
+ brief_file: opts.briefFile,
308
+ template: templateRef,
309
+ origin,
310
+ idempotency_key: opts.idempotencyKey,
311
+ start: !opts.backlog,
312
+ });
313
+ if (!opts.backlog && opts.room !== false && templateRef && !record.room_id) {
314
+ const template = resolveRoomTemplate(cfg, templateRef.name);
315
+ try {
316
+ await provisionRoom(cfg, {
317
+ name: opts.title,
318
+ goal: opts.title,
319
+ brief,
320
+ template,
321
+ taskId: record.task_id,
322
+ onCreated: room => {
323
+ record = updateTaskRoom(record.task_id, room.room_id, room.room_identity_cid);
324
+ },
325
+ });
326
+ record = getTask(record.task_id);
327
+ }
328
+ catch (error) {
329
+ if (error instanceof CoworkUnavailableError) {
330
+ blockTask(record.task_id, 'Cowork management socket is unavailable');
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+ if (opts.json) {
336
+ console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
337
+ return;
338
+ }
339
+ console.log(`Task ${record.task_id} created · ${record.state}`);
340
+ if (templateRef)
341
+ console.log(`Template: ${templateRef.name}@${templateRef.version}`);
342
+ }
343
+ catch (e) {
344
+ die(e);
345
+ }
346
+ });
347
+ cOpt(taskCmd.command('list'))
348
+ .description('list tasks')
349
+ .option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
350
+ .option('--json', 'JSON output')
351
+ .action((opts) => {
352
+ try {
353
+ let stateFilter;
354
+ if (opts.state && opts.state !== 'all') {
355
+ stateFilter = opts.state;
356
+ }
357
+ const tasks = listTasks(stateFilter ? { state: stateFilter } : undefined);
358
+ if (opts.json) {
359
+ console.log(JSON.stringify({ schema_version: 1, tasks }, null, 2));
360
+ return;
361
+ }
362
+ if (!tasks.length) {
363
+ console.log('No tasks.');
364
+ return;
365
+ }
366
+ for (const t of tasks) {
367
+ const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
368
+ console.log(`${t.task_id} ${t.state}${blocked} ${t.title}`);
369
+ }
370
+ }
371
+ catch (e) {
372
+ die(e);
373
+ }
374
+ });
375
+ taskCmd.command('show <id>')
376
+ .description('show task details')
377
+ .option('--json', 'JSON output')
378
+ .action((id, opts) => {
379
+ try {
380
+ const t = getTask(id);
381
+ const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
382
+ if (opts.json) {
383
+ console.log(JSON.stringify({
384
+ schema_version: 1, task: t, orchestration: room ?? null,
385
+ }, null, 2));
386
+ return;
387
+ }
388
+ console.log(`Task: ${t.task_id}`);
389
+ console.log(`Title: ${t.title}`);
390
+ console.log(`State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`);
391
+ if (t.template)
392
+ console.log(`Template: ${t.template.name}@${t.template.version}`);
393
+ if (t.room_id)
394
+ console.log(`Room: ${t.room_id}`);
395
+ if (t.room_identity_cid)
396
+ console.log(`Room CID: ${t.room_identity_cid}`);
397
+ if (t.member_roles.length) {
398
+ console.log('Members:');
399
+ for (const m of t.member_roles)
400
+ console.log(` ${m.name} (${m.cowork_role}) ${m.identity_cid}`);
401
+ }
402
+ if (room)
403
+ printRoomStartupEvidence(room);
404
+ console.log(`Origin: ${t.origin.type}`);
405
+ console.log(`Created: ${t.created_at}`);
406
+ if (t.started_at)
407
+ console.log(`Started: ${t.started_at}`);
408
+ if (t.ended_at)
409
+ console.log(`Ended: ${t.ended_at}`);
410
+ if (t.outcome)
411
+ console.log(`Outcome: ${t.outcome.summary}`);
412
+ }
413
+ catch (e) {
414
+ die(e);
415
+ }
416
+ });
417
+ cOpt(taskCmd.command('start <id>'))
418
+ .description('start a backlog task')
419
+ .option('--json', 'JSON output')
420
+ .action(async (id, opts) => {
421
+ try {
422
+ const cfg = loadCfg(opts);
423
+ let t = startTask(id);
424
+ if (t.template && !t.room_id) {
425
+ const template = resolveRoomTemplate(cfg, t.template.name);
426
+ if (!template || template.content_hash !== t.template.content_hash)
427
+ throw new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`);
428
+ await provisionRoom(cfg, {
429
+ name: t.title,
430
+ goal: t.title,
431
+ brief: t.brief,
432
+ template,
433
+ taskId: t.task_id,
434
+ onCreated: room => {
435
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
436
+ },
437
+ });
438
+ t = getTask(t.task_id);
439
+ }
440
+ if (opts.json) {
441
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
442
+ return;
443
+ }
444
+ console.log(`Task ${t.task_id} · provisioning`);
445
+ }
446
+ catch (e) {
447
+ die(e);
448
+ }
449
+ });
450
+ taskCmd.command('block <id>')
451
+ .description('mark a task as blocked')
452
+ .requiredOption('--reason <reason>', 'blocking reason')
453
+ .option('--json', 'JSON output')
454
+ .action((id, opts) => {
455
+ try {
456
+ const t = blockTask(id, opts.reason);
457
+ if (opts.json) {
458
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
459
+ return;
460
+ }
461
+ console.log(`Task ${t.task_id} · blocked: ${opts.reason}`);
462
+ }
463
+ catch (e) {
464
+ die(e);
465
+ }
466
+ });
467
+ taskCmd.command('unblock <id>')
468
+ .description('unblock a task')
469
+ .option('--json', 'JSON output')
470
+ .action((id, opts) => {
471
+ try {
472
+ const t = unblockTask(id);
473
+ if (opts.json) {
474
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
475
+ return;
476
+ }
477
+ console.log(`Task ${t.task_id} · unblocked`);
478
+ }
479
+ catch (e) {
480
+ die(e);
481
+ }
482
+ });
483
+ taskCmd.command('review <id>')
484
+ .description('move a task to review')
485
+ .option('--json', 'JSON output')
486
+ .action((id, opts) => {
487
+ try {
488
+ const t = reviewTask(id);
489
+ if (opts.json) {
490
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
491
+ return;
492
+ }
493
+ console.log(`Task ${t.task_id} · review`);
494
+ }
495
+ catch (e) {
496
+ die(e);
497
+ }
498
+ });
499
+ cOpt(taskCmd.command('done <id>'))
500
+ .description('complete a task')
501
+ .option('--summary <text>', 'completion summary')
502
+ .option('--summary-file <path>', 'completion summary from file')
503
+ .option('--json', 'JSON output')
504
+ .action(async (id, opts) => {
505
+ try {
506
+ let summary = opts.summary;
507
+ if (opts.summaryFile)
508
+ summary = readFileSync(opts.summaryFile, 'utf8');
509
+ const outcome = summary ? { summary } : undefined;
510
+ const current = getTask(id);
511
+ if (TASK_TERMINAL_STATES.includes(current.state))
512
+ throw new TaskStateError(`task ${id} is already in terminal state '${current.state}'`);
513
+ if (current.state !== 'review')
514
+ throw new TaskStateError(`cannot transition from '${current.state}' to 'done'`);
515
+ let roomId;
516
+ let cfg;
517
+ if (current.room_id) {
518
+ cfg = loadCfg(opts);
519
+ const shouldClose = cfg.tasks?.close_room_on_done
520
+ ?? cfg.rooms?.defaults?.close_when_task_done
521
+ ?? false;
522
+ if (shouldClose)
523
+ roomId = current.room_id;
524
+ }
525
+ await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId, outcome });
526
+ const settled = roomId
527
+ ? await launchTaskSettleWorker(id, opts.configuration)
528
+ : { task: getTask(id), timedOut: false };
529
+ const t = settled.task;
530
+ if (opts.json) {
531
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
532
+ return;
533
+ }
534
+ if (settled.timedOut) {
535
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
536
+ return;
537
+ }
538
+ console.log(`Task ${t.task_id} · done`);
539
+ }
540
+ catch (e) {
541
+ die(e);
542
+ }
543
+ });
544
+ cOpt(taskCmd.command('cancel <id> <confirm-id>'))
545
+ .description('cancel a task (requires ID twice for confirmation)')
546
+ .option('--json', 'JSON output')
547
+ .action(async (id, confirmId, opts) => {
548
+ try {
549
+ if (id !== confirmId)
550
+ die(new Error('confirmation ID must match task ID'));
551
+ const current = getTask(id);
552
+ if (!TASK_CANCELLABLE_STATES.includes(current.state))
553
+ throw new TaskStateError(`cannot cancel a '${current.state}' task`);
554
+ if (current.room_id)
555
+ loadCfg(opts);
556
+ await acceptTaskTerminalIntent({
557
+ taskId: id, kind: 'cancelled', roomId: current.room_id,
558
+ });
559
+ const settled = current.room_id
560
+ ? await launchTaskSettleWorker(id, opts.configuration)
561
+ : { task: getTask(id), timedOut: false };
562
+ const t = settled.task;
563
+ if (opts.json) {
564
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
565
+ return;
566
+ }
567
+ if (settled.timedOut) {
568
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
569
+ return;
570
+ }
571
+ console.log(`Task ${t.task_id} · cancelled`);
572
+ }
573
+ catch (e) {
574
+ die(e);
575
+ }
576
+ });
577
+ cOpt(taskCmd.command('recover <id>'))
578
+ .description('attempt to recover a stuck task')
579
+ .option('--json', 'JSON output')
580
+ .action(async (id, opts) => {
581
+ try {
582
+ const cfg = loadCfg(opts);
583
+ let t = getTask(id);
584
+ let terminalTimedOut = false;
585
+ if (t.terminal_intent?.status === 'pending') {
586
+ const settled = await launchTaskSettleWorker(id, opts.configuration);
587
+ t = settled.task;
588
+ terminalTimedOut = settled.timedOut;
589
+ }
590
+ let room = t.room_id ? getRoomRecord(t.room_id) : undefined;
591
+ const result = {
592
+ task: t,
593
+ room: room ?? null,
594
+ recovery_actions: [],
595
+ };
596
+ if (terminalTimedOut) {
597
+ result.recovery_actions.push(`Terminal intent remains pending — retry task recover ${id}`);
598
+ }
599
+ if (t.state === 'provisioning' && room) {
600
+ if (room.provisioning_detail === 'waiting_cowork')
601
+ result.recovery_actions.push('Cowork management socket unreachable — check ours-cowork service');
602
+ if (room.provisioning_detail === 'waiting_owner_invite')
603
+ result.recovery_actions.push('Owner invite invalid or expired — rotate rooms.owner.public_invite in config');
604
+ if (room.provisioning_detail === 'owner_cid_mismatch')
605
+ result.recovery_actions.push('Owner CID mismatch — verify rooms.owner.expected_cid matches Messenger identity');
606
+ if (room.provisioning_detail === 'member_failed')
607
+ result.recovery_actions.push(`Member creation failed at saga step ${room.saga.step_index} — inspect and retry`);
608
+ if (room.provisioning_detail === 'waiting_briefing_acks')
609
+ result.recovery_actions.push('Members are not ready — inspect per-seat briefing and ACK evidence, then retry recovery');
610
+ if (room.provisioning_detail === 'briefing_delivery_failed')
611
+ result.recovery_actions.push('Cowork briefing relay failed terminally — replace the seat or use a Cowork-supported redelivery');
612
+ const resumable = ['create_members', 'configure_briefings', 'join_role_groups', 'wait_seats',
613
+ 'launch_work', 'wait_briefing_acks', 'activate'];
614
+ if (resumable.includes(room.saga.phase)) {
615
+ const template = durableTaskRoomTemplate(t, room);
616
+ if (template) {
617
+ try {
618
+ await provisionMembers({
619
+ cfg,
620
+ cowork: coworkFor(cfg),
621
+ roomId: room.room_id,
622
+ taskId: t.task_id,
623
+ template,
624
+ binPath: getBinPath(),
625
+ brief: t.brief,
626
+ goal: t.title,
627
+ });
628
+ t = getTask(t.task_id);
629
+ room = getRoomRecord(room.room_id);
630
+ result.task = t;
631
+ result.room = room ?? null;
632
+ result.recovery_actions = ['Provisioning resumed successfully'];
633
+ }
634
+ catch (error) {
635
+ result.recovery_actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
636
+ }
637
+ }
638
+ }
639
+ }
640
+ if (opts.json) {
641
+ console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
642
+ return;
643
+ }
644
+ console.log(`Task ${t.task_id} · ${t.state}`);
645
+ if (room)
646
+ console.log(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
647
+ if (result.recovery_actions.length) {
648
+ console.log('Recovery actions:');
649
+ for (const a of result.recovery_actions)
650
+ console.log(` - ${a}`);
651
+ }
652
+ else {
653
+ console.log('No automated recovery actions available.');
654
+ }
655
+ }
656
+ catch (e) {
657
+ die(e);
658
+ }
659
+ });
660
+ cOpt(taskCmd.command('_settle <id>', { hidden: true }))
661
+ .description('internal: settle a previously accepted task terminal intent')
662
+ .option('--json', 'JSON output')
663
+ .action(async (id, opts) => {
664
+ try {
665
+ const cfg = loadCfg(opts);
666
+ const t = await settleTaskTerminalIntent({ taskId: id, cowork: coworkFor(cfg) });
667
+ if (opts.json) {
668
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
669
+ return;
670
+ }
671
+ console.log(`Task ${t.task_id} · ${t.state}`);
672
+ }
673
+ catch (e) {
674
+ await recordTaskTerminalIntentError(id, errorText(e), `External settle worker failed. Retry task recover ${id}.`).catch(() => { });
675
+ die(e);
676
+ }
677
+ });
678
+ cOpt(taskCmd.command('work <id>'))
679
+ .description('ensure a task has a room and agents running')
680
+ .option('--template <name>', 'room template')
681
+ .option('--json', 'JSON output')
682
+ .action(async (id, opts) => {
683
+ try {
684
+ const cfg = loadCfg(opts);
685
+ let t = getTask(id);
686
+ if (TASK_TERMINAL_STATES.includes(t.state))
687
+ die(new Error(`task ${id} is in terminal state '${t.state}'`));
688
+ if (t.state === 'active' && t.room_id) {
689
+ // An explicit --template must agree with the room the task already
690
+ // runs in; a conflicting override is an error, not a silent no-op.
691
+ if (opts.template) {
692
+ const override = resolveTemplate(opts.template, allTemplates(cfg));
693
+ if (!override)
694
+ die(new Error(`template not found: ${opts.template}`));
695
+ const overrideSnap = snapshotTemplate(override);
696
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
697
+ if (roomSnap && (roomSnap.name !== overrideSnap.name || roomSnap.content_hash !== overrideSnap.content_hash))
698
+ die(new Error(`template ${overrideSnap.name}@${overrideSnap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
699
+ }
700
+ if (opts.json) {
701
+ console.log(JSON.stringify({ schema_version: 1, task: t, status: 'already_active' }, null, 2));
702
+ return;
703
+ }
704
+ console.log('Task already has an active room');
705
+ return;
706
+ }
707
+ // Select and validate the template snapshot before any state change or
708
+ // provisioning. An explicit --template re-pins the task; a stored ref
709
+ // must still match its snapshot (same contract as `task start`).
710
+ const existingRoom = t.room_id ? getRoomRecord(t.room_id) : undefined;
711
+ const durableSnapshot = existingRoom ? durableTaskRoomTemplate(t, existingRoom) : undefined;
712
+ const templateName = opts.template
713
+ ?? durableSnapshot?.name
714
+ ?? t.template?.name
715
+ ?? cfg.tasks?.default_room_template
716
+ ?? cfg.rooms?.defaults?.template
717
+ ?? 'single';
718
+ const resolved = durableSnapshot && !opts.template
719
+ ? undefined : resolveTemplate(templateName, allTemplates(cfg));
720
+ if (!durableSnapshot && !resolved)
721
+ die(new Error(`template not found: ${templateName}`));
722
+ if (opts.template && !resolved)
723
+ die(new Error(`template not found: ${templateName}`));
724
+ if (durableSnapshot && resolved) {
725
+ const requested = snapshotTemplate(resolved);
726
+ if (requested.name !== durableSnapshot.name
727
+ || requested.content_hash !== durableSnapshot.content_hash) {
728
+ die(new Error(`template ${requested.name}@${requested.version} does not match room ${existingRoom.room_id}'s provisioned template ${durableSnapshot.name}@${durableSnapshot.version}`));
729
+ }
730
+ }
731
+ const snap = durableSnapshot ?? snapshotTemplate(resolved);
732
+ if (!opts.template && t.template && snap.content_hash !== t.template.content_hash)
733
+ die(new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`));
734
+ // A provisioned room is pinned to its snapshot: never resume or re-pin
735
+ // an existing room under a different template.
736
+ if (t.room_id) {
737
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot;
738
+ if (roomSnap && (roomSnap.name !== snap.name || roomSnap.content_hash !== snap.content_hash))
739
+ die(new Error(`template ${snap.name}@${snap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
740
+ }
741
+ let templateRef = t.template;
742
+ if (!templateRef || templateRef.name !== snap.name || templateRef.content_hash !== snap.content_hash) {
743
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
744
+ t = updateTaskTemplate(t.task_id, templateRef);
745
+ }
746
+ if (t.state === 'backlog') {
747
+ t = startTask(id);
748
+ }
749
+ if (!t.room_id) {
750
+ try {
751
+ await provisionRoom(cfg, {
752
+ name: t.title,
753
+ goal: t.title,
754
+ brief: t.brief,
755
+ template: snap,
756
+ taskId: t.task_id,
757
+ onCreated: room => {
758
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
759
+ },
760
+ });
761
+ t = getTask(t.task_id);
762
+ }
763
+ catch (error) {
764
+ if (error instanceof CoworkUnavailableError) {
765
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
766
+ }
767
+ throw error;
768
+ }
769
+ }
770
+ else if (t.state === 'provisioning') {
771
+ // The task already has a room mid-provisioning (e.g. seats were still
772
+ // pending on the last run). Resume it exactly as `task recover` does.
773
+ const room = getRoomRecord(t.room_id);
774
+ const resumable = ['create_members', 'configure_briefings', 'join_role_groups', 'wait_seats',
775
+ 'launch_work', 'wait_briefing_acks', 'activate'];
776
+ if (room && resumable.includes(room.saga.phase)) {
777
+ try {
778
+ await provisionMembers({
779
+ cfg,
780
+ cowork: coworkFor(cfg),
781
+ roomId: room.room_id,
782
+ taskId: t.task_id,
783
+ template: snap,
784
+ binPath: getBinPath(),
785
+ brief: t.brief,
786
+ goal: t.title,
787
+ });
788
+ }
789
+ catch (error) {
790
+ if (error instanceof CoworkUnavailableError) {
791
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
792
+ }
793
+ throw error;
794
+ }
795
+ t = getTask(t.task_id);
796
+ }
797
+ else {
798
+ die(new Error(`task ${id} has room ${t.room_id} in a non-resumable state — run 'task recover ${id}'`));
799
+ }
800
+ }
801
+ if (opts.json) {
802
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
803
+ return;
804
+ }
805
+ console.log(`Task ${t.task_id} · ${t.state}`);
806
+ if (templateRef)
807
+ console.log(`Template: ${templateRef.name}@${templateRef.version}`);
808
+ if (t.room_id)
809
+ console.log(`Room: ${t.room_id}`);
810
+ if (t.member_roles.length) {
811
+ console.log('Agents:');
812
+ for (const m of t.member_roles)
813
+ console.log(` ${m.name} (${m.cowork_role})`);
814
+ }
815
+ }
816
+ catch (e) {
817
+ die(e);
818
+ }
819
+ });
820
+ cOpt(taskCmd.command('finish <id>'))
821
+ .description('finish a task: transition to done and close its room')
822
+ .option('--summary <text>', 'completion summary')
823
+ .option('--summary-file <path>', 'completion summary from file')
824
+ .option('--json', 'JSON output')
825
+ .action(async (id, opts) => {
826
+ try {
827
+ let summary = opts.summary;
828
+ if (opts.summaryFile)
829
+ summary = readFileSync(opts.summaryFile, 'utf8');
830
+ const outcome = summary ? { summary } : undefined;
831
+ let t = getTask(id);
832
+ if (TASK_TERMINAL_STATES.includes(t.state))
833
+ die(new Error(`task ${id} is already in terminal state '${t.state}'`));
834
+ if (t.state === 'active') {
835
+ reviewTask(id);
836
+ }
837
+ if (t.room_id)
838
+ loadCfg(opts); // validate configuration before durable acceptance
839
+ await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId: t.room_id, outcome });
840
+ const settled = t.room_id
841
+ ? await launchTaskSettleWorker(id, opts.configuration)
842
+ : { task: getTask(id), timedOut: false };
843
+ t = settled.task;
844
+ if (opts.json) {
845
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
846
+ return;
847
+ }
848
+ if (settled.timedOut) {
849
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
850
+ return;
851
+ }
852
+ console.log(`Task ${t.task_id} · done`);
853
+ }
854
+ catch (e) {
855
+ die(e);
856
+ }
857
+ });
858
+ }
859
+ export function registerRoomCommands(parent, cOpt) {
860
+ const roomCmd = parent.command('room').description('room orchestration operations');
861
+ cOpt(roomCmd.command('create'))
862
+ .description('create a standalone room')
863
+ .requiredOption('--name <name>', 'room name')
864
+ .option('--template <name>', 'room template')
865
+ .option('--goal <text>', 'room goal')
866
+ .option('--brief <text>', 'room briefing')
867
+ .option('--brief-file <path>', 'room briefing from file')
868
+ .option('--json', 'JSON output')
869
+ .action(async (opts) => {
870
+ try {
871
+ const cfg = loadCfg(opts);
872
+ let brief = opts.brief;
873
+ if (opts.briefFile)
874
+ brief = readFileSync(opts.briefFile, 'utf8');
875
+ let templateSnapshot;
876
+ if (opts.template) {
877
+ const t = resolveTemplate(opts.template, allTemplates(cfg));
878
+ if (!t)
879
+ die(new Error(`template not found: ${opts.template}`));
880
+ templateSnapshot = snapshotTemplate(t);
881
+ }
882
+ const record = await provisionRoom(cfg, {
883
+ name: opts.name,
884
+ goal: opts.goal,
885
+ brief,
886
+ template: templateSnapshot,
887
+ });
888
+ if (opts.json) {
889
+ console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
890
+ return;
891
+ }
892
+ console.log(`Room ${record.room_id} created · ${record.state}`);
893
+ if (templateSnapshot)
894
+ console.log(`Template: ${templateSnapshot.name}@${templateSnapshot.version}`);
895
+ }
896
+ catch (e) {
897
+ die(e);
898
+ }
899
+ });
900
+ cOpt(roomCmd.command('list'))
901
+ .description('list rooms')
902
+ .option('--state <state>', 'filter by state (active|provisioning|closing|closed|all)')
903
+ .option('--json', 'JSON output')
904
+ .action(async (opts) => {
905
+ try {
906
+ const cfg = loadCfg(opts);
907
+ let stateFilter;
908
+ if (opts.state && opts.state !== 'all') {
909
+ stateFilter = opts.state;
910
+ }
911
+ const local = new Map(listRoomRecords().map(room => [room.room_id, room]));
912
+ const coworkRooms = await coworkFor(cfg).listRooms();
913
+ const rooms = coworkRooms
914
+ .filter(room => !stateFilter || room.state === stateFilter)
915
+ .map(room => ({ ...room, orchestration: local.get(room.room_id) ?? null }));
916
+ if (opts.json) {
917
+ console.log(JSON.stringify({ schema_version: 1, rooms }, null, 2));
918
+ return;
919
+ }
920
+ if (!rooms.length) {
921
+ console.log('No rooms.');
922
+ return;
923
+ }
924
+ for (const r of rooms)
925
+ console.log(`${r.room_id} ${r.state} ${r.room_name}${r.orchestration?.task_id ? ` (task: ${r.orchestration.task_id})` : ''}`);
926
+ }
927
+ catch (e) {
928
+ die(e);
929
+ }
930
+ });
931
+ cOpt(roomCmd.command('show <id>'))
932
+ .description('show room details')
933
+ .option('--json', 'JSON output')
934
+ .action(async (id, opts) => {
935
+ try {
936
+ const cfg = loadCfg(opts);
937
+ const cowork = await coworkFor(cfg).getRoom(id);
938
+ if (!cowork)
939
+ die(new Error(`room not found: ${id}`));
940
+ const r = getRoomRecord(id);
941
+ if (opts.json) {
942
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null }, null, 2));
943
+ return;
944
+ }
945
+ console.log(`Room: ${cowork.room_id}`);
946
+ console.log(`Name: ${cowork.room_name}`);
947
+ console.log(`State: ${cowork.state}`);
948
+ console.log(`Identity CID: ${cowork.identity_cid}`);
949
+ if (r?.task_id)
950
+ console.log(`Task: ${r.task_id}`);
951
+ if (r)
952
+ console.log(`Saga: ${r.saga.phase} (step ${r.saga.step_index})`);
953
+ if (r?.provisioning_detail)
954
+ console.log(`Detail: ${r.provisioning_detail}`);
955
+ if (r?.saga.error)
956
+ console.log(`Error: ${r.saga.error}`);
957
+ if (cowork.seats.length) {
958
+ console.log('Members:');
959
+ for (const s of cowork.seats)
960
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
961
+ }
962
+ if (r)
963
+ printRoomStartupEvidence(r);
964
+ if (r)
965
+ console.log(`Tracked by Fleet since: ${r.created_at}`);
966
+ }
967
+ catch (e) {
968
+ die(e);
969
+ }
970
+ });
971
+ cOpt(roomCmd.command('open <id>'))
972
+ .description('open room in Cowork local console')
973
+ .option('--json', 'JSON output')
974
+ .action(async (id, opts) => {
975
+ try {
976
+ const cfg = loadCfg(opts);
977
+ const room = await coworkFor(cfg).getRoom(id);
978
+ if (!room)
979
+ die(new Error(`room not found: ${id}`));
980
+ const url = `http://localhost:4460/room/${id}`;
981
+ if (opts.json) {
982
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, url, room_name: room.room_name }, null, 2));
983
+ return;
984
+ }
985
+ console.log(`Room ${id} — ${room.room_name}`);
986
+ console.log(`Local console: ${url}`);
987
+ }
988
+ catch (e) {
989
+ die(e);
990
+ }
991
+ });
992
+ cOpt(roomCmd.command('members <id>'))
993
+ .description('show room members')
994
+ .option('--json', 'JSON output')
995
+ .action(async (id, opts) => {
996
+ try {
997
+ const cfg = loadCfg(opts);
998
+ const r = getRoomRecord(id);
999
+ const members = await coworkFor(cfg).getSeats(id);
1000
+ if (opts.json) {
1001
+ console.log(JSON.stringify({
1002
+ schema_version: 1,
1003
+ room_id: id,
1004
+ members,
1005
+ owner_seat_cid: r?.owner_seat_cid ?? null,
1006
+ }, null, 2));
1007
+ return;
1008
+ }
1009
+ console.log(`Room ${id}${r ? ` — ${r.room_name}` : ''}`);
1010
+ if (r?.owner_seat_cid)
1011
+ console.log(`Owner: ${r.owner_seat_cid}`);
1012
+ if (!members.length) {
1013
+ console.log('No members.');
1014
+ return;
1015
+ }
1016
+ for (const s of members)
1017
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
1018
+ }
1019
+ catch (e) {
1020
+ die(e);
1021
+ }
1022
+ });
1023
+ cOpt(roomCmd.command('close <id> <confirm-id>'))
1024
+ .description('close a room (requires ID twice for confirmation)')
1025
+ .option('--json', 'JSON output')
1026
+ .action(async (id, confirmId, opts) => {
1027
+ try {
1028
+ if (id !== confirmId)
1029
+ die(new Error('confirmation ID must match room ID'));
1030
+ coworkFor(loadCfg(opts));
1031
+ const existing = getRoomRecord(id);
1032
+ if (!existing)
1033
+ throw new Error(`room not found in Fleet orchestration: ${id}`);
1034
+ await acceptManagedRoomClose(id);
1035
+ const settled = await launchRoomCloseWorker(id, opts.configuration);
1036
+ const r = settled.room;
1037
+ if (opts.json) {
1038
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
1039
+ return;
1040
+ }
1041
+ if (settled.timedOut) {
1042
+ console.log(`Room ${id} · closing (accepted/pending; run room recover ${id})`);
1043
+ return;
1044
+ }
1045
+ console.log(`Room ${id} · closed`);
1046
+ }
1047
+ catch (e) {
1048
+ die(e);
1049
+ }
1050
+ });
1051
+ cOpt(roomCmd.command('recover <id>'))
1052
+ .description('attempt to recover a stuck room')
1053
+ .option('--json', 'JSON output')
1054
+ .action(async (id, opts) => {
1055
+ try {
1056
+ const cfg = loadCfg(opts);
1057
+ const adapter = coworkFor(cfg);
1058
+ let r = getRoomRecord(id);
1059
+ if (r?.state === 'closing') {
1060
+ const settled = await launchRoomCloseWorker(id, opts.configuration);
1061
+ r = settled.room;
1062
+ const cowork = r.state === 'closed' ? await adapter.getRoom(id) : undefined;
1063
+ if (opts.json) {
1064
+ console.log(JSON.stringify({
1065
+ schema_version: 1, room: cowork ?? null, orchestration: r,
1066
+ recovery_actions: [settled.timedOut
1067
+ ? `Closing remains pending — retry room recover ${id}`
1068
+ : 'Closing saga resumed successfully'],
1069
+ }, null, 2));
1070
+ return;
1071
+ }
1072
+ console.log(`Room ${id} · ${r.state} · close: ${r.close?.phase ?? 'unknown'}`);
1073
+ return;
1074
+ }
1075
+ const cowork = await adapter.recoverRoom(id);
1076
+ r = getRoomRecord(id);
1077
+ if (r && !r.owner_seat_cid
1078
+ && (r.provisioning_detail === 'waiting_owner_invite'
1079
+ || r.provisioning_detail === 'owner_cid_mismatch')) {
1080
+ const expected = cfg.rooms.owner.expected_cid.toLowerCase();
1081
+ const existing = (await adapter.getSeats(id))
1082
+ .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
1083
+ if (!existing && !cfg.ownerInvite)
1084
+ throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
1085
+ let acceptedCid = existing?.identity_cid;
1086
+ if (!acceptedCid) {
1087
+ const accepted = await adapter.acceptInvite(id, cfg.ownerInvite, {
1088
+ role: cfg.rooms.owner.role,
1089
+ expected_cid: cfg.rooms.owner.expected_cid,
1090
+ });
1091
+ acceptedCid = accepted.seat_cid;
1092
+ }
1093
+ setOwnerSeat(id, acceptedCid, cfg.ownerInviteFingerprint ?? '');
1094
+ r = advanceSaga(id, 'create_members', 3);
1095
+ }
1096
+ const actions = [];
1097
+ if (r?.saga.error)
1098
+ actions.push(`Last error: ${r.saga.error}`);
1099
+ if (r?.saga.recovery_hint)
1100
+ actions.push(r.saga.recovery_hint);
1101
+ if (r?.provisioning_detail === 'waiting_cowork')
1102
+ actions.push('Check ours-cowork service status');
1103
+ if (r?.provisioning_detail === 'waiting_owner_invite')
1104
+ actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
1105
+ if (r?.provisioning_detail === 'waiting_briefing_acks')
1106
+ actions.push('Inspect per-seat briefing and ACK evidence, then re-run recover');
1107
+ if (r?.provisioning_detail === 'briefing_delivery_failed')
1108
+ actions.push('Replace the failed seat or use a Cowork-supported briefing redelivery');
1109
+ if (r && r.state === 'provisioning') {
1110
+ const resumable = ['create_members', 'configure_briefings', 'join_role_groups', 'wait_seats',
1111
+ 'launch_work', 'wait_briefing_acks', 'activate'];
1112
+ if (resumable.includes(r.saga.phase) && r.template_snapshot) {
1113
+ try {
1114
+ const template = r.task_id
1115
+ ? durableTaskRoomTemplate(getTask(r.task_id), r)
1116
+ : r.template_snapshot;
1117
+ if (template) {
1118
+ await provisionMembers({
1119
+ cfg,
1120
+ cowork: adapter,
1121
+ roomId: r.room_id,
1122
+ taskId: r.task_id,
1123
+ template,
1124
+ binPath: getBinPath(),
1125
+ goal: r.room_name,
1126
+ });
1127
+ r = getRoomRecord(id);
1128
+ actions.splice(0, actions.length, 'Provisioning resumed successfully');
1129
+ }
1130
+ }
1131
+ catch (error) {
1132
+ actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
1133
+ }
1134
+ }
1135
+ }
1136
+ if (opts.json) {
1137
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
1138
+ return;
1139
+ }
1140
+ console.log(`Room ${cowork.room_id} · ${cowork.state}${r ? ` · saga: ${r.saga.phase}` : ''}`);
1141
+ if (actions.length) {
1142
+ console.log('Recovery:');
1143
+ for (const a of actions)
1144
+ console.log(` - ${a}`);
1145
+ }
1146
+ else {
1147
+ console.log('No recovery actions needed.');
1148
+ }
1149
+ }
1150
+ catch (e) {
1151
+ die(e);
1152
+ }
1153
+ });
1154
+ cOpt(roomCmd.command('_close <id>', { hidden: true }))
1155
+ .description('internal: settle an accepted room close')
1156
+ .option('--json', 'JSON output')
1157
+ .action(async (id, opts) => {
1158
+ try {
1159
+ const cfg = loadCfg(opts);
1160
+ const r = await closeManagedRoom({ roomId: id, cowork: coworkFor(cfg) });
1161
+ if (opts.json) {
1162
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
1163
+ return;
1164
+ }
1165
+ console.log(`Room ${id} · ${r.state}`);
1166
+ }
1167
+ catch (e) {
1168
+ await recordManagedRoomCloseError(id, errorText(e), `External close worker failed. Retry room recover ${id}.`).catch(() => { });
1169
+ die(e);
1170
+ }
1171
+ });
1172
+ }