@ours.network/fleet 1.1.0-nightly.8 → 1.1.0

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 (202) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +9 -2
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +579 -173
  13. package/dist/briefing.js +85 -28
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +130 -55
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +193 -55
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +59 -9
  27. package/dist/fleet-command-audit.js +426 -63
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +21 -5
  50. package/dist/owner-channel/channel.js +312 -138
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/owner-channel/state.d.ts +2 -0
  58. package/dist/owner-channel/state.js +8 -2
  59. package/dist/permissions.js +1 -1
  60. package/dist/preset-migration.d.ts +42 -0
  61. package/dist/preset-migration.js +450 -0
  62. package/dist/reports/artifact.d.ts +10 -0
  63. package/dist/reports/artifact.js +32 -0
  64. package/dist/reports/index.d.ts +5 -0
  65. package/dist/reports/index.js +5 -0
  66. package/dist/reports/render.d.ts +6 -0
  67. package/dist/reports/render.js +240 -0
  68. package/dist/reports/service.d.ts +42 -0
  69. package/dist/reports/service.js +57 -0
  70. package/dist/reports/tasks.d.ts +52 -0
  71. package/dist/reports/tasks.js +177 -0
  72. package/dist/reports/types.d.ts +315 -0
  73. package/dist/reports/types.js +6 -0
  74. package/dist/resolved-plan.js +13 -1
  75. package/dist/rooms-tasks/cli.d.ts +2 -0
  76. package/dist/rooms-tasks/cli.js +434 -279
  77. package/dist/rooms-tasks/close.d.ts +9 -1
  78. package/dist/rooms-tasks/close.js +23 -7
  79. package/dist/rooms-tasks/config.js +16 -25
  80. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  81. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  82. package/dist/rooms-tasks/deletion.d.ts +51 -0
  83. package/dist/rooms-tasks/deletion.js +217 -0
  84. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  85. package/dist/rooms-tasks/external-worker.js +38 -0
  86. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  87. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  88. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  89. package/dist/rooms-tasks/member-overrides.js +204 -0
  90. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  91. package/dist/rooms-tasks/member-startup.js +9 -2
  92. package/dist/rooms-tasks/provision.js +146 -56
  93. package/dist/rooms-tasks/room-state.d.ts +1 -0
  94. package/dist/rooms-tasks/room-state.js +12 -1
  95. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  96. package/dist/rooms-tasks/task-room-name.js +61 -0
  97. package/dist/rooms-tasks/task-state.d.ts +115 -3
  98. package/dist/rooms-tasks/task-state.js +361 -11
  99. package/dist/rooms-tasks/templates.d.ts +4 -1
  100. package/dist/rooms-tasks/templates.js +34 -3
  101. package/dist/rooms-tasks/terminal.d.ts +3 -0
  102. package/dist/rooms-tasks/terminal.js +10 -4
  103. package/dist/rooms-tasks/types.d.ts +92 -7
  104. package/dist/rooms-tasks/types.js +14 -1
  105. package/dist/runner.d.ts +3 -0
  106. package/dist/runner.js +92 -33
  107. package/dist/session/acp.d.ts +4 -1
  108. package/dist/session/acp.js +29 -13
  109. package/dist/session/arbiter.d.ts +2 -1
  110. package/dist/session/arbiter.js +2 -0
  111. package/dist/session/codex-app-server-transport.d.ts +53 -0
  112. package/dist/session/codex-app-server-transport.js +184 -0
  113. package/dist/session/codex-app-server.d.ts +122 -0
  114. package/dist/session/codex-app-server.js +1031 -0
  115. package/dist/session/control.d.ts +2 -2
  116. package/dist/session/control.js +18 -2
  117. package/dist/session/conversation-types.d.ts +4 -4
  118. package/dist/session/types.d.ts +24 -6
  119. package/dist/session/types.js +28 -0
  120. package/dist/spawn.d.ts +9 -0
  121. package/dist/spawn.js +77 -9
  122. package/dist/web/fleet-config-service.d.ts +1 -0
  123. package/dist/web/fleet-config-service.js +25 -7
  124. package/dist/web/runtime.js +11 -6
  125. package/dist/web/server.js +28 -1
  126. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  127. package/dist/web-app/index.html +1 -1
  128. package/package.json +4 -4
  129. package/presets/brain-catalog.json +17 -0
  130. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  131. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  132. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  133. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  134. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  137. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  138. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  142. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  143. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  147. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  148. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  188. package/presets/fleet/roles/Coordinator.yaml +37 -0
  189. package/presets/fleet/roles/Critic.yaml +23 -1
  190. package/presets/fleet/roles/Developer.yaml +27 -5
  191. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  192. package/presets/fleet/room_templates/pair.yaml +9 -7
  193. package/presets/fleet/room_templates/single.yaml +5 -4
  194. package/presets/fleet/room_templates/team.yaml +12 -8
  195. package/presets/manifest.json +2 -1
  196. package/presets/fleet/agents/Architect.yaml +0 -3
  197. package/presets/fleet/agents/Secretary.yaml +0 -3
  198. package/presets/fleet/agents/Tester.yaml +0 -3
  199. package/presets/fleet/roles/Agent.yaml +0 -6
  200. package/presets/fleet/roles/Architect.yaml +0 -6
  201. package/presets/fleet/roles/Secretary.yaml +0 -6
  202. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -3,12 +3,51 @@ import { ConfigError, loadConfig } from '../config.js';
3
3
  import { CoworkProtocolError, CoworkUnavailableError, createCoworkAdapter, } from '../rooms-tasks/cowork-adapter.js';
4
4
  import { getBinPath, provisionMembers } from '../rooms-tasks/provision.js';
5
5
  import { activateRoom, advanceSaga, createRoomRecord, getRoomRecord, listRoomRecords, setOwnerSeat, setSagaError, } from '../rooms-tasks/room-state.js';
6
- import { activateTask, blockTask as persistBlockTask, createTask as persistTask, deleteTask as persistDeleteTask, getTask as readTask, listTasks as readTasks, moveTaskToList, reviewTask as persistReviewTask, startTask as transitionTask, TaskStateError, unblockTask as persistUnblockTask, updateTaskRoom, updateTaskTemplate, } from '../rooms-tasks/task-state.js';
6
+ import { activateTask, blockTask as persistBlockTask, createTask as persistTask, getDeletingTask, getTask as readTask, listTasks as readTasks, taskDeletionState, moveTaskToList, reviewTask as persistReviewTask, startTask as transitionTask, TaskStateError, unblockTask as persistUnblockTask, updateTaskRoom, updateTaskTemplate, updateTaskExecutionPlan, } from '../rooms-tasks/task-state.js';
7
7
  import { createTaskListLocked, DEFAULT_TASK_LIST_ID, deleteTaskListRecordLocked, readTaskLists, renameTaskListLocked, resolveTaskList, TaskListError, withTaskListsLock, } from '../rooms-tasks/task-lists.js';
8
- import { hashTemplate, listTemplates, resolveTemplate, snapshotTemplate } from '../rooms-tasks/templates.js';
8
+ import { hashTemplate, listTemplates, resolveTemplate, sealTemplateSnapshot, snapshotTemplate } from '../rooms-tasks/templates.js';
9
+ import { acquireLaunchSnapshotLock, releaseLaunchSnapshot } from '../rooms-tasks/launch-snapshot.js';
10
+ import { hashMemberOverrides, prepareExecutionPlan } from '../rooms-tasks/member-overrides.js';
11
+ import { recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
9
12
  import { acceptManagedRoomClose, deleteLegacyClosedRooms, deleteManagedRoom, recordManagedRoomCloseError, } from '../rooms-tasks/close.js';
10
- import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, } from '../rooms-tasks/terminal.js';
11
- import { TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
13
+ import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath, } from '../rooms-tasks/terminal.js';
14
+ import { acceptTaskDeletion, recordTaskDeletionError, settleTaskDeletion, } from '../rooms-tasks/deletion.js';
15
+ import { withFileLock } from '../atomic-file.js';
16
+ import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
17
+ import { storedRoomLaunchPolicy, TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
18
+ import { deriveTaskRoomName } from '../rooms-tasks/task-room-name.js';
19
+ const OWNER_ROOM_COMMANDS = ['list-members', 'remove-member'];
20
+ /** Repairable terminal observation derived entirely from durable Task/Room state. */
21
+ export function recordTaskProvisioningOutcome(outcome) {
22
+ const room = outcome.room;
23
+ recordFleetAuditResource('task', outcome.task.task_id);
24
+ if (room)
25
+ recordFleetAuditResource('room', room.room_id);
26
+ if (outcome.kind === 'ready' && room)
27
+ recordFleetAuditPresentation({
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 })),
35
+ });
36
+ if (outcome.kind === 'failed')
37
+ recordFleetAuditPresentation({
38
+ kind: 'lifecycle_failure', resource: 'Task', id: outcome.task.task_id,
39
+ label: outcome.task.title,
40
+ state: outcome.task.state, category: 'provision_failed',
41
+ eventId: outcome.task.ended_at ?? room?.created_at ?? outcome.task.created_at,
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
+ });
50
+ }
12
51
  export class TaskRoomApplicationError extends Error {
13
52
  code;
14
53
  fields;
@@ -19,43 +58,95 @@ export class TaskRoomApplicationError extends Error {
19
58
  this.name = 'TaskRoomApplicationError';
20
59
  }
21
60
  }
61
+ export function resolveRoomLaunchPolicy(template, override) {
62
+ const anonymous = override ?? template?.room?.anonymous ?? false;
63
+ return { anonymous };
64
+ }
22
65
  /** Exact extraction of the previously CLI-owned task create/start behavior. */
23
66
  export class TaskRoomApplicationService {
24
67
  configurationPath;
25
68
  deps;
26
- recovery;
27
69
  constructor(configurationPath, deps = {}) {
28
70
  this.configurationPath = configurationPath;
29
71
  this.deps = deps;
30
72
  }
31
73
  async createTask(request) {
32
74
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
33
- const template = this.createTemplate(cfg, request.template, request.noRoom);
34
- const ref = template && {
35
- name: template.name, version: template.version, content_hash: template.content_hash,
36
- };
75
+ let template = this.createTemplate(cfg, request.template, request.noRoom);
76
+ if (request.noRoom && request.anonymous !== undefined)
77
+ throw new ConfigError('--anonymous/--no-anonymous cannot be combined with --no-room');
78
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
79
+ let launchDefinitions;
80
+ let executionPlan;
81
+ let preparedPlan;
82
+ if (template) {
83
+ const definition = resolveTemplate(template.name, cfg.roomTemplates ?? {});
84
+ preparedPlan = prepareExecutionPlan(definition, cfg, request.members ?? {});
85
+ launchDefinitions = preparedPlan.launchDefinitions;
86
+ }
37
87
  const brief = request.briefFile ? readFileSync(request.briefFile, 'utf8') : request.brief;
38
- let task = await withTaskListsLock(() => {
39
- const list = resolveTaskList(request.list ?? 'default');
40
- return persistTask({
41
- title: request.title, brief, brief_file: request.briefFile, template: ref,
42
- origin: request.origin, idempotency_key: request.idempotencyKey,
43
- start: !request.backlog, no_room: request.noRoom, listId: list.list_id,
88
+ const unlock = preparedPlan ? acquireLaunchSnapshotLock() : undefined;
89
+ let sealedHash;
90
+ let task;
91
+ try {
92
+ if (preparedPlan) {
93
+ template = sealTemplateSnapshot(preparedPlan.snapshot, cfg.agentTemplates ?? {}, preparedPlan.launchDefinitions);
94
+ sealedHash = template.launch_snapshot_hash;
95
+ executionPlan = { schema_version: 1, snapshot: template, room_policy: roomPolicy,
96
+ overrides: preparedPlan.overrides,
97
+ overrides_hash: preparedPlan.overridesHash,
98
+ plan_hash: preparedPlan.planHash };
99
+ }
100
+ const ref = template && { name: template.name, version: template.version, content_hash: template.content_hash };
101
+ task = await withTaskListsLock(() => {
102
+ const list = resolveTaskList(request.list ?? 'default');
103
+ return persistTask({
104
+ title: request.title, brief, brief_file: request.briefFile, template: ref,
105
+ execution_plan: executionPlan,
106
+ origin: request.origin, idempotency_key: request.idempotencyKey,
107
+ start: !request.backlog, no_room: request.noRoom, listId: list.list_id,
108
+ });
44
109
  });
45
- });
110
+ }
111
+ catch (error) {
112
+ unlock?.();
113
+ if (sealedHash)
114
+ releaseLaunchSnapshot(sealedHash);
115
+ throw error;
116
+ }
117
+ unlock?.();
118
+ const ref = template && { name: template.name, version: template.version, content_hash: template.content_hash };
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.
122
+ if (request.backlog || request.noRoom)
123
+ recordFleetAuditPresentation({ kind: 'task', operation: 'create', id: task.task_id,
124
+ title: task.title, previousState: 'none', newState: task.state, revision: task.created_at,
125
+ list: task.list_name ?? 'default', roomId: task.room_id,
126
+ template: task.template ? `${task.template.name}@${task.template.version}` : undefined,
127
+ agents: [] });
46
128
  if (!request.backlog && !request.noRoom && ref && !task.room_id) {
47
129
  try {
48
- await this.provisionRoom(cfg, task, template, room => {
49
- task = updateTaskRoom(task.task_id, room.room_id, room.room_identity_cid);
50
- });
130
+ const room = await this.provisionRoom(cfg, task, template, created => {
131
+ task = updateTaskRoom(task.task_id, created.room_id, created.room_identity_cid);
132
+ }, launchDefinitions, roomPolicy);
51
133
  task = readTask(task.task_id);
134
+ void room;
52
135
  }
53
136
  catch (error) {
54
137
  if (error instanceof CoworkUnavailableError)
55
138
  persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
56
- throw error;
139
+ // Once the durable Room exists, provisioning errors are resumable
140
+ // saga state. Return that explicit state so the command can launch a
141
+ // continuation and report the durable in-progress outcome.
142
+ const current = readTask(task.task_id);
143
+ if (!current.room_id || !getRoomRecord(current.room_id))
144
+ throw error;
145
+ task = current;
57
146
  }
58
147
  }
148
+ if (!request.backlog && !request.noRoom && task.room_id)
149
+ recordTaskProvisioningOutcome(this.taskProvisioningOutcome(task.task_id));
59
150
  return task;
60
151
  }
61
152
  async createRoom(request) {
@@ -66,27 +157,28 @@ export class TaskRoomApplicationService {
66
157
  const definition = resolveTemplate(request.template, cfg.roomTemplates ?? {});
67
158
  if (!definition)
68
159
  throw new TaskRoomApplicationError('template_not_found', `template not found: ${request.template}`, { template: request.template });
69
- template = snapshotTemplate(definition);
160
+ if (request.members && Object.keys(request.members).length) {
161
+ const prepared = prepareExecutionPlan(definition, cfg, request.members);
162
+ template = prepared.snapshot;
163
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
164
+ return this.provisionRoom(cfg, { title: request.name, brief, goal: request.goal }, template, () => { }, prepared.launchDefinitions, roomPolicy);
165
+ }
166
+ template = snapshotTemplate(definition, cfg.agentTemplates);
70
167
  }
168
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
71
169
  return this.provisionRoom(cfg, {
72
170
  title: request.name, brief, goal: request.goal,
73
- }, template, () => { });
171
+ }, template, () => { }, undefined, roomPolicy);
74
172
  }
75
173
  async startTask(input) {
76
- const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
77
- let task = transitionTask(input.taskId);
78
- if (task.template && !task.room_id) {
79
- const template = this.existingTemplate(cfg, task);
80
- await this.provisionRoom(cfg, task, template, room => {
81
- task = updateTaskRoom(task.task_id, room.room_id, room.room_identity_cid);
82
- });
83
- task = readTask(task.task_id);
84
- }
85
- return 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;
86
178
  }
87
179
  listTasks(filter) {
88
180
  const listId = filter?.list === undefined ? undefined : resolveTaskList(filter.list).list_id;
89
- return readTasks({ state: filter?.state, listId });
181
+ return readTasks({ state: filter?.state, listId, includeDeleting: filter?.includeDeleting });
90
182
  }
91
183
  listTaskLists() { return readTaskLists(); }
92
184
  groupedTasks(filter) {
@@ -130,6 +222,55 @@ export class TaskRoomApplicationService {
130
222
  const task = readTask(taskId);
131
223
  return { task, orchestration: task.room_id ? getRoomRecord(task.room_id) : undefined };
132
224
  }
225
+ taskProvisioningOutcome(taskId) {
226
+ const task = readTask(taskId);
227
+ const room = task.room_id ? getRoomRecord(task.room_id) : undefined;
228
+ const expected = room?.template_snapshot?.members.reduce((sum, member) => sum + member.count, 0)
229
+ ?? task.execution_plan?.snapshot.members.reduce((sum, member) => sum + member.count, 0) ?? 0;
230
+ const active = room?.member_seats.filter(seat => seat.seat_state === 'active').length ?? 0;
231
+ const launched = room?.member_seats.filter(seat => seat.launch?.state === 'launched').length ?? 0;
232
+ const failed = task.state === 'failed' || room?.saga.phase === 'failed';
233
+ const ready = task.state === 'active' && room?.state === 'active'
234
+ && active === expected && launched === expected;
235
+ const blocker = task.outcome?.summary ?? task.blocked?.reason ?? room?.saga.error;
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
+ return {
248
+ kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room,
249
+ launch: {
250
+ ...(room?.template_snapshot
251
+ ? { template: `${room.template_snapshot.name}@${room.template_snapshot.version}` }
252
+ : task.template ? { template: `${task.template.name}@${task.template.version}` } : {}),
253
+ anonymous: storedRoomLaunchPolicy(room?.room_policy ?? task.execution_plan?.room_policy).anonymous,
254
+ owner_attached: Boolean(room?.owner_seat_cid),
255
+ },
256
+ members: { expected, active, launched },
257
+ ...(blocker ? { blocker } : {}),
258
+ ...(nextAction ? { next_action: nextAction } : {}),
259
+ };
260
+ }
261
+ async awaitTaskProvisioning(input) {
262
+ const now = this.deps.now ?? Date.now;
263
+ const sleep = this.deps.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
264
+ const deadline = now() + (input.waitMs ?? 60_000);
265
+ for (;;) {
266
+ const outcome = this.taskProvisioningOutcome(input.taskId);
267
+ if (outcome.kind !== 'in_progress' || now() >= deadline) {
268
+ recordTaskProvisioningOutcome(outcome);
269
+ return outcome;
270
+ }
271
+ await sleep(100);
272
+ }
273
+ }
133
274
  blockTask(input) {
134
275
  return persistBlockTask(input.taskId, input.reason);
135
276
  }
@@ -139,8 +280,93 @@ export class TaskRoomApplicationService {
139
280
  reviewTask(input) {
140
281
  return persistReviewTask(input.taskId);
141
282
  }
142
- deleteTask(input) {
143
- return persistDeleteTask(input.taskId);
283
+ deletionActor(actor) {
284
+ if (actor.kind === 'local_control')
285
+ return { kind: 'local_control', surface: actor.surface };
286
+ if (actor.kind === 'authenticated_owner')
287
+ return { kind: 'authenticated_owner', surface: 'messenger', cid: actor.cid };
288
+ throw new Error('internal workers cannot originate a task deletion acceptance');
289
+ }
290
+ /** Accept a permanent deletion in any lifecycle state; audit at acceptance. */
291
+ async requestTaskDeletion(input) {
292
+ const result = await acceptTaskDeletion(input.taskId, this.deletionActor(input.actor));
293
+ if (result.status === 'accepted') {
294
+ const task = result.task;
295
+ recordFleetAuditPresentation({ kind: 'task', operation: 'delete', id: task.task_id,
296
+ title: task.title, previousState: task.state, newState: 'deleting',
297
+ revision: task.deletion?.accepted_at ?? task.created_at, list: task.list_name ?? 'default',
298
+ roomId: task.room_id,
299
+ template: task.template ? `${task.template.name}@${task.template.version}` : undefined,
300
+ agents: [] });
301
+ }
302
+ return result;
303
+ }
304
+ /** Worker entry: converge an accepted deletion; Cowork is resolved lazily. */
305
+ async settleTaskDeletion(input) {
306
+ const result = await settleTaskDeletion({
307
+ taskId: input.taskId,
308
+ cowork: () => {
309
+ // Config resolves only when room work exists: a no-room deletion must
310
+ // settle even with missing or invalid rooms configuration.
311
+ const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
312
+ if (!cfg.rooms)
313
+ throw new ConfigError('rooms: configuration is required before deleting task rooms');
314
+ return this.deps.cowork ? this.deps.cowork(cfg)
315
+ : createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
316
+ },
317
+ });
318
+ if (result.deleted && result.previous_state) {
319
+ recordFleetAuditPresentation({ kind: 'task', operation: 'delete', id: result.task_id,
320
+ title: result.title, previousState: result.previous_state, newState: 'deleted',
321
+ revision: new Date().toISOString(), list: 'default', roomId: undefined,
322
+ template: undefined, agents: [] });
323
+ }
324
+ return result;
325
+ }
326
+ /**
327
+ * Launch the external deletion worker outside the caller's lifecycle and
328
+ * wait boundedly for physical absence. Timeouts and launch failures report
329
+ * a pending, recoverable state — never success. A concurrent worker that
330
+ * already removed the record reads as settled.
331
+ */
332
+ async launchTaskDeletionWorker(input) {
333
+ const launch = this.deps.launchDeletionWorker
334
+ ?? ((taskId) => launchFleetWorker(['task', '_settle_delete', taskId], `task-delete-${taskId}`, this.configurationPath));
335
+ const errorAtBefore = (() => {
336
+ try {
337
+ return getDeletingTask(input.taskId).deletion?.error_at;
338
+ }
339
+ catch {
340
+ return undefined;
341
+ }
342
+ })();
343
+ try {
344
+ await launch(input.taskId);
345
+ }
346
+ catch (error) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ await recordTaskDeletionError(input.taskId, message, `External delete worker failed to start. Repeat the delete request for ${input.taskId}.`).catch(() => { });
349
+ return { deleted: false, pending: true, error: message };
350
+ }
351
+ const now = this.deps.now ?? Date.now;
352
+ const sleep = this.deps.sleep
353
+ ?? ((ms) => new Promise(resolve => { setTimeout(resolve, ms); }));
354
+ const deadline = now() + (input.waitMs ?? 10_000);
355
+ while (now() < deadline) {
356
+ if (taskDeletionState(input.taskId) === 'absent')
357
+ return { deleted: true, pending: false };
358
+ try {
359
+ const current = getDeletingTask(input.taskId).deletion;
360
+ if (current?.error_at !== errorAtBefore && current?.error)
361
+ return { deleted: false, pending: true, error: current.error };
362
+ }
363
+ catch { /* re-checked as absence on the next iteration */ }
364
+ await sleep(100);
365
+ }
366
+ return { deleted: false, pending: true };
367
+ }
368
+ recordDeletionError(input) {
369
+ return recordTaskDeletionError(input.taskId, input.error, input.recoveryHint);
144
370
  }
145
371
  async completeTask(input) {
146
372
  const task = readTask(input.taskId);
@@ -178,33 +404,63 @@ export class TaskRoomApplicationService {
178
404
  recordSettlementError(input) {
179
405
  return recordTaskTerminalIntentError(input.taskId, input.error, input.recoveryHint);
180
406
  }
181
- async beginTaskRecovery(input) {
182
- const config = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
183
- this.recovery = { taskId: input.taskId, config };
184
- const task = readTask(input.taskId);
185
- if (task.terminal_intent?.status === 'pending')
186
- return { kind: 'terminal_worker_required', taskId: input.taskId };
187
- return { kind: 'final', result: await this.continueTaskRecovery({
188
- actor: input.actor, taskId: input.taskId, terminalTimedOut: false,
189
- }) };
190
- }
191
- async continueTaskRecovery(input) {
192
- if (!this.recovery || this.recovery.taskId !== input.taskId)
193
- throw new Error('task recovery continuation requires a matching begin');
194
- const recovery = this.recovery;
195
- this.recovery = undefined;
196
- const cfg = recovery.config;
407
+ async continueTaskProvisioning(input) {
408
+ const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
197
409
  let task = readTask(input.taskId);
198
410
  let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
199
- const issues = input.terminalTimedOut ? [{ code: 'terminal_pending' }] : [];
411
+ const issues = [];
200
412
  if (task.state !== 'provisioning')
201
413
  return {
202
- kind: TASK_TERMINAL_STATES.includes(task.state) ? 'terminal' : 'no_op', task, room, issues,
414
+ kind: 'no_op', task, room, issues,
203
415
  };
416
+ if (!room) {
417
+ if (!task.template)
418
+ return {
419
+ kind: 'provisioning_non_resumable', task, room, issues, reason: 'missing_room',
420
+ };
421
+ try {
422
+ const template = task.execution_plan?.snapshot ?? this.existingTemplate(cfg, task);
423
+ const roomPolicy = task.execution_plan
424
+ ? storedRoomLaunchPolicy(task.execution_plan.room_policy)
425
+ : resolveRoomLaunchPolicy(template, undefined);
426
+ await this.provisionRoom(cfg, task, template, created => {
427
+ task = updateTaskRoom(task.task_id, created.room_id, created.room_identity_cid);
428
+ }, undefined, roomPolicy);
429
+ task = readTask(task.task_id);
430
+ room = task.room_id ? getRoomRecord(task.room_id) : undefined;
431
+ return { kind: 'provisioning_resumed', task, room, issues: [{ code: 'provisioning_resumed' }] };
432
+ }
433
+ catch (error) {
434
+ issues.push({ code: 'resume_failed', error: error instanceof Error ? error.message : String(error) });
435
+ return { kind: 'provisioning_resume_failed', task, room, issues };
436
+ }
437
+ }
438
+ if (room.saga.phase === 'attach_owner'
439
+ || room.provisioning_detail === 'waiting_owner_authorization'
440
+ || room.provisioning_detail === 'waiting_owner_invite'
441
+ || room.provisioning_detail === 'owner_cid_mismatch') {
442
+ try {
443
+ await this.reconcileProvisioningOwner(cfg, room.room_id);
444
+ task = readTask(task.task_id);
445
+ room = getRoomRecord(room.room_id);
446
+ if (task.state === 'active' && room?.state === 'active')
447
+ return {
448
+ kind: 'provisioning_resumed', task, room, issues: [{ code: 'provisioning_resumed' }],
449
+ };
450
+ }
451
+ catch (error) {
452
+ issues.push({ code: 'resume_failed', error: error instanceof Error ? error.message : String(error) });
453
+ return { kind: 'provisioning_resume_failed', task, room, issues };
454
+ }
455
+ }
204
456
  if (!room)
205
- return { kind: 'provisioning_non_resumable', task, room, issues, reason: 'missing_room' };
457
+ return {
458
+ kind: 'provisioning_non_resumable', task, room, issues, reason: 'missing_room',
459
+ };
206
460
  if (room.provisioning_detail === 'waiting_cowork')
207
461
  issues.push({ code: 'waiting_cowork' });
462
+ if (room.provisioning_detail === 'waiting_owner_authorization')
463
+ issues.push({ code: 'waiting_owner_authorization' });
208
464
  if (room.provisioning_detail === 'waiting_owner_invite')
209
465
  issues.push({ code: 'waiting_owner_invite' });
210
466
  if (room.provisioning_detail === 'owner_cid_mismatch')
@@ -241,6 +497,50 @@ export class TaskRoomApplicationService {
241
497
  return { kind: 'provisioning_resume_failed', task, room, issues };
242
498
  }
243
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
+ }
244
544
  async acceptTerminal(taskId, kind, roomId, outcome) {
245
545
  const task = await acceptTaskTerminalIntent({ taskId, kind, roomId, outcome });
246
546
  return { task, settlementRequired: task.terminal_intent?.status === 'pending' && !!roomId };
@@ -260,11 +560,9 @@ export class TaskRoomApplicationService {
260
560
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
261
561
  return listTemplates(cfg.roomTemplates ?? {}).flatMap(template => {
262
562
  const issues = template.members.flatMap(member => {
263
- if (!('ref' in member.agent))
264
- return [];
265
- const ref = member.agent.ref;
266
- return cfg.roles.some(role => role.name === ref)
267
- ? [] : [`member ${member.slot}: Agent ref '${ref}' not found`];
563
+ const ref = member.agent_template;
564
+ return cfg.agentTemplates?.[ref]
565
+ ? [] : [`member ${member.slot}: Agent Template '${ref}' not found`];
268
566
  });
269
567
  return issues.length ? [{ template: `${template.name}@${template.version}`, issues }] : [];
270
568
  });
@@ -335,73 +633,6 @@ export class TaskRoomApplicationService {
335
633
  recordRoomSettlementError(input) {
336
634
  return recordManagedRoomCloseError(input.roomId, input.error, input.recoveryHint);
337
635
  }
338
- async recoverRoom(input) {
339
- const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
340
- if (!cfg.rooms)
341
- throw new ConfigError('rooms: configuration is required before creating or querying rooms');
342
- const adapter = this.deps.cowork ? this.deps.cowork(cfg)
343
- : createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
344
- let orchestration = getRoomRecord(input.roomId);
345
- if (orchestration?.state === 'closing' || orchestration?.state === 'closed')
346
- return { kind: 'deletion_worker_required', roomId: input.roomId };
347
- const room = await adapter.recoverRoom(input.roomId);
348
- orchestration = getRoomRecord(input.roomId);
349
- if (orchestration && !orchestration.owner_seat_cid
350
- && (orchestration.provisioning_detail === 'waiting_owner_invite'
351
- || orchestration.provisioning_detail === 'owner_cid_mismatch')) {
352
- const expected = cfg.rooms.owner.expected_cid.toLowerCase();
353
- const existing = (await adapter.getSeats(input.roomId))
354
- .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
355
- if (!existing && !cfg.ownerInvite)
356
- throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
357
- let acceptedCid = existing?.identity_cid;
358
- if (!acceptedCid)
359
- acceptedCid = (await adapter.acceptInvite(input.roomId, cfg.ownerInvite, {
360
- role: cfg.rooms.owner.role, expected_cid: cfg.rooms.owner.expected_cid,
361
- })).seat_cid;
362
- setOwnerSeat(input.roomId, acceptedCid, cfg.ownerInviteFingerprint ?? '');
363
- orchestration = advanceSaga(input.roomId, 'create_members', 3);
364
- }
365
- const issues = [];
366
- if (orchestration?.saga.error)
367
- issues.push('A provisioning failure is recorded; inspect role logs for diagnostics.');
368
- if (orchestration?.saga.recovery_hint)
369
- issues.push('Recovery guidance is recorded; inspect role logs for diagnostics.');
370
- if (orchestration?.provisioning_detail === 'waiting_cowork')
371
- issues.push('Check ours-cowork service status');
372
- if (orchestration?.provisioning_detail === 'waiting_owner_invite')
373
- issues.push('Rotate rooms.owner.public_invite in config, then re-run recover');
374
- if (orchestration?.provisioning_detail === 'waiting_seats')
375
- issues.push('Inspect temporary member logs for invite acceptance, then re-run recover');
376
- if (orchestration?.state === 'provisioning'
377
- && ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'].includes(orchestration.saga.phase)
378
- && orchestration.template_snapshot) {
379
- try {
380
- let template = orchestration.template_snapshot;
381
- let brief;
382
- let goal = orchestration.room_name;
383
- if (orchestration.task_id) {
384
- const task = readTask(orchestration.task_id);
385
- if (!task.template || task.template.name !== template.name || task.template.version !== template.version
386
- || task.template.content_hash !== template.content_hash)
387
- throw new Error(`task ${task.task_id} template reference does not match room ${orchestration.room_id}'s durable snapshot`);
388
- brief = task.brief;
389
- goal = task.title;
390
- }
391
- await (this.deps.provisionMembers ?? provisionMembers)({ cfg, cowork: adapter,
392
- roomId: orchestration.room_id, taskId: orchestration.task_id, template,
393
- binPath: (this.deps.binPath ?? getBinPath)(), brief, goal });
394
- orchestration = getRoomRecord(input.roomId);
395
- return { kind: 'provisioning_resumed', room, orchestration,
396
- issues: ['Provisioning resumed successfully'] };
397
- }
398
- catch (error) {
399
- issues.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
400
- return { kind: 'provisioning_resume_failed', room, orchestration, issues };
401
- }
402
- }
403
- return { kind: 'recovered', room, orchestration, issues };
404
- }
405
636
  async finishTask(input) {
406
637
  let task = readTask(input.taskId);
407
638
  if (TASK_TERMINAL_STATES.includes(task.state))
@@ -415,73 +646,161 @@ export class TaskRoomApplicationService {
415
646
  async ensureTaskWork(input) {
416
647
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
417
648
  let task = readTask(input.taskId);
649
+ const recordedRoom = task.room_id ? getRoomRecord(task.room_id) : undefined;
650
+ const recordedPolicy = storedRoomLaunchPolicy(recordedRoom?.room_policy ?? task.execution_plan?.room_policy);
651
+ if (task.room_id && input.anonymous !== undefined && input.anonymous !== recordedPolicy.anonymous)
652
+ throw new TaskRoomApplicationError('template_mismatch', 'anonymous override does not match the existing Room launch policy', { room: task.room_id });
418
653
  if (TASK_TERMINAL_STATES.includes(task.state))
419
654
  throw new TaskRoomApplicationError('task_terminal', 'task terminal', { task: input.taskId, state: task.state });
655
+ const durableTemplate = task.room_id
656
+ ? getRoomRecord(task.room_id)?.template_snapshot ?? task.execution_plan?.snapshot
657
+ : task.execution_plan?.snapshot;
658
+ const templateRequestMatches = !input.template || (durableTemplate !== undefined
659
+ && (input.template === durableTemplate.name
660
+ || input.template === `${durableTemplate.name}@${durableTemplate.version}`));
661
+ const memberRequestMatches = input.members === undefined || (task.execution_plan !== undefined
662
+ && task.execution_plan.overrides_hash === hashMemberOverrides(input.members));
663
+ const durableRequestMatches = durableTemplate !== undefined
664
+ && templateRequestMatches && memberRequestMatches;
420
665
  if (task.state === 'active' && task.room_id) {
421
- if (input.template) {
422
- const definition = resolveTemplate(input.template, cfg.roomTemplates ?? {});
666
+ if (input.template || input.members) {
667
+ const pinned = durableTemplate;
668
+ if (!pinned)
669
+ throw new TaskRoomApplicationError('template_mismatch', 'active task has no durable execution plan', { room: task.room_id });
670
+ if (durableRequestMatches)
671
+ return { task, status: 'already_active' };
672
+ const templateName = input.template ?? pinned.name;
673
+ const definition = resolveTemplate(templateName, cfg.roomTemplates ?? {});
423
674
  if (!definition)
424
- throw new TaskRoomApplicationError('template_not_found', 'template not found', { template: input.template });
425
- const requested = snapshotTemplate(definition);
426
- const pinned = getRoomRecord(task.room_id)?.template_snapshot ?? task.template;
427
- if (pinned && (pinned.name !== requested.name || pinned.content_hash !== requested.content_hash))
675
+ throw new TaskRoomApplicationError('template_not_found', 'template not found', { template: templateName });
676
+ const requested = prepareExecutionPlan(definition, cfg, input.members ?? {});
677
+ const pinnedPlanHash = task.execution_plan?.plan_hash ?? pinned.content_hash;
678
+ if (pinned.name !== requested.snapshot.name || pinnedPlanHash !== requested.planHash)
428
679
  throw new TaskRoomApplicationError('template_mismatch', 'template mismatch', {
429
- requested: `${requested.name}@${requested.version}`, room: task.room_id,
680
+ requested: `${requested.snapshot.name}@${requested.snapshot.version}`, room: task.room_id,
430
681
  provisioned: `${pinned.name}@${pinned.version}`
431
682
  });
432
683
  }
433
684
  return { task, status: 'already_active' };
434
685
  }
435
- const room = task.room_id ? getRoomRecord(task.room_id) : undefined;
436
- const durable = room?.template_snapshot;
686
+ let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
687
+ const durable = room?.template_snapshot ?? task.execution_plan?.snapshot;
437
688
  if (durable && (!task.template || task.template.name !== durable.name
438
689
  || task.template.version !== durable.version || task.template.content_hash !== durable.content_hash))
439
- throw new Error(`task ${task.task_id} template reference does not match room ${room.room_id}'s durable snapshot`);
690
+ throw new Error(`task ${task.task_id} template reference does not match its durable execution snapshot`);
440
691
  const templateName = input.template ?? durable?.name ?? task.template?.name
441
692
  ?? cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'single';
442
- const definition = durable && !input.template ? undefined
693
+ const storedOverridesMatch = !!(durable && memberRequestMatches);
694
+ const definition = durable && durableRequestMatches ? undefined
443
695
  : resolveTemplate(templateName, cfg.roomTemplates ?? {});
444
- if (input.template && !definition)
696
+ if (input.template && !definition && !durableRequestMatches)
445
697
  throw new TaskRoomApplicationError('template_not_found', 'template not found', { template: templateName });
446
698
  if (!durable && !definition)
447
699
  throw new TaskRoomApplicationError('template_not_found', 'template not found', { template: templateName });
448
- const snapshot = durable ?? snapshotTemplate(definition);
700
+ let launchDefinitions;
701
+ let preparedPlan = !durable && definition
702
+ ? prepareExecutionPlan(definition, cfg, input.members ?? {}) : undefined;
703
+ let snapshot = durable ?? preparedPlan.snapshot;
704
+ const roomPolicy = input.anonymous !== undefined
705
+ ? resolveRoomLaunchPolicy(snapshot, input.anonymous)
706
+ : task.execution_plan
707
+ ? storedRoomLaunchPolicy(task.execution_plan.room_policy)
708
+ : resolveRoomLaunchPolicy(snapshot, undefined);
709
+ if (preparedPlan)
710
+ launchDefinitions = preparedPlan.launchDefinitions;
711
+ if (input.members && Object.keys(input.members).length && !storedOverridesMatch) {
712
+ if (!definition)
713
+ throw new TaskRoomApplicationError('template_mismatch', 'member overrides cannot change an existing Room execution plan');
714
+ const prepared = preparedPlan ?? prepareExecutionPlan(definition, cfg, input.members);
715
+ preparedPlan = prepared;
716
+ if (durable && prepared.planHash !== durable.content_hash)
717
+ throw new TaskRoomApplicationError('template_mismatch', 'member overrides do not match existing execution plan');
718
+ snapshot = prepared.snapshot;
719
+ launchDefinitions = prepared.launchDefinitions;
720
+ }
449
721
  if (durable && input.template && definition) {
450
- const requested = snapshotTemplate(definition);
722
+ const requested = snapshotTemplate(definition, cfg.agentTemplates);
451
723
  if (requested.name !== durable.name || requested.content_hash !== durable.content_hash)
452
724
  throw new TaskRoomApplicationError('template_mismatch', 'template mismatch', {
453
725
  requested: `${requested.name}@${requested.version}`, room: room.room_id,
454
726
  provisioned: `${durable.name}@${durable.version}`
455
727
  });
456
728
  }
457
- if (!input.template && task.template && snapshot.content_hash !== task.template.content_hash)
458
- throw new TaskRoomApplicationError('task_template_drift', 'task template drift', {
459
- template: `${task.template.name}@${task.template.version}`
460
- });
729
+ if (!input.template && task.template && snapshot.content_hash !== task.template.content_hash) {
730
+ // Pre-execution-plan backlog records used the template-only hash. Accept
731
+ // that exact legacy pin once, then upgrade it to the complete sealed plan.
732
+ const legacyHash = definition
733
+ ? snapshotTemplate(definition, cfg.agentTemplates).content_hash : undefined;
734
+ if (task.template.content_hash !== legacyHash)
735
+ throw new TaskRoomApplicationError('task_template_drift', 'task template drift', {
736
+ template: `${task.template.name}@${task.template.version}`
737
+ });
738
+ }
461
739
  if (task.room_id && room?.template_snapshot
462
740
  && (room.template_snapshot.name !== snapshot.name || room.template_snapshot.content_hash !== snapshot.content_hash))
463
741
  throw new TaskRoomApplicationError('template_mismatch', 'template mismatch', {
464
742
  requested: `${snapshot.name}@${snapshot.version}`, room: task.room_id,
465
743
  provisioned: `${room.template_snapshot.name}@${room.template_snapshot.version}`
466
744
  });
467
- if (!task.template || task.template.name !== snapshot.name || task.template.content_hash !== snapshot.content_hash)
745
+ if (preparedPlan && !task.room_id) {
746
+ const unlock = acquireLaunchSnapshotLock();
747
+ let sealed;
748
+ try {
749
+ sealed = sealTemplateSnapshot(snapshot, cfg.agentTemplates ?? {}, launchDefinitions);
750
+ task = updateTaskExecutionPlan(task.task_id, { schema_version: 1, snapshot: sealed,
751
+ room_policy: roomPolicy,
752
+ overrides: preparedPlan.overrides, overrides_hash: preparedPlan.overridesHash,
753
+ plan_hash: preparedPlan.planHash });
754
+ snapshot = sealed;
755
+ }
756
+ catch (error) {
757
+ unlock();
758
+ if (sealed?.launch_snapshot_hash)
759
+ releaseLaunchSnapshot(sealed.launch_snapshot_hash);
760
+ throw error;
761
+ }
762
+ unlock();
763
+ }
764
+ else if (!task.room_id && task.execution_plan && input.anonymous !== undefined) {
765
+ task = updateTaskExecutionPlan(task.task_id, { ...task.execution_plan, room_policy: roomPolicy });
766
+ }
767
+ else if (!task.template || task.template.name !== snapshot.name || task.template.content_hash !== snapshot.content_hash)
468
768
  task = updateTaskTemplate(task.task_id, { name: snapshot.name, version: snapshot.version, content_hash: snapshot.content_hash });
469
- if (task.state === 'backlog')
769
+ if (task.state === 'backlog') {
470
770
  task = transitionTask(task.task_id);
771
+ }
471
772
  if (!task.room_id) {
472
773
  try {
473
774
  await this.provisionRoom(cfg, task, snapshot, created => {
474
775
  task = updateTaskRoom(task.task_id, created.room_id, created.room_identity_cid);
475
- });
776
+ }, launchDefinitions, roomPolicy);
476
777
  task = readTask(task.task_id);
477
778
  }
478
779
  catch (error) {
479
780
  if (error instanceof CoworkUnavailableError)
480
781
  persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
481
- throw error;
782
+ const current = readTask(task.task_id);
783
+ if (!current.room_id || !getRoomRecord(current.room_id))
784
+ throw error;
785
+ task = current;
482
786
  }
483
787
  }
484
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
+ }
485
804
  if (!room || !['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'].includes(room.saga.phase))
486
805
  throw new TaskRoomApplicationError('task_non_resumable', 'task non-resumable', { task: task.task_id, room: task.room_id });
487
806
  try {
@@ -496,10 +815,11 @@ export class TaskRoomApplicationService {
496
815
  catch (error) {
497
816
  if (error instanceof CoworkUnavailableError)
498
817
  persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
499
- throw error;
818
+ task = readTask(task.task_id);
500
819
  }
501
820
  }
502
- return { task, status: 'ready' };
821
+ const resultingRoom = getRoomRecord(task.room_id);
822
+ return { task, status: task.state === 'active' && resultingRoom?.state === 'active' ? 'ready' : 'in_progress' };
503
823
  }
504
824
  createTemplate(cfg, requested, noRoom) {
505
825
  if (noRoom)
@@ -512,17 +832,17 @@ export class TaskRoomApplicationService {
512
832
  throw new TaskRoomApplicationError('template_not_found', `template not found: ${requested}`);
513
833
  return undefined;
514
834
  }
515
- return snapshotTemplate(definition);
835
+ return snapshotTemplate(definition, cfg.agentTemplates);
516
836
  }
517
837
  existingTemplate(cfg, task) {
518
838
  const ref = task.template;
519
839
  const definition = resolveTemplate(ref.name, cfg.roomTemplates ?? {});
520
- const snapshot = definition ? snapshotTemplate(definition) : undefined;
840
+ const snapshot = definition ? snapshotTemplate(definition, cfg.agentTemplates) : undefined;
521
841
  if (!snapshot || snapshot.content_hash !== ref.content_hash)
522
842
  throw new TaskRoomApplicationError('task_template_drift', `task template snapshot no longer matches ${ref.name}@${ref.version}`);
523
843
  return snapshot;
524
844
  }
525
- async provisionRoom(cfg, task, template, onCreated) {
845
+ async provisionRoom(cfg, task, template, onCreated, launchDefinitions, policy = resolveRoomLaunchPolicy(template, undefined)) {
526
846
  const rooms = cfg.rooms;
527
847
  if (!rooms)
528
848
  throw new ConfigError('rooms: configuration is required');
@@ -534,21 +854,61 @@ export class TaskRoomApplicationService {
534
854
  throw new ConfigError('rooms: configuration is required before creating or querying rooms');
535
855
  return createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
536
856
  })();
537
- const created = await cowork.createRoom({
538
- room_name: task.title, goal: task.goal?.trim() || task.title,
539
- briefing: task.brief?.trim() || template?.contract?.trim() || task.goal?.trim() || task.title,
540
- quiet_membership: template?.room?.quiet_membership,
541
- anonymous: template?.room?.anonymous,
542
- });
543
- let room = createRoomRecord({
544
- room_id: created.room_id, room_name: task.title, room_identity_cid: created.identity_cid,
545
- task_id: task.task_id, template_snapshot: template,
546
- });
547
- onCreated(room);
857
+ const roomName = task.task_id ? deriveTaskRoomName(task.title, task.task_id) : task.title;
858
+ const unlockSnapshot = template ? acquireLaunchSnapshotLock() : undefined;
859
+ let launchTemplate;
860
+ let room;
861
+ try {
862
+ launchTemplate = template ? (template.launch_snapshot_hash ? template
863
+ : sealTemplateSnapshot(template, cfg.agentTemplates ?? {}, launchDefinitions)) : undefined;
864
+ // Room publication window: for a task-bound room, hold the common
865
+ // task-operation lock across remote creation, local record creation, and
866
+ // the task link, so an accepted deletion linearizes strictly before
867
+ // (bounded task_deleting abort) or strictly after (record visible to the
868
+ // deletion scan). Lock order stays launch-snapshot → task-operation.
869
+ const publishRoom = async () => {
870
+ if (task.task_id) {
871
+ const fresh = readTask(task.task_id);
872
+ if (fresh.deletion?.status === 'pending')
873
+ throw new TaskRoomApplicationError('task_deleting', `task ${task.task_id} is pending deletion`, { task: task.task_id });
874
+ }
875
+ const created = await cowork.createRoom({
876
+ room_name: roomName, goal: task.goal?.trim() || task.title,
877
+ briefing: task.brief?.trim() || launchTemplate?.contract?.trim() || task.goal?.trim() || task.title,
878
+ quiet_membership: launchTemplate?.room?.quiet_membership,
879
+ anonymous: policy.anonymous,
880
+ });
881
+ const record = createRoomRecord({
882
+ room_id: created.room_id, room_name: roomName, room_identity_cid: created.identity_cid,
883
+ task_id: task.task_id, template_snapshot: launchTemplate, room_policy: policy,
884
+ });
885
+ onCreated(record);
886
+ return record;
887
+ };
888
+ room = task.task_id
889
+ ? await withFileLock(taskOperationLockPath(task.task_id), publishRoom, {}, TASK_OPERATION_LOCK_STALE_MS)
890
+ : await publishRoom();
891
+ }
892
+ catch (error) {
893
+ unlockSnapshot?.();
894
+ if (launchTemplate?.launch_snapshot_hash)
895
+ releaseLaunchSnapshot(launchTemplate.launch_snapshot_hash);
896
+ throw error;
897
+ }
898
+ unlockSnapshot?.();
548
899
  room = advanceSaga(room.room_id, 'create_room', 1);
549
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
+ }
550
911
  try {
551
- room = advanceSaga(room.room_id, 'attach_owner', 2);
552
912
  const accepted = await cowork.acceptInvite(room.room_id, cfg.ownerInvite, {
553
913
  role: rooms.owner.role, expected_cid: rooms.owner.expected_cid,
554
914
  });
@@ -557,19 +917,65 @@ export class TaskRoomApplicationService {
557
917
  catch (error) {
558
918
  const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
559
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.'
560
- : '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 });
561
925
  throw error;
562
926
  }
563
927
  }
564
928
  room = advanceSaga(room.room_id, 'create_members', 3);
565
- if (template?.members.length)
566
- return (this.deps.provisionMembers ?? provisionMembers)({
567
- cfg, cowork, roomId: room.room_id, taskId: task.task_id, template,
568
- binPath: (this.deps.binPath ?? getBinPath)(), brief: task.brief,
569
- goal: task.task_id ? task.title : task.goal,
929
+ if (launchTemplate && (launchTemplate.members.length > 0 || attachOwner))
930
+ try {
931
+ room = await (this.deps.provisionMembers ?? provisionMembers)({
932
+ cfg, cowork, roomId: room.room_id, taskId: task.task_id, template: launchTemplate,
933
+ binPath: (this.deps.binPath ?? getBinPath)(), brief: task.brief,
934
+ goal: task.task_id ? task.title : task.goal,
935
+ });
936
+ }
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 });
942
+ throw error;
943
+ }
944
+ else if (attachOwner) {
945
+ const now = this.deps.now ?? Date.now;
946
+ const sleep = this.deps.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
947
+ const deadline = now() + 60_000;
948
+ for (;;) {
949
+ const seats = await cowork.getSeats(room.room_id);
950
+ if (room.owner_seat_cid && seats.some(seat => seat.identity_cid.toLowerCase() === room.owner_seat_cid.toLowerCase()
951
+ && seat.seat_state === 'active')) {
952
+ room = activateRoom(room.room_id);
953
+ break;
954
+ }
955
+ if (now() >= deadline) {
956
+ room = advanceSaga(room.room_id, 'wait_seats', 5, 'waiting_seats');
957
+ break;
958
+ }
959
+ await sleep(250);
960
+ }
961
+ }
962
+ else
963
+ room = activateRoom(room.room_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,
969
+ template: room.template_snapshot ? `${room.template_snapshot.name}@${room.template_snapshot.version}` : undefined,
970
+ anonymous: policy.anonymous, memberCount: room.member_seats.length, ownerAttached: attachOwner,
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,
570
977
  });
571
- room = activateRoom(room.room_id);
572
- if (task.task_id)
978
+ if (task.task_id && !launchTemplate?.members.length)
573
979
  activateTask(task.task_id);
574
980
  return room;
575
981
  }