@ours.network/fleet 1.2.0-nightly.1 → 1.2.0-nightly.10

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 (86) hide show
  1. package/README.md +95 -21
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/file-rpc.d.ts +23 -0
  7. package/dist/agent-ours/file-rpc.js +123 -0
  8. package/dist/agent-ours/harness.d.ts +14 -0
  9. package/dist/agent-ours/harness.js +64 -0
  10. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  11. package/dist/agent-ours/mcp-endpoint.js +111 -0
  12. package/dist/agent-ours/runtime.d.ts +53 -0
  13. package/dist/agent-ours/runtime.js +247 -0
  14. package/dist/agent-ours/service.d.ts +23 -0
  15. package/dist/agent-ours/service.js +364 -0
  16. package/dist/agent-ours/state.d.ts +45 -0
  17. package/dist/agent-ours/state.js +95 -0
  18. package/dist/agent-ours/wire.d.ts +14 -0
  19. package/dist/agent-ours/wire.js +55 -0
  20. package/dist/agent-recovery-gate.js +1 -1
  21. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  22. package/dist/application/legacy-supervisor-identity.js +43 -0
  23. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  24. package/dist/application/supervisor-ours-tools.js +121 -0
  25. package/dist/application/task-room-service.js +55 -19
  26. package/dist/briefing.js +10 -59
  27. package/dist/build-info.json +5 -4
  28. package/dist/capabilities.d.ts +2 -1
  29. package/dist/capabilities.js +2 -0
  30. package/dist/cli.js +63 -2
  31. package/dist/client-profile.d.ts +1 -0
  32. package/dist/client-profile.js +18 -3
  33. package/dist/config.d.ts +1 -0
  34. package/dist/creation.d.ts +1 -1
  35. package/dist/creation.js +3 -14
  36. package/dist/docs.d.ts +3 -3
  37. package/dist/docs.js +32 -23
  38. package/dist/doctor.d.ts +1 -1
  39. package/dist/doctor.js +29 -10
  40. package/dist/fleet-command-audit.js +3 -2
  41. package/dist/harness/agent-session.d.ts +2 -0
  42. package/dist/harness/claude-code-session.js +4 -2
  43. package/dist/harness/codex-session.js +9 -2
  44. package/dist/harness/codex.d.ts +3 -2
  45. package/dist/harness/codex.js +4 -3
  46. package/dist/harness/hermes-session.js +7 -1
  47. package/dist/owner-channel/channel.d.ts +3 -0
  48. package/dist/owner-channel/channel.js +24 -4
  49. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  50. package/dist/rooms-tasks/archived-absence.js +48 -0
  51. package/dist/rooms-tasks/cli.js +1 -1
  52. package/dist/rooms-tasks/close.d.ts +3 -0
  53. package/dist/rooms-tasks/close.js +95 -17
  54. package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
  55. package/dist/rooms-tasks/cowork-adapter.js +10 -2
  56. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  57. package/dist/rooms-tasks/cowork-http.js +70 -0
  58. package/dist/rooms-tasks/deletion.js +40 -3
  59. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  60. package/dist/rooms-tasks/member-startup.js +2 -0
  61. package/dist/rooms-tasks/provision.d.ts +11 -0
  62. package/dist/rooms-tasks/provision.js +138 -26
  63. package/dist/rooms-tasks/room-state.js +13 -0
  64. package/dist/rooms-tasks/task-state.d.ts +13 -3
  65. package/dist/rooms-tasks/task-state.js +42 -6
  66. package/dist/rooms-tasks/types.d.ts +12 -0
  67. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  68. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  69. package/dist/rooms-tasks/workspace.d.ts +26 -0
  70. package/dist/rooms-tasks/workspace.js +195 -0
  71. package/dist/runner.d.ts +14 -0
  72. package/dist/runner.js +527 -495
  73. package/dist/session/codex-app-server-transport.js +1 -1
  74. package/dist/spawn.js +19 -16
  75. package/dist/startup-readiness.d.ts +13 -0
  76. package/dist/startup-readiness.js +38 -0
  77. package/dist/supervisor/systemd.js +5 -0
  78. package/dist/temp-supervisor-recovery.d.ts +3 -0
  79. package/dist/temp-supervisor-recovery.js +46 -0
  80. package/dist/watchdog/briefing.js +3 -16
  81. package/dist/watchdog/run.js +19 -5
  82. package/dist/web/server.d.ts +2 -0
  83. package/dist/web/server.js +18 -1
  84. package/docs/task-workspaces.md +98 -0
  85. package/package.json +6 -3
  86. package/presets/fleet/roles/Coordinator.yaml +22 -0
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { attachOursClient } from '@ours.network/sdk/client';
3
+ import { readPrivateFile } from '@ours.network/sdk/connector';
4
+ import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
5
+ /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
6
+ export async function coworkHttpCall(profile, method, params, timeoutMs) {
7
+ const id = randomUUID();
8
+ const signal = AbortSignal.timeout(timeoutMs);
9
+ const body = JSON.stringify({ version: 1, id, method, params });
10
+ if (Buffer.byteLength(body) > 1024 * 1024)
11
+ throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
12
+ try {
13
+ const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
14
+ credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
15
+ await client.close();
16
+ const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
17
+ const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
18
+ method: 'POST', redirect: 'error', signal,
19
+ headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
20
+ body,
21
+ });
22
+ if (response.status === 401 || response.status === 403) {
23
+ await response.body?.cancel();
24
+ throw new CoworkProtocolError(method, 'server credential was rejected', 'unauthorized');
25
+ }
26
+ const reader = response.body?.getReader();
27
+ if (!reader)
28
+ throw new CoworkProtocolError(method, 'empty HTTP response');
29
+ const chunks = [];
30
+ let size = 0;
31
+ try {
32
+ for (;;) {
33
+ const { done, value } = await reader.read();
34
+ if (done)
35
+ break;
36
+ size += value.length;
37
+ if (size > 4 * 1024 * 1024)
38
+ throw new CoworkProtocolError(method, 'response exceeded 4 MiB');
39
+ chunks.push(value);
40
+ }
41
+ }
42
+ finally {
43
+ await reader.cancel();
44
+ }
45
+ let rpc;
46
+ try {
47
+ rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
48
+ }
49
+ catch {
50
+ throw new CoworkProtocolError(method, 'server returned malformed JSON');
51
+ }
52
+ if (!rpc || Array.isArray(rpc) || rpc.version !== 1 || rpc.id !== id
53
+ || Object.hasOwn(rpc, 'result') === Object.hasOwn(rpc, 'error'))
54
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC response');
55
+ if (Object.hasOwn(rpc, 'error')) {
56
+ const error = rpc.error;
57
+ if (!error || typeof error.code !== 'string' || typeof error.message !== 'string')
58
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC error');
59
+ throw new CoworkProtocolError(method, error.message, error.code);
60
+ }
61
+ if (!response.ok)
62
+ throw new CoworkProtocolError(method, `management answered HTTP ${response.status}`);
63
+ return rpc.result;
64
+ }
65
+ catch (error) {
66
+ if (error instanceof CoworkProtocolError)
67
+ throw error;
68
+ throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
69
+ }
70
+ }
@@ -1,13 +1,16 @@
1
+ import { proveArchivedAbsence, verifyArchivedAbsence, verifyArchivedMemberStillAbsent } from './archived-absence.js';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { withFileLock } from '../atomic-file.js';
3
4
  import { agentDir } from '../paths.js';
5
+ import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
6
+ import { collectWorkspaceArchives } from './workspace-artifacts.js';
4
7
  import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
5
8
  import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
6
9
  import { CoworkProtocolError } from './cowork-adapter.js';
7
10
  import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
8
11
  import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
9
12
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
10
- import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
13
+ import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, beginTaskWorkspaceCleanup, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
11
14
  export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
12
15
  import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
13
16
  /**
@@ -137,6 +140,8 @@ export async function settleTaskDeletion(input) {
137
140
  // aborts settlement (fail closed).
138
141
  ensureTaskDeletionReceipt(taskId);
139
142
  const records = listRoomRecords().filter(room => room.task_id === taskId);
143
+ if (task.deletion.workspace_cleanup_started_at && records.length)
144
+ throw new TaskStateError(`task ${taskId} room records reappeared after workspace cleanup began`);
140
145
  const recordIds = new Set(records.map(room => room.room_id));
141
146
  const recordedRoomId = task.deletion.room_id ?? task.room_id;
142
147
  const needsCowork = records.length > 0 || recordedRoomId !== undefined;
@@ -145,10 +150,31 @@ export async function settleTaskDeletion(input) {
145
150
  upsertTaskDeletionMembersFromSeats(taskId, record.member_seats);
146
151
  await closeManagedRoom({ roomId: record.room_id, cowork: cowork, deps: deps.roomClose });
147
152
  const closed = getRoomRecord(record.room_id);
148
- importTaskDeletionRetirementEvidence(taskId, closed?.member_seats ?? record.member_seats);
153
+ const seats = closed?.member_seats ?? record.member_seats;
154
+ const proofs = [];
155
+ for (const seat of seats) {
156
+ if (!seat.identity_cid && seat.retirement?.launch_id !== 'never-launched')
157
+ proofs.push(await proveArchivedAbsence(seat));
158
+ }
159
+ // No await between the last live-state fence and durable checkpoint.
160
+ for (const proof of proofs) {
161
+ const latest = getRoomRecord(record.room_id)?.member_seats.find(seat => seat.role_name === proof.name);
162
+ if (existsSync(agentDir(proof.name, true)) || latest?.identity_cid
163
+ || latest?.launch?.launch_id !== proof.launch_id || latest.launch.action_id !== proof.action_id)
164
+ throw new Error('Archived absence seat changed before checkpoint');
165
+ }
166
+ importTaskDeletionRetirementEvidence(taskId, seats, proofs);
149
167
  await cowork.deleteRoom(record.room_id);
168
+ for (const proof of proofs)
169
+ await verifyArchivedAbsence(proof);
170
+ for (const proof of proofs)
171
+ if (existsSync(agentDir(proof.name, true)))
172
+ throw new Error('Archived member replacement before room unlink');
150
173
  deleteRoomRecord(record.room_id);
151
174
  }
175
+ const deletion = getDeletingTask(taskId).deletion;
176
+ for (const proof of deletion.archived_absences ?? [])
177
+ await (deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
152
178
  // Members whose room record is gone (crash after record deletion, or
153
179
  // legacy state): resume from the durable cursors.
154
180
  for (const cursor of getDeletingTask(taskId).deletion.members) {
@@ -176,7 +202,7 @@ export async function settleTaskDeletion(input) {
176
202
  completeTaskDeletionReceipt(taskId);
177
203
  return { task_id: taskId, deleted: false };
178
204
  }
179
- const finalize = () => withFileLock(taskOperationLockPath(taskId), () => {
205
+ const finalize = () => withFileLock(taskOperationLockPath(taskId), async () => {
180
206
  try {
181
207
  const task = getDeletingTask(taskId);
182
208
  if (task.deletion?.status !== 'pending')
@@ -185,8 +211,19 @@ export async function settleTaskDeletion(input) {
185
211
  throw new TaskStateError(`task ${taskId} room records reappeared during deletion finalization`);
186
212
  if (task.deletion.members.some(member => member.phase !== 'identity_absent'))
187
213
  throw new TaskStateError(`task ${taskId} has unretired members at deletion finalization`);
214
+ for (const proof of task.deletion.archived_absences ?? [])
215
+ await (task.deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
216
+ for (const proof of task.deletion.archived_absences ?? [])
217
+ if (existsSync(agentDir(proof.name, true)))
218
+ throw new Error('Archived member replacement before task unlink');
188
219
  if (cleanup.snapshotHash)
189
220
  releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
221
+ if (task.workspace) {
222
+ assertWorkspaceDeletable(task.workspace, 'task', taskId);
223
+ beginTaskWorkspaceCleanup(taskId);
224
+ collectWorkspaceArchives(task.workspace);
225
+ deleteWorkspace(task.workspace, 'task', taskId);
226
+ }
190
227
  unlinkDeletedTask(taskId);
191
228
  }
192
229
  catch (error) {
@@ -6,6 +6,7 @@ export interface RoomTaskMember {
6
6
  }
7
7
  /** Build the complete task text Fleet places directly in a temporary member's briefing. */
8
8
  export declare function buildRoomMemberTask(input: {
9
+ workspace?: string;
9
10
  taskId?: string;
10
11
  roomId: string;
11
12
  roomIdentityCid: string;
@@ -7,6 +7,8 @@ export function buildRoomMemberTask(input) {
7
7
  `Fleet Task ${input.taskId ?? '(standalone)'} — ${member.cowork_role} in room ${input.roomId}`,
8
8
  '',
9
9
  ];
10
+ if (input.workspace)
11
+ lines.push(`Task-owned workspace: ${input.workspace}`, 'Start and keep all execution artifacts, dependency checkouts, and Git worktrees inside this workspace.', 'Clone repositories inside this workspace before adding worktrees; never attach to an external Git common directory.', 'Use distinct per-agent branches/directories for concurrent edits. Configured cwd is source context, not artifact ownership.', 'Completion, cancellation and room retirement retain artifacts. Only explicit owner deletion removes them.', 'Do not delete the workspace or its ownership marker yourself.', '');
10
12
  if (input.goal)
11
13
  lines.push(`Goal: ${input.goal}`);
12
14
  if (input.brief)
@@ -21,6 +21,17 @@ export interface StartupWaitPolicy {
21
21
  sleep(ms: number): Promise<void>;
22
22
  }
23
23
  export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
24
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
25
+ export declare function reconcileExistingTaskMembers(input: {
26
+ taskId: string;
27
+ checkOnly?: boolean;
28
+ cowork: Pick<CoworkAdapter, 'getRoom'>;
29
+ expectedLaunches: ReadonlyArray<{
30
+ role_name: string;
31
+ launch_id: string;
32
+ identity_cid: string;
33
+ }>;
34
+ }): Promise<RoomOrchestrationRecord>;
24
35
  export declare function cleanupMembers(input: {
25
36
  roomId: string;
26
37
  taskId?: string;
@@ -1,3 +1,5 @@
1
+ import { readRoomReadiness } from '../agent-ours/service.js';
2
+ import { ensureWorkspace, validateWorkspace } from './workspace.js';
1
3
  import { createHash, randomUUID } from 'node:crypto';
2
4
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
5
  import { join } from 'node:path';
@@ -13,7 +15,7 @@ import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js
13
15
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
14
16
  import { controlRequest } from '../session/control.js';
15
17
  import { SessionControlError } from '../session/types.js';
16
- import { closeManagedRoom } from './close.js';
18
+ import { closeManagedRoom, roomCloseLockPath, CLOSE_LOCK_STALE_MS } from './close.js';
17
19
  import { withFileLock } from '../atomic-file.js';
18
20
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
19
21
  import { TaskStateError, taskDeletionState } from './task-state.js';
@@ -115,6 +117,7 @@ function settingsFor(member, cfg, sealed) {
115
117
  }
116
118
  function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid, anonymous) {
117
119
  return buildRoomMemberTask({
120
+ workspace: getRoomRecord(input.roomId)?.workspace?.path,
118
121
  taskId: input.taskId,
119
122
  roomId: input.roomId,
120
123
  roomIdentityCid,
@@ -141,6 +144,10 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
141
144
  try {
142
145
  const role = parse(readFileSync(`${dir}/role.yaml`, 'utf8'));
143
146
  const startup = role.roomMemberStartup;
147
+ const workspace = getRoomRecord(roomId)?.workspace;
148
+ if (workspace && (role.cwd !== workspace.path
149
+ || JSON.stringify(startup?.workspace) !== JSON.stringify(workspace)))
150
+ return false;
144
151
  return role.identity === member.name
145
152
  && startup?.room_id === roomId
146
153
  && startup.room_identity_cid === roomIdentityCid
@@ -149,8 +156,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
149
156
  && (startup.anonymous ?? false) === anonymous
150
157
  && sha256Text(startup.task ?? '') === taskSha
151
158
  && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
152
- && typeof startup.invite === 'string'
153
- && startup.invite.length > 0;
159
+ && startup.invite === '';
154
160
  }
155
161
  catch {
156
162
  return false;
@@ -234,25 +240,9 @@ async function retainRunningLaunch(input) {
234
240
  }
235
241
  return false;
236
242
  }
237
- /**
238
- * Member-launch publication window: for a task-bound room, the durable launch
239
- * intent, spawn, and launch record publish under the common task-operation
240
- * lock so an accepted deletion linearizes strictly before (bounded abort — no
241
- * spawn happens) or strictly after (the seat's launch record is visible to
242
- * deletion retirement). The lock is held per member only, never across
243
- * seat-wait loops.
244
- */
245
243
  async function launchMember(input) {
246
- const taskId = input.provision.taskId;
247
- if (!taskId)
248
- return launchMemberUnlocked(input);
249
- return withFileLock(taskOperationLockPath(taskId), () => {
250
- if (taskDeletionState(taskId) !== 'none')
251
- throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
252
- return launchMemberUnlocked(input);
253
- }, {}, TASK_OPERATION_LOCK_STALE_MS);
254
- }
255
- async function launchMemberUnlocked(input) {
244
+ // provisionMembers holds the task and room lifecycle locks through every launch.
245
+ assertProvisioningOpen(input.provision);
256
246
  const { provision, member, settings, startup } = input;
257
247
  const seat = getRoomRecord(provision.roomId).member_seats
258
248
  .find(candidate => candidate.role_name === member.name);
@@ -345,7 +335,14 @@ function reconcileMemberSeats(roomId, members, observed, ownerSeatCid) {
345
335
  && seat.seat_state === 'active');
346
336
  const seats = current.member_seats.map(seat => {
347
337
  const member = members.find(candidate => candidate.name === seat.role_name);
338
+ const readiness = readRoomReadiness(current.room_identity_cid, member.name);
339
+ if (!readiness || readiness.room !== roomId || readiness.invite !== seat.invite_id) {
340
+ complete = false;
341
+ return seat;
342
+ }
348
343
  const found = exactCoworkSeat(observed, member, seat.invite_id);
344
+ if (found && found.identity_cid !== readiness.cid)
345
+ throw new Error('Cowork seat CID does not match supervisor provisioned identity');
349
346
  if (!found || found.seat_state !== 'active') {
350
347
  complete = false;
351
348
  return seat;
@@ -364,9 +361,29 @@ function assertCoworkRoomPolicy(room, expectedAnonymous) {
364
361
  if ((room.anonymous ?? false) !== expectedAnonymous)
365
362
  throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
366
363
  }
364
+ function assertProvisioningOpen(input) {
365
+ if (input.taskId && getTask(input.taskId).terminal_intent)
366
+ throw new Error(`task ${input.taskId} has an accepted terminal intent; refusing to provision members`);
367
+ const room = getRoomRecord(input.roomId);
368
+ if (room?.state === 'closing' || room?.state === 'closed')
369
+ throw new Error(`room ${input.roomId} is ${room.state}; refusing to provision members`);
370
+ }
367
371
  export async function provisionMembers(input) {
372
+ // Same lock order as terminal/deletion settlement. Serialize concurrent retries
373
+ // and standalone retirement as well as member publication. Seat waits are bounded.
374
+ const run = () => withFileLock(roomCloseLockPath(input.roomId), () => {
375
+ assertProvisioningOpen(input);
376
+ if (!getRoomRecord(input.roomId))
377
+ throw new Error(`room ${input.roomId} is missing; refusing provisioning`);
378
+ return provisionMembersUnlocked(input);
379
+ }, {}, CLOSE_LOCK_STALE_MS);
380
+ return input.taskId
381
+ ? withFileLock(taskOperationLockPath(input.taskId), run, {}, TASK_OPERATION_LOCK_STALE_MS)
382
+ : run();
383
+ }
384
+ async function provisionMembersUnlocked(input) {
368
385
  const { cfg, cowork, roomId, taskId, template } = input;
369
- // Deletion-epoch pre-check; each member launch re-checks under the lock.
386
+ // The common task-operation lock protects this epoch through all member launches.
370
387
  if (taskId && taskDeletionState(taskId) !== 'none')
371
388
  throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
372
389
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
@@ -378,6 +395,12 @@ export async function provisionMembers(input) {
378
395
  const existing = getRoomRecord(roomId);
379
396
  if (!existing?.room_identity_cid)
380
397
  throw new Error(`room ${roomId} has no pinned room identity CID`);
398
+ if (existing.workspace) {
399
+ validateWorkspace(existing.workspace, taskId ? 'task' : 'room', taskId ?? roomId);
400
+ ensureWorkspace(existing.workspace);
401
+ for (const setting of settings.values())
402
+ setting.definition.cwd = existing.workspace.path;
403
+ }
381
404
  const roomIdentityCid = existing.room_identity_cid;
382
405
  const ownerSeatCid = existing.owner_seat_cid ?? null;
383
406
  const roomPolicy = storedRoomLaunchPolicy(existing.room_policy);
@@ -439,17 +462,31 @@ export async function provisionMembers(input) {
439
462
  provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
440
463
  }))
441
464
  continue;
465
+ // A previous failed launch keeps its invite pointer durably. Never
466
+ // overwrite that requirement until the supported revoke has succeeded;
467
+ // a transport failure must fence subsequent invite issuance too.
468
+ if (currentSeat.invite_id) {
469
+ const observed = await cowork.getRoom(roomId);
470
+ if (!observed || observed.identity_cid !== roomIdentityCid)
471
+ throw new Error('cannot verify room before failed-attempt invite cleanup');
472
+ if (observed.seats.some(seat => seat.invite_id === currentSeat.invite_id
473
+ && seat.seat_state !== 'removed'))
474
+ throw new Error('failed-attempt invite has an admitted seat; reconcile before replacing the member');
475
+ await cowork.revokeInvite(roomId, currentSeat.invite_id);
476
+ }
442
477
  const issued = await cowork.issueInvite(roomId, {
443
478
  mode: 'one_time', role: member.coworkRole, min_accepts: 1,
444
479
  });
445
- const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
446
- updateMemberSeats(roomId, seats);
447
480
  try {
481
+ assertProvisioningOpen(input);
482
+ const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
483
+ updateMemberSeats(roomId, seats);
448
484
  await launchMember({
449
485
  provision: input,
450
486
  member,
451
487
  settings: settings.get(member.name),
452
488
  startup: {
489
+ workspace: existing.workspace,
453
490
  room_id: roomId,
454
491
  room_identity_cid: roomIdentityCid,
455
492
  identity_name: member.name,
@@ -463,7 +500,12 @@ export async function provisionMembers(input) {
463
500
  });
464
501
  }
465
502
  catch (error) {
466
- await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
503
+ try {
504
+ await cowork.revokeInvite(roomId, issued.invite_id);
505
+ }
506
+ catch (cleanupError) {
507
+ throw new AggregateError([error, cleanupError], 'member launch failed and invite cleanup is unresolved; retry must revoke the retained requirement first');
508
+ }
467
509
  throw error;
468
510
  }
469
511
  }
@@ -491,7 +533,7 @@ export async function provisionMembers(input) {
491
533
  const remote = await cowork.recoverRoom(roomId);
492
534
  assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
493
535
  const reconciled = reconcileMemberSeats(roomId, members, remote.seats, ownerSeatCid ?? undefined);
494
- if (reconciled.complete)
536
+ if (reconciled.complete && remote.state === 'active')
495
537
  break;
496
538
  if (policy.now() >= deadline) {
497
539
  advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
@@ -531,6 +573,76 @@ export async function provisionMembers(input) {
531
573
  activateTask(taskId);
532
574
  return record;
533
575
  }
576
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
577
+ export async function reconcileExistingTaskMembers(input) {
578
+ return withFileLock(taskOperationLockPath(input.taskId), async () => {
579
+ const task = getTask(input.taskId);
580
+ if (!['provisioning', 'active'].includes(task.state) || task.terminal_intent || taskDeletionState(input.taskId) !== 'none'
581
+ || !task.room_id)
582
+ throw new Error('task is not open for existing-member reconciliation');
583
+ const roomId = task.room_id;
584
+ return withFileLock(roomCloseLockPath(roomId), async () => {
585
+ const current = getRoomRecord(roomId);
586
+ if (!current || !['provisioning', 'active'].includes(current.state) || current.close
587
+ || current.task_id !== input.taskId || !current.room_identity_cid
588
+ || task.room_identity_cid !== current.room_identity_cid)
589
+ throw new Error('room is not open for existing-member reconciliation');
590
+ const names = new Set(input.expectedLaunches.map(seat => seat.role_name));
591
+ if (!names.size || names.size !== input.expectedLaunches.length
592
+ || current.member_seats.length !== names.size
593
+ || current.member_seats.some(seat => !names.has(seat.role_name)))
594
+ throw new Error('expected launch roster does not match the persisted room');
595
+ const remote = await input.cowork.getRoom(roomId);
596
+ if (!remote || remote.room_id !== roomId || remote.identity_cid !== current.room_identity_cid
597
+ || remote.state !== 'active')
598
+ throw new Error('Cowork room is not the exact active room');
599
+ assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
600
+ if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
601
+ throw new Error('expected Owner seat is not active');
602
+ const seats = [];
603
+ for (const seat of current.member_seats) {
604
+ const expected = input.expectedLaunches.find(item => item.role_name === seat.role_name);
605
+ const launch = seat.launch;
606
+ if (seat.seat_state === 'removed' || !seat.invite_id || launch?.state !== 'launched'
607
+ || launch.launch_id !== expected.launch_id || !launch.action_id || !launch.mission_sha256
608
+ || (seat.identity_cid && seat.identity_cid !== expected.identity_cid))
609
+ throw new Error('persisted member launch does not match reconciliation evidence');
610
+ const dir = agentDir(seat.role_name, true);
611
+ if (!launchMatches(dir, { name: seat.role_name, coworkRole: seat.cowork_role }, launch.action_id, launch.mission_sha256, roomId, current.room_identity_cid, seat.invite_id, storedRoomLaunchPolicy(current.room_policy).anonymous))
612
+ throw new Error('member startup provenance mismatch');
613
+ const supervisor = readTempSupervisor(dir);
614
+ if (!supervisor || supervisor.role !== seat.role_name || supervisor.launchId !== expected.launch_id
615
+ || await tempSupervisorLiveness(dir) !== 'running')
616
+ throw new Error('expected member supervisor is not running');
617
+ const ready = readRoomReadiness(current.room_identity_cid, seat.role_name);
618
+ const matches = remote.seats.filter(item => item.display_name === seat.role_name
619
+ && item.seat_state !== 'removed');
620
+ const found = matches[0];
621
+ if (!ready || ready.room !== roomId || ready.invite !== seat.invite_id
622
+ || ready.cid !== expected.identity_cid || matches.length !== 1 || !found
623
+ || found.identity_cid !== expected.identity_cid || found.role !== seat.cowork_role
624
+ || found.seat_state !== 'active' || found.invite_id !== seat.invite_id)
625
+ throw new Error('member readiness or authenticated Cowork seat mismatch');
626
+ seats.push({ ...seat, identity_cid: expected.identity_cid, seat_state: 'active' });
627
+ }
628
+ if (input.checkOnly)
629
+ return current;
630
+ // No mutation until the complete roster has passed; these writes are replayable
631
+ // under the same task/room lifecycle locks if interrupted between files.
632
+ updateMemberSeats(roomId, seats);
633
+ updateTaskMembers(input.taskId, seats.map(seat => ({ name: seat.role_name,
634
+ identity_cid: seat.identity_cid, slot: seat.slot, cowork_role: seat.cowork_role })));
635
+ if (getTask(input.taskId).blocked)
636
+ unblockTask(input.taskId);
637
+ if (current.state !== 'active')
638
+ advanceSaga(roomId, 'activate', 6);
639
+ const activated = current.state === 'active' ? getRoomRecord(roomId) : activateRoom(roomId);
640
+ if (task.state !== 'active')
641
+ activateTask(input.taskId);
642
+ return activated;
643
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
644
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
645
+ }
534
646
  export async function cleanupMembers(input) {
535
647
  const room = getRoomRecord(input.roomId);
536
648
  if (!room)
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:f
2
2
  import { join } from 'node:path';
3
3
  import { replaceFileAtomically } from '../atomic-file.js';
4
4
  import { stateRoot } from '../paths.js';
5
+ import { planWorkspace, ensureWorkspace } from './workspace.js';
6
+ import { getTask } from './task-state.js';
5
7
  import { storedRoomLaunchPolicy } from './types.js';
6
8
  import { releaseLaunchSnapshot } from './launch-snapshot.js';
7
9
  export const roomsDir = () => join(stateRoot(), 'rooms');
@@ -25,6 +27,8 @@ export function createRoomRecord(input) {
25
27
  const requested = storedRoomLaunchPolicy(input.room_policy);
26
28
  if (JSON.stringify(before) !== JSON.stringify(requested))
27
29
  throw new RoomStateError(`room ${input.room_id} launch policy mismatch`);
30
+ if (existing.workspace)
31
+ ensureWorkspace(existing.workspace);
28
32
  return existing;
29
33
  }
30
34
  const record = {
@@ -40,7 +44,10 @@ export function createRoomRecord(input) {
40
44
  state: 'provisioning',
41
45
  created_at: new Date().toISOString(),
42
46
  };
47
+ record.workspace = input.task_id ? getTask(input.task_id).workspace : planWorkspace('room', input.room_id);
43
48
  writeRoom(record);
49
+ if (record.workspace)
50
+ ensureWorkspace(record.workspace);
44
51
  return record;
45
52
  }
46
53
  export function getRoomRecord(id) {
@@ -112,6 +119,8 @@ export function setOwnerSeat(id, ownerSeatCid, inviteFingerprint) {
112
119
  }
113
120
  export function updateMemberSeats(id, seats) {
114
121
  const r = readRoom(id);
122
+ if (r.state === 'closing' || r.state === 'closed')
123
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
115
124
  r.member_seats = seats;
116
125
  writeRoom(r);
117
126
  return r;
@@ -135,6 +144,8 @@ const LAUNCH_ORDER = [
135
144
  ];
136
145
  export function updateMemberStartup(id, roleName, update) {
137
146
  const r = readRoom(id);
147
+ if (r.state === 'closing' || r.state === 'closed')
148
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
138
149
  const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
139
150
  if (!seat)
140
151
  throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
@@ -151,6 +162,8 @@ export function updateMemberStartup(id, roleName, update) {
151
162
  }
152
163
  export function activateRoom(id) {
153
164
  const r = readRoom(id);
165
+ if (r.state === 'closing' || r.state === 'closed')
166
+ throw new RoomStateError(`room ${id} is ${r.state}; activation is fenced`);
154
167
  r.state = 'active';
155
168
  r.saga = { phase: 'completed', step_index: 0 };
156
169
  delete r.provisioning_detail;
@@ -1,3 +1,4 @@
1
+ import type { ArchivedMemberAbsence } from './types.js';
1
2
  import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase } from './types.js';
2
3
  export declare const tasksDir: () => string;
3
4
  export declare class TaskStateError extends Error {
@@ -74,6 +75,7 @@ export interface TaskDeletionReceipt {
74
75
  original_state: TaskState;
75
76
  room_id?: string;
76
77
  member_count: number;
78
+ archived_absences?: ArchivedMemberAbsence[];
77
79
  settled_at?: string;
78
80
  result?: 'deleted';
79
81
  }
@@ -122,6 +124,10 @@ export declare function advanceTaskDeletionMember(id: string, name: string, phas
122
124
  interface SeatEvidence {
123
125
  role_name: string;
124
126
  identity_cid?: string;
127
+ launch?: {
128
+ launch_id?: string;
129
+ action_id?: string;
130
+ };
125
131
  retirement?: {
126
132
  phase: TaskDeletionMemberPhase;
127
133
  launch_id: string;
@@ -146,10 +152,14 @@ export declare function upsertTaskDeletionMembersFromSeats(id: string, seats: Re
146
152
  *
147
153
  * The room saga is trusted for phase jumps, but partial or corrupt retained
148
154
  * records must not become false success: every seat must be identity_absent;
149
- * a real launch requires archive evidence; an identity-less seat is accepted
150
- * only via the never-launched proof.
155
+ * a real launch requires archive evidence; an identity-less seat requires
156
+ * never-launched proof or a freshly verified archived-absence checkpoint.
157
+ */
158
+ export declare function importTaskDeletionRetirementEvidence(id: string, seats: ReadonlyArray<SeatEvidence>, proofs?: ReadonlyArray<ArchivedMemberAbsence>): TaskRecord;
159
+ /** Settlement-only checkpoint, under the task-operation lock after fresh archive verification.
160
+ * From here retries still verify live absence, but owned archives may be consumed.
151
161
  */
152
- export declare function importTaskDeletionRetirementEvidence(id: string, seats: ReadonlyArray<SeatEvidence>): TaskRecord;
162
+ export declare function beginTaskWorkspaceCleanup(id: string): void;
153
163
  /**
154
164
  * Physically remove a deletion-pending task record. Settlement-only: callers
155
165
  * must have completed member retirement and room cleanup first. Missing