@ours.network/fleet 1.1.0-nightly.27 → 1.1.0-nightly.29

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/README.md CHANGED
@@ -609,20 +609,18 @@ live Fleet launch, and the configured Owner seat is active when owner attachment
609
609
  enabled. There is no briefing hash, startup ACK, or separate role-briefing readiness
610
610
  gate.
611
611
 
612
- Normal provisioning emits exactly two authenticated Owner lifecycle notices: a
613
- concise created notice immediately after the Cowork room is visible, and
614
- `The room <name> is ready.` after the ready predicate above is true. Intermediate
615
- task, saga, member-spawn, timeout, and recoverable-failure transitions stay in local
616
- state and logs. A terminal failed task emits one actionable failure notice; the
617
- command result carries the exact blocker and canonical recovery action.
612
+ Normal Task provisioning emits exactly one authenticated Owner lifecycle notice after
613
+ the ready predicate above is true; standalone Room provisioning likewise emits one
614
+ ready notice. Intermediate task, saga, member-spawn, timeout, and recoverable-failure
615
+ transitions stay in local state and logs. A terminal failed Task emits one actionable
616
+ failure notice; the command result carries the exact blocker and canonical recovery
617
+ action.
618
618
 
619
619
  `task start` and create-and-start wait for readiness. If their bounded wait expires,
620
- they return an explicit `in_progress` result with the stable
621
- `ours-fleet task await <id>` handle and start a safe continuation. `task await`
622
- waits on that same durable operation and returns `ready`, `in_progress`, or
623
- `failed` in both human and `--json` forms; timeout is not failure. The detached
624
- continuation is serialized per task and remains alive until convergence or an
625
- Owner-action blocker; invoking `task await` safely re-arms it after a restart.
620
+ they return an explicit `in_progress` result and start a safe continuation. The detached
621
+ continuation is serialized per Task and remains alive until convergence or an
622
+ Owner-action blocker. Re-running `task start <id>` safely resumes the same durable
623
+ provisioning operation after the blocker is corrected or a process restarts.
626
624
 
627
625
  Set `room.anonymous: true` on a room template, or pass `--anonymous` to
628
626
  `task create`, `task start`, `task work`, or `room create`, to create an
@@ -21,8 +21,8 @@ export interface TaskSettlementPlan {
21
21
  task: TaskRecord;
22
22
  settlementRequired: boolean;
23
23
  }
24
- export type TaskRecoveryIssue = {
25
- code: 'terminal_pending' | 'waiting_cowork' | 'waiting_owner_invite' | 'owner_cid_mismatch' | 'waiting_seats' | 'provisioning_resumed';
24
+ export type TaskProvisioningContinuationIssue = {
25
+ code: 'waiting_cowork' | 'waiting_owner_authorization' | 'waiting_owner_invite' | 'owner_cid_mismatch' | 'waiting_seats' | 'provisioning_resumed';
26
26
  } | {
27
27
  code: 'member_failed';
28
28
  stepIndex: number;
@@ -30,21 +30,17 @@ export type TaskRecoveryIssue = {
30
30
  code: 'resume_failed';
31
31
  error: string;
32
32
  };
33
- export interface TaskRecoveryResult {
34
- kind: 'provisioning_resumed' | 'provisioning_resume_failed' | 'provisioning_non_resumable' | 'terminal' | 'no_op';
33
+ export interface TaskProvisioningContinuationResult {
34
+ kind: 'provisioning_resumed' | 'provisioning_resume_failed' | 'provisioning_non_resumable' | 'no_op';
35
35
  task: TaskRecord;
36
36
  room: RoomOrchestrationRecord | undefined;
37
- issues: TaskRecoveryIssue[];
37
+ issues: TaskProvisioningContinuationIssue[];
38
38
  reason?: 'missing_room' | 'missing_durable_template' | 'non_resumable_phase';
39
39
  }
40
40
  export interface TaskProvisioningOutcome {
41
41
  kind: 'ready' | 'in_progress' | 'failed';
42
42
  task: TaskRecord;
43
43
  room?: RoomOrchestrationRecord;
44
- handle: {
45
- command: string;
46
- task_id: string;
47
- };
48
44
  launch: {
49
45
  template?: string;
50
46
  anonymous: boolean;
@@ -60,16 +56,6 @@ export interface TaskProvisioningOutcome {
60
56
  }
61
57
  /** Repairable terminal observation derived entirely from durable Task/Room state. */
62
58
  export declare function recordTaskProvisioningOutcome(outcome: TaskProvisioningOutcome): void;
63
- export type TaskRecoveryBegin = {
64
- kind: 'terminal_worker_required';
65
- taskId: string;
66
- } | {
67
- kind: 'deletion_worker_required';
68
- taskId: string;
69
- } | {
70
- kind: 'final';
71
- result: TaskRecoveryResult;
72
- };
73
59
  export declare class TaskRoomApplicationError extends Error {
74
60
  readonly code: 'template_not_found' | 'task_template_drift' | 'template_mismatch' | 'task_terminal' | 'task_terminal_already' | 'task_non_resumable' | 'task_deleting' | 'room_not_found' | 'room_record_not_found';
75
61
  readonly fields: Readonly<Record<string, string>>;
@@ -114,7 +100,6 @@ export interface TaskRoomServiceDeps {
114
100
  export declare class TaskRoomApplicationService {
115
101
  private readonly configurationPath?;
116
102
  private readonly deps;
117
- private recovery?;
118
103
  constructor(configurationPath?: string | undefined, deps?: TaskRoomServiceDeps);
119
104
  createTask(request: CreateTaskRequest): Promise<TaskRecord>;
120
105
  createRoom(request: CreateRoomRequest): Promise<RoomOrchestrationRecord>;
@@ -241,15 +226,14 @@ export declare class TaskRoomApplicationService {
241
226
  error: string;
242
227
  recoveryHint: string;
243
228
  }): Promise<TaskRecord>;
244
- beginTaskRecovery(input: {
245
- actor: TaskRoomActor;
246
- taskId: string;
247
- }): Promise<TaskRecoveryBegin>;
248
- continueTaskRecovery(input: {
249
- actor: TaskRoomActor;
229
+ continueTaskProvisioning(input: {
230
+ actor: {
231
+ kind: 'internal_worker';
232
+ surface: 'cli';
233
+ };
250
234
  taskId: string;
251
- terminalTimedOut: boolean;
252
- }): Promise<TaskRecoveryResult>;
235
+ }): Promise<TaskProvisioningContinuationResult>;
236
+ private reconcileProvisioningOwner;
253
237
  private acceptTerminal;
254
238
  listTemplates(): import("../rooms-tasks/types.js").TemplateDefinition[];
255
239
  getTemplate(name: string): {
@@ -310,18 +294,6 @@ export declare class TaskRoomApplicationService {
310
294
  error: string;
311
295
  recoveryHint: string;
312
296
  }): Promise<RoomOrchestrationRecord>;
313
- recoverRoom(input: {
314
- actor: TaskRoomActor;
315
- roomId: string;
316
- }): Promise<{
317
- kind: 'deletion_worker_required';
318
- roomId: string;
319
- } | {
320
- kind: 'recovered' | 'provisioning_resumed' | 'provisioning_resume_failed';
321
- room: Awaited<ReturnType<CoworkAdapter['recoverRoom']>>;
322
- orchestration: RoomOrchestrationRecord | undefined;
323
- issues: string[];
324
- }>;
325
297
  finishTask(input: {
326
298
  actor: TaskRoomActor;
327
299
  taskId: string;
@@ -8,7 +8,7 @@ import { createTaskListLocked, DEFAULT_TASK_LIST_ID, deleteTaskListRecordLocked,
8
8
  import { hashTemplate, listTemplates, resolveTemplate, sealTemplateSnapshot, snapshotTemplate } from '../rooms-tasks/templates.js';
9
9
  import { acquireLaunchSnapshotLock, releaseLaunchSnapshot } from '../rooms-tasks/launch-snapshot.js';
10
10
  import { hashMemberOverrides, prepareExecutionPlan } from '../rooms-tasks/member-overrides.js';
11
- import { checkpointFleetAuditPresentations, recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
11
+ import { recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
12
12
  import { acceptManagedRoomClose, deleteLegacyClosedRooms, deleteManagedRoom, recordManagedRoomCloseError, } from '../rooms-tasks/close.js';
13
13
  import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath, } from '../rooms-tasks/terminal.js';
14
14
  import { acceptTaskDeletion, recordTaskDeletionError, settleTaskDeletion, } from '../rooms-tasks/deletion.js';
@@ -16,43 +16,37 @@ import { withFileLock } from '../atomic-file.js';
16
16
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
17
17
  import { storedRoomLaunchPolicy, TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
18
18
  import { deriveTaskRoomName } from '../rooms-tasks/task-room-name.js';
19
- function recordCanonicalRoomCreated(room) {
20
- recordFleetAuditPresentation({ kind: 'room', operation: 'create',
21
- eventId: `room-created:${room.created_at}`, id: room.room_id,
22
- name: room.room_name, previousState: 'none', newState: 'provisioning',
23
- revision: room.created_at, taskId: room.task_id,
24
- template: room.template_snapshot
25
- ? `${room.template_snapshot.name}@${room.template_snapshot.version}` : undefined,
26
- anonymous: storedRoomLaunchPolicy(room.room_policy).anonymous,
27
- memberCount: room.template_snapshot?.members.reduce((sum, member) => sum + member.count, 0) ?? 0,
28
- participants: [] });
29
- }
19
+ const OWNER_ROOM_COMMANDS = ['list-members', 'remove-member'];
30
20
  /** Repairable terminal observation derived entirely from durable Task/Room state. */
31
21
  export function recordTaskProvisioningOutcome(outcome) {
32
22
  const room = outcome.room;
33
23
  recordFleetAuditResource('task', outcome.task.task_id);
34
- if (room) {
24
+ if (room)
35
25
  recordFleetAuditResource('room', room.room_id);
36
- // Always restate created before a later terminal transition. The stable
37
- // digest makes this a no-op when the original checkpoint was delivered,
38
- // and repairs a crash after Room persistence but before that checkpoint.
39
- recordCanonicalRoomCreated(room);
40
- }
41
26
  if (outcome.kind === 'ready' && room)
42
27
  recordFleetAuditPresentation({
43
- kind: 'room', operation: 'activate', eventId: `room-ready:${room.activated_at ?? room.created_at}`,
44
- id: room.room_id, name: room.room_name, previousState: 'provisioning', newState: 'active',
45
- revision: room.activated_at ?? room.created_at, taskId: room.task_id,
46
- template: outcome.launch.template, anonymous: outcome.launch.anonymous,
47
- memberCount: outcome.members.expected, ownerAttached: outcome.launch.owner_attached,
48
- participants: [],
28
+ kind: 'task', operation: 'work', eventId: `task-ready:${room.activated_at ?? room.created_at}`,
29
+ id: outcome.task.task_id, title: outcome.task.title,
30
+ previousState: 'provisioning', newState: 'active', revision: room.activated_at ?? room.created_at,
31
+ list: outcome.task.list_name ?? 'default', roomId: room.room_id, roomName: room.room_name,
32
+ template: outcome.launch.template,
33
+ agents: room.member_seats.map(member => ({ name: member.role_name, role: member.cowork_role,
34
+ configuration: member.launch?.presentation })),
49
35
  });
50
36
  if (outcome.kind === 'failed')
51
37
  recordFleetAuditPresentation({
52
38
  kind: 'lifecycle_failure', resource: 'Task', id: outcome.task.task_id,
39
+ label: outcome.task.title,
53
40
  state: outcome.task.state, category: 'provision_failed',
54
41
  eventId: outcome.task.ended_at ?? room?.created_at ?? outcome.task.created_at,
55
42
  });
43
+ if (outcome.kind === 'in_progress')
44
+ recordFleetAuditPresentation({
45
+ kind: 'lifecycle_failure', resource: 'Task', id: outcome.task.task_id,
46
+ label: outcome.task.title,
47
+ state: outcome.task.state, category: 'provision_pending',
48
+ eventId: room?.created_at ?? outcome.task.created_at,
49
+ });
56
50
  }
57
51
  export class TaskRoomApplicationError extends Error {
58
52
  code;
@@ -72,7 +66,6 @@ export function resolveRoomLaunchPolicy(template, override) {
72
66
  export class TaskRoomApplicationService {
73
67
  configurationPath;
74
68
  deps;
75
- recovery;
76
69
  constructor(configurationPath, deps = {}) {
77
70
  this.configurationPath = configurationPath;
78
71
  this.deps = deps;
@@ -123,9 +116,9 @@ export class TaskRoomApplicationService {
123
116
  }
124
117
  unlock?.();
125
118
  const ref = template && { name: template.name, version: template.version, content_hash: template.content_hash };
126
- // A create-and-start launch is represented to the Owner solely by the
127
- // canonical Room created/ready events. Backlog and no-room tasks retain
128
- // their useful Task lifecycle notice.
119
+ // A successful create-and-start launch is recorded once by
120
+ // recordTaskProvisioningOutcome after the linked Room and seats are ready.
121
+ // Backlog and no-room tasks retain their direct Task lifecycle notice.
129
122
  if (request.backlog || request.noRoom)
130
123
  recordFleetAuditPresentation({ kind: 'task', operation: 'create', id: task.task_id,
131
124
  title: task.title, previousState: 'none', newState: task.state, revision: task.created_at,
@@ -145,13 +138,15 @@ export class TaskRoomApplicationService {
145
138
  persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
146
139
  // Once the durable Room exists, provisioning errors are resumable
147
140
  // saga state. Return that explicit state so the command can launch a
148
- // continuation and give the caller a stable await handle.
141
+ // continuation and report the durable in-progress outcome.
149
142
  const current = readTask(task.task_id);
150
143
  if (!current.room_id || !getRoomRecord(current.room_id))
151
144
  throw error;
152
145
  task = current;
153
146
  }
154
147
  }
148
+ if (!request.backlog && !request.noRoom && task.room_id)
149
+ recordTaskProvisioningOutcome(this.taskProvisioningOutcome(task.task_id));
155
150
  return task;
156
151
  }
157
152
  async createRoom(request) {
@@ -176,7 +171,10 @@ export class TaskRoomApplicationService {
176
171
  }, template, () => { }, undefined, roomPolicy);
177
172
  }
178
173
  async startTask(input) {
179
- return (await this.ensureTaskWork(input)).task;
174
+ const result = await this.ensureTaskWork(input);
175
+ if (result.status !== 'already_active')
176
+ recordTaskProvisioningOutcome(this.taskProvisioningOutcome(result.task.task_id));
177
+ return result.task;
180
178
  }
181
179
  listTasks(filter) {
182
180
  const listId = filter?.list === undefined ? undefined : resolveTaskList(filter.list).list_id;
@@ -235,18 +233,19 @@ export class TaskRoomApplicationService {
235
233
  const ready = task.state === 'active' && room?.state === 'active'
236
234
  && active === expected && launched === expected;
237
235
  const blocker = task.outcome?.summary ?? task.blocked?.reason ?? room?.saga.error;
238
- const nextAction = room?.provisioning_detail === 'waiting_owner_invite'
239
- ? 'Rotate rooms.owner.public_invite, then await the same task.'
240
- : room?.provisioning_detail === 'owner_cid_mismatch'
241
- ? 'Verify rooms.owner.expected_cid, rotate the Owner invite, then await the same task.'
242
- : room?.provisioning_detail === 'waiting_cowork'
243
- ? 'Restore ours-cowork, then await the same task.'
244
- : failed
245
- ? `Correct the blocker, then run ours-fleet task recover ${task.task_id}.`
246
- : undefined;
236
+ const nextAction = room?.provisioning_detail === 'waiting_owner_authorization'
237
+ ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
238
+ : room?.provisioning_detail === 'waiting_owner_invite'
239
+ ? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
240
+ : room?.provisioning_detail === 'owner_cid_mismatch'
241
+ ? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
242
+ : room?.provisioning_detail === 'waiting_cowork'
243
+ ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
244
+ : failed
245
+ ? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
246
+ : undefined;
247
247
  return {
248
248
  kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room,
249
- handle: { command: `ours-fleet task await ${task.task_id}`, task_id: task.task_id },
250
249
  launch: {
251
250
  ...(room?.template_snapshot
252
251
  ? { template: `${room.template_snapshot.name}@${room.template_snapshot.version}` }
@@ -405,40 +404,14 @@ export class TaskRoomApplicationService {
405
404
  recordSettlementError(input) {
406
405
  return recordTaskTerminalIntentError(input.taskId, input.error, input.recoveryHint);
407
406
  }
408
- async beginTaskRecovery(input) {
409
- // The deletion-vs-terminal routing decision serializes on the common
410
- // task-operation lock and must not require configuration: recovery of a
411
- // deletion-pending task continues the deletion and never resurrects.
412
- const routed = await withFileLock(taskOperationLockPath(input.taskId), () => {
413
- const task = readTask(input.taskId);
414
- if (task.deletion?.status === 'pending')
415
- return 'deletion';
416
- if (task.terminal_intent?.status === 'pending')
417
- return 'terminal';
418
- return 'none';
419
- }, {}, TASK_OPERATION_LOCK_STALE_MS);
420
- if (routed === 'deletion')
421
- return { kind: 'deletion_worker_required', taskId: input.taskId };
422
- const config = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
423
- this.recovery = { taskId: input.taskId, config };
424
- if (routed === 'terminal')
425
- return { kind: 'terminal_worker_required', taskId: input.taskId };
426
- return { kind: 'final', result: await this.continueTaskRecovery({
427
- actor: input.actor, taskId: input.taskId, terminalTimedOut: false,
428
- }) };
429
- }
430
- async continueTaskRecovery(input) {
431
- if (!this.recovery || this.recovery.taskId !== input.taskId)
432
- throw new Error('task recovery continuation requires a matching begin');
433
- const recovery = this.recovery;
434
- this.recovery = undefined;
435
- const cfg = recovery.config;
407
+ async continueTaskProvisioning(input) {
408
+ const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
436
409
  let task = readTask(input.taskId);
437
410
  let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
438
- const issues = input.terminalTimedOut ? [{ code: 'terminal_pending' }] : [];
411
+ const issues = [];
439
412
  if (task.state !== 'provisioning')
440
413
  return {
441
- kind: TASK_TERMINAL_STATES.includes(task.state) ? 'terminal' : 'no_op', task, room, issues,
414
+ kind: 'no_op', task, room, issues,
442
415
  };
443
416
  if (!room) {
444
417
  if (!task.template)
@@ -462,10 +435,12 @@ export class TaskRoomApplicationService {
462
435
  return { kind: 'provisioning_resume_failed', task, room, issues };
463
436
  }
464
437
  }
465
- if (room.provisioning_detail === 'waiting_owner_invite'
438
+ if (room.saga.phase === 'attach_owner'
439
+ || room.provisioning_detail === 'waiting_owner_authorization'
440
+ || room.provisioning_detail === 'waiting_owner_invite'
466
441
  || room.provisioning_detail === 'owner_cid_mismatch') {
467
442
  try {
468
- await this.recoverRoom({ actor: input.actor, roomId: room.room_id });
443
+ await this.reconcileProvisioningOwner(cfg, room.room_id);
469
444
  task = readTask(task.task_id);
470
445
  room = getRoomRecord(room.room_id);
471
446
  if (task.state === 'active' && room?.state === 'active')
@@ -484,6 +459,8 @@ export class TaskRoomApplicationService {
484
459
  };
485
460
  if (room.provisioning_detail === 'waiting_cowork')
486
461
  issues.push({ code: 'waiting_cowork' });
462
+ if (room.provisioning_detail === 'waiting_owner_authorization')
463
+ issues.push({ code: 'waiting_owner_authorization' });
487
464
  if (room.provisioning_detail === 'waiting_owner_invite')
488
465
  issues.push({ code: 'waiting_owner_invite' });
489
466
  if (room.provisioning_detail === 'owner_cid_mismatch')
@@ -520,6 +497,50 @@ export class TaskRoomApplicationService {
520
497
  return { kind: 'provisioning_resume_failed', task, room, issues };
521
498
  }
522
499
  }
500
+ async reconcileProvisioningOwner(cfg, roomId) {
501
+ if (!cfg.rooms)
502
+ throw new ConfigError('rooms: configuration is required before creating or querying rooms');
503
+ const cowork = this.deps.cowork ? this.deps.cowork(cfg)
504
+ : createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
505
+ const remote = await cowork.recoverRoom(roomId);
506
+ const room = getRoomRecord(roomId);
507
+ if (!room)
508
+ throw new Error(`room ${roomId} has no durable orchestration record`);
509
+ if (room.state === 'closing' || room.state === 'closed')
510
+ throw new Error(`room ${roomId} is already ${room.state}`);
511
+ const policy = storedRoomLaunchPolicy(room.room_policy);
512
+ if ((remote.anonymous ?? false) !== policy.anonymous) {
513
+ const error = `Cowork anonymity (${String(remote.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(policy.anonymous)})`;
514
+ setSagaError(roomId, error, 'Do not respawn members. Repair or upgrade Cowork, then retry the originating task.', 'waiting_cowork');
515
+ throw new Error(error);
516
+ }
517
+ if (room.owner_seat_cid) {
518
+ const ownerCid = room.owner_seat_cid.toLowerCase();
519
+ const ownerSeat = remote.seats.find(seat => seat.identity_cid.toLowerCase() === ownerCid && seat.seat_state !== 'removed');
520
+ if (ownerSeat)
521
+ await cowork.setRoleCommands(roomId, {
522
+ role: ownerSeat.role, commands: [...OWNER_ROOM_COMMANDS],
523
+ });
524
+ if (room.saga.phase === 'attach_owner')
525
+ advanceSaga(roomId, 'create_members', 3);
526
+ return;
527
+ }
528
+ const expected = cfg.rooms.owner.expected_cid?.toLowerCase();
529
+ if (!expected)
530
+ throw new ConfigError('rooms.owner.expected_cid is required before continuing Owner-seat provisioning');
531
+ const existing = (await cowork.getSeats(roomId))
532
+ .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
533
+ if (!existing && !cfg.ownerInvite)
534
+ throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before continuing task provisioning');
535
+ await cowork.setRoleCommands(roomId, {
536
+ role: existing?.role ?? cfg.rooms.owner.role, commands: [...OWNER_ROOM_COMMANDS],
537
+ });
538
+ const acceptedCid = existing?.identity_cid ?? (await cowork.acceptInvite(roomId, cfg.ownerInvite, {
539
+ role: cfg.rooms.owner.role, expected_cid: cfg.rooms.owner.expected_cid,
540
+ })).seat_cid;
541
+ setOwnerSeat(roomId, acceptedCid, cfg.ownerInviteFingerprint ?? '');
542
+ advanceSaga(roomId, 'create_members', 3);
543
+ }
523
544
  async acceptTerminal(taskId, kind, roomId, outcome) {
524
545
  const task = await acceptTaskTerminalIntent({ taskId, kind, roomId, outcome });
525
546
  return { task, settlementRequired: task.terminal_intent?.status === 'pending' && !!roomId };
@@ -612,91 +633,6 @@ export class TaskRoomApplicationService {
612
633
  recordRoomSettlementError(input) {
613
634
  return recordManagedRoomCloseError(input.roomId, input.error, input.recoveryHint);
614
635
  }
615
- async recoverRoom(input) {
616
- const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
617
- if (!cfg.rooms)
618
- throw new ConfigError('rooms: configuration is required before creating or querying rooms');
619
- const adapter = this.deps.cowork ? this.deps.cowork(cfg)
620
- : createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
621
- let orchestration = getRoomRecord(input.roomId);
622
- if (orchestration?.state === 'closing' || orchestration?.state === 'closed')
623
- return { kind: 'deletion_worker_required', roomId: input.roomId };
624
- const room = await adapter.recoverRoom(input.roomId);
625
- orchestration = getRoomRecord(input.roomId);
626
- if (orchestration) {
627
- const policy = storedRoomLaunchPolicy(orchestration.room_policy);
628
- if ((room.anonymous ?? false) !== policy.anonymous) {
629
- const error = `Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(policy.anonymous)})`;
630
- setSagaError(input.roomId, error, 'Do not respawn members. Repair or upgrade Cowork, then recover the Room without changing its policy.', 'waiting_cowork');
631
- throw new Error(error);
632
- }
633
- }
634
- if (orchestration && !orchestration.owner_seat_cid
635
- && (orchestration.provisioning_detail === 'waiting_owner_invite'
636
- || orchestration.provisioning_detail === 'owner_cid_mismatch')) {
637
- const expected = cfg.rooms.owner.expected_cid.toLowerCase();
638
- const existing = (await adapter.getSeats(input.roomId))
639
- .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
640
- if (!existing && !cfg.ownerInvite)
641
- throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
642
- let acceptedCid = existing?.identity_cid;
643
- if (!acceptedCid)
644
- acceptedCid = (await adapter.acceptInvite(input.roomId, cfg.ownerInvite, {
645
- role: cfg.rooms.owner.role, expected_cid: cfg.rooms.owner.expected_cid,
646
- })).seat_cid;
647
- setOwnerSeat(input.roomId, acceptedCid, cfg.ownerInviteFingerprint ?? '');
648
- orchestration = advanceSaga(input.roomId, 'create_members', 3);
649
- }
650
- const issues = [];
651
- if (orchestration?.saga.error)
652
- issues.push('A provisioning failure is recorded; inspect role logs for diagnostics.');
653
- if (orchestration?.saga.recovery_hint)
654
- issues.push('Recovery guidance is recorded; inspect role logs for diagnostics.');
655
- if (orchestration?.provisioning_detail === 'waiting_cowork')
656
- issues.push('Check ours-cowork service status');
657
- if (orchestration?.provisioning_detail === 'waiting_owner_invite')
658
- issues.push('Rotate rooms.owner.public_invite in config, then re-run recover');
659
- if (orchestration?.provisioning_detail === 'waiting_seats')
660
- issues.push('Inspect temporary member logs for invite acceptance, then re-run recover');
661
- if (orchestration?.state === 'provisioning'
662
- && ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'].includes(orchestration.saga.phase)
663
- && orchestration.template_snapshot) {
664
- try {
665
- let template = orchestration.template_snapshot;
666
- let brief;
667
- let goal = orchestration.room_name;
668
- if (orchestration.task_id) {
669
- const task = readTask(orchestration.task_id);
670
- if (!task.template || task.template.name !== template.name || task.template.version !== template.version
671
- || task.template.content_hash !== template.content_hash)
672
- throw new Error(`task ${task.task_id} template reference does not match room ${orchestration.room_id}'s durable snapshot`);
673
- brief = task.brief;
674
- goal = task.title;
675
- }
676
- await (this.deps.provisionMembers ?? provisionMembers)({ cfg, cowork: adapter,
677
- roomId: orchestration.room_id, taskId: orchestration.task_id, template,
678
- binPath: (this.deps.binPath ?? getBinPath)(), brief, goal });
679
- orchestration = getRoomRecord(input.roomId);
680
- return { kind: 'provisioning_resumed', room, orchestration,
681
- issues: ['Provisioning resumed successfully'] };
682
- }
683
- catch (error) {
684
- issues.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
685
- return { kind: 'provisioning_resume_failed', room, orchestration, issues };
686
- }
687
- }
688
- if (orchestration?.state === 'provisioning' && !orchestration.template_snapshot) {
689
- const seats = await adapter.getSeats(input.roomId);
690
- const ownerReady = !orchestration.owner_seat_cid || seats.some(seat => seat.identity_cid.toLowerCase() === orchestration.owner_seat_cid.toLowerCase()
691
- && seat.seat_state === 'active');
692
- if (ownerReady) {
693
- orchestration = activateRoom(input.roomId);
694
- if (orchestration.task_id)
695
- activateTask(orchestration.task_id);
696
- }
697
- }
698
- return { kind: 'recovered', room, orchestration, issues };
699
- }
700
636
  async finishTask(input) {
701
637
  let task = readTask(input.taskId);
702
638
  if (TASK_TERMINAL_STATES.includes(task.state))
@@ -747,7 +683,7 @@ export class TaskRoomApplicationService {
747
683
  }
748
684
  return { task, status: 'already_active' };
749
685
  }
750
- const room = task.room_id ? getRoomRecord(task.room_id) : undefined;
686
+ let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
751
687
  const durable = room?.template_snapshot ?? task.execution_plan?.snapshot;
752
688
  if (durable && (!task.template || task.template.name !== durable.name
753
689
  || task.template.version !== durable.version || task.template.content_hash !== durable.content_hash))
@@ -850,6 +786,21 @@ export class TaskRoomApplicationService {
850
786
  }
851
787
  }
852
788
  else if (task.state === 'provisioning') {
789
+ if (room && (room.saga.phase === 'attach_owner'
790
+ || room.provisioning_detail === 'waiting_owner_authorization'
791
+ || room.provisioning_detail === 'waiting_owner_invite'
792
+ || room.provisioning_detail === 'owner_cid_mismatch')) {
793
+ try {
794
+ await this.reconcileProvisioningOwner(cfg, room.room_id);
795
+ room = getRoomRecord(room.room_id);
796
+ }
797
+ catch (error) {
798
+ if (error instanceof CoworkUnavailableError)
799
+ persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
800
+ task = readTask(task.task_id);
801
+ return { task, status: 'in_progress' };
802
+ }
803
+ }
853
804
  if (!room || !['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'].includes(room.saga.phase))
854
805
  throw new TaskRoomApplicationError('task_non_resumable', 'task non-resumable', { task: task.task_id, room: task.room_id });
855
806
  try {
@@ -945,14 +896,19 @@ export class TaskRoomApplicationService {
945
896
  throw error;
946
897
  }
947
898
  unlockSnapshot?.();
948
- recordCanonicalRoomCreated(room);
949
- // The created notice is observable while member admission is still in
950
- // progress; it is not delayed behind task-start command completion.
951
- await checkpointFleetAuditPresentations();
952
899
  room = advanceSaga(room.room_id, 'create_room', 1);
953
900
  if (attachOwner) {
901
+ room = advanceSaga(room.room_id, 'attach_owner', 2);
902
+ try {
903
+ await cowork.setRoleCommands(room.room_id, {
904
+ role: rooms.owner.role, commands: [...OWNER_ROOM_COMMANDS],
905
+ });
906
+ }
907
+ catch (error) {
908
+ setSagaError(room.room_id, error instanceof Error ? error.message : String(error), 'Restore Cowork availability, then retry the originating task or room create command.', 'waiting_owner_authorization');
909
+ throw error;
910
+ }
954
911
  try {
955
- room = advanceSaga(room.room_id, 'attach_owner', 2);
956
912
  const accepted = await cowork.acceptInvite(room.room_id, cfg.ownerInvite, {
957
913
  role: rooms.owner.role, expected_cid: rooms.owner.expected_cid,
958
914
  });
@@ -961,7 +917,11 @@ export class TaskRoomApplicationService {
961
917
  catch (error) {
962
918
  const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
963
919
  setSagaError(room.room_id, error instanceof Error ? error.message : String(error), mismatch ? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
964
- : 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
920
+ : 'Rotate rooms.owner.public_invite or public_invite_file, then retry the originating task or room create command.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
921
+ if (!task.task_id)
922
+ recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Room',
923
+ id: room.room_id, label: room.room_name, state: room.state, category: 'provision_failed',
924
+ eventId: room.created_at });
965
925
  throw error;
966
926
  }
967
927
  }
@@ -975,6 +935,10 @@ export class TaskRoomApplicationService {
975
935
  });
976
936
  }
977
937
  catch (error) {
938
+ if (!task.task_id)
939
+ recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Room',
940
+ id: room.room_id, label: room.room_name, state: room.state, category: 'provision_failed',
941
+ eventId: room.created_at });
978
942
  throw error;
979
943
  }
980
944
  else if (attachOwner) {
@@ -997,14 +961,20 @@ export class TaskRoomApplicationService {
997
961
  }
998
962
  else
999
963
  room = activateRoom(room.room_id);
1000
- if (room.state === 'active')
1001
- recordFleetAuditPresentation({ kind: 'room', operation: 'activate',
1002
- eventId: `room-ready:${room.activated_at ?? room.created_at}`, id: room.room_id,
1003
- name: room.room_name, previousState: 'provisioning', newState: 'active',
1004
- revision: room.activated_at ?? room.created_at, taskId: room.task_id,
964
+ if (!task.task_id && room.state === 'active')
965
+ recordFleetAuditPresentation({
966
+ kind: 'room', operation: 'activate', eventId: `room-ready:${room.activated_at ?? room.created_at}`,
967
+ id: room.room_id, name: room.room_name, previousState: 'provisioning', newState: 'active',
968
+ revision: room.activated_at ?? room.created_at,
1005
969
  template: room.template_snapshot ? `${room.template_snapshot.name}@${room.template_snapshot.version}` : undefined,
1006
970
  anonymous: policy.anonymous, memberCount: room.member_seats.length, ownerAttached: attachOwner,
1007
- participants: [] });
971
+ participants: [],
972
+ });
973
+ if (!task.task_id && room.state !== 'active')
974
+ recordFleetAuditPresentation({
975
+ kind: 'lifecycle_failure', resource: 'Room', id: room.room_id, label: room.room_name,
976
+ state: room.state, category: 'provision_pending', eventId: room.created_at,
977
+ });
1008
978
  if (task.task_id && !launchTemplate?.members.length)
1009
979
  activateTask(task.task_id);
1010
980
  return room;
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.1.0-nightly.27",
3
- "buildId": "188ee458bb22",
4
- "commit": "0ace1644c06e5c637efccced7b39bdfa03260cf6",
2
+ "version": "1.1.0-nightly.29",
3
+ "buildId": "4c492a1ee52e",
4
+ "commit": "b201f3f81183766a67423d34c3acd938ddd2d115",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-03T18:06:33.704Z",
6
+ "builtAt": "2026-09-04T16:18:42.272Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]