@ours.network/fleet 1.0.1 → 1.0.3

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.
@@ -1,13 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { parse } from 'yaml';
4
- import { attachOursClient, } from '@ours.network/sdk/client';
5
- import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, updateRoomRoleBriefing, updateRoomHistoryCursor, activateRoom, getRoomRecord, } from './room-state.js';
6
- import { activateTask, updateTaskMembers, failTask, blockTask, unblockTask, getTask, } from './task-state.js';
4
+ import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
5
+ import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
7
6
  import { spawnTemp } from '../spawn.js';
8
7
  import { findRole } from '../config.js';
8
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
9
+ import { controlRequest } from '../session/control.js';
10
+ import { SessionControlError } from '../session/types.js';
9
11
  import { closeManagedRoom } from './close.js';
10
- import { buildRoomMemberCharter, reconcileBriefingHistory, sha256Text, } from './member-startup.js';
12
+ import { buildRoomMemberTask, sha256Text } from './member-startup.js';
11
13
  import { agentDir } from '../paths.js';
12
14
  import { readProvenance } from '../creation.js';
13
15
  import { readTempSupervisor, secureStoppedTempArchive, tempArchiveForCreationAction, tempSupervisorLiveness, } from '../temp-lifecycle.js';
@@ -19,6 +21,28 @@ export function getBinPath() {
19
21
  return process.argv[1];
20
22
  }
21
23
  }
24
+ async function spawnRoomMember(options, binPath) {
25
+ const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
26
+ if (!stateDir)
27
+ return {
28
+ statePath: await spawnTemp(options, binPath),
29
+ creationActionId: options.creationActionId,
30
+ };
31
+ const response = await controlRequest(stateDir, { command: 'fleet_spawn', spawn: options }, 10 * 60_000);
32
+ if (!response.ok) {
33
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'managed room member spawn failed');
34
+ }
35
+ const result = response.result;
36
+ const expectedCaller = process.env[FLEET_PROXY_CALLER_ENV];
37
+ if (expectedCaller && result.caller !== expectedCaller) {
38
+ throw new Error(`fleet proxy caller mismatch: expected '${expectedCaller}', got '${result.caller}'`);
39
+ }
40
+ return {
41
+ statePath: result.statePath,
42
+ creationActionId: result.creationActionId,
43
+ callerRole: result.caller,
44
+ };
45
+ }
22
46
  function shortId(id) { return id.slice(0, 8); }
23
47
  function expandMembers(template, prefix) {
24
48
  const result = [];
@@ -35,135 +59,90 @@ function expandMembers(template, prefix) {
35
59
  }
36
60
  return result;
37
61
  }
38
- async function createMemberIdentity(name, bio) {
39
- const client = await attachOursClient({
40
- env: process.env,
41
- leaseToken: `ours-fleet-member-create-${process.pid}-${randomUUID()}`,
42
- clientPid: process.pid,
43
- });
44
- try {
45
- const result = await client.createIdentity({
46
- name,
47
- bio,
48
- exposeLocal: true,
49
- localAutoAccept: true,
50
- });
51
- return { cid: result.info.cid, client };
52
- }
53
- finally {
54
- await client.releaseLease();
55
- }
56
- }
57
- async function removeMemberIdentity(name) {
58
- try {
59
- const client = await attachOursClient({
60
- env: process.env,
61
- leaseToken: `ours-fleet-member-cleanup-${process.pid}-${randomUUID()}`,
62
- clientPid: process.pid,
63
- });
64
- try {
65
- await client.removeIdentity({ name });
66
- }
67
- finally {
68
- await client.releaseLease();
69
- }
70
- }
71
- catch { /* best-effort cleanup */ }
72
- }
73
- async function redeemRoomInviteAsMember(member, invite, roomIdentityCid) {
74
- const client = await attachOursClient({
75
- env: process.env,
76
- leaseToken: `ours-fleet-member-admit-${process.pid}-${randomUUID()}`,
77
- clientPid: process.pid,
78
- });
79
- try {
80
- const bound = await client.chooseIdentity({ name: member.name, force: false });
81
- if (bound.cid.toLowerCase() !== member.cid.toLowerCase()) {
82
- throw new Error(`member ${member.name} identity CID mismatch: expected ${member.cid}, found ${bound.cid}`);
83
- }
84
- const added = await client.addContact({ invite });
85
- if (added.cid.toLowerCase() !== roomIdentityCid.toLowerCase()) {
86
- throw new Error(`member ${member.name} invite resolved to ${added.cid}; expected room ${roomIdentityCid}`);
87
- }
88
- }
89
- finally {
90
- await client.releaseLease().catch(() => { });
91
- }
92
- }
93
62
  function settingsFor(member, cfg) {
94
63
  let refRole;
95
64
  try {
96
65
  refRole = findRole(cfg, member.roleRef);
97
66
  }
98
67
  catch { /* no ref role */ }
68
+ const permissions = member.overrides?.permissions;
99
69
  return {
100
70
  model: member.overrides?.model ?? refRole?.model,
101
71
  harness: member.overrides?.harness ?? refRole?.harness,
102
72
  cwd: member.overrides?.cwd ?? refRole?.cwd,
103
73
  persona: member.overrides?.persona ?? refRole?.persona,
74
+ approval: permissions?.approval,
75
+ filesystem: permissions?.filesystem,
76
+ unattended: permissions?.unattended,
104
77
  };
105
78
  }
106
- async function roomHistory(cowork, roomId) {
107
- const records = [];
108
- let after = getRoomRecord(roomId)?.history_cursor ?? 0;
109
- for (let page = 0; page < 100; page++) {
110
- const next = await cowork.getHistory(roomId, { after, limit: 200 });
111
- records.push(...next.records);
112
- if (next.raw_count === 0)
113
- return { records, cursor: after, exhausted: true };
114
- if (next.next_after <= after)
115
- throw new Error('Cowork room history cursor did not advance');
116
- after = next.next_after;
117
- if (next.raw_count < 200)
118
- return { records, cursor: after, exhausted: true };
119
- }
120
- return { records, cursor: after, exhausted: false };
121
- }
122
- async function assertCurrentRoleBriefings(cowork, roomId, definitions) {
123
- const room = await cowork.getRoom(roomId);
124
- if (!room)
125
- throw new Error(`Cowork room ${roomId} is missing during briefing verification`);
126
- for (const [role, expected] of definitions) {
127
- const current = room.role_briefings[role];
128
- if (!current || current.text !== expected.text
129
- || sha256Text(current.text) !== expected.sha256
130
- || (expected.version !== undefined && current.version !== expected.version)) {
131
- throw new Error(`Cowork current role briefing drift for ${role}`);
132
- }
133
- }
134
- }
135
- function startupMission(gate) {
136
- return [
137
- 'Complete the dedicated room startup gate in briefing.md before readiness.',
138
- `Expected room ${gate.room_id} (${gate.room_identity_cid}), role ${gate.briefing_role},`,
139
- `briefing version ${gate.briefing_version}, sha256 ${gate.briefing_sha256}.`,
140
- ].join(' ');
79
+ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
80
+ return buildRoomMemberTask({
81
+ taskId: input.taskId,
82
+ roomId: input.roomId,
83
+ roomIdentityCid,
84
+ ownerSeatCid,
85
+ goal: input.goal,
86
+ brief: input.brief,
87
+ contract: input.template.contract,
88
+ member: {
89
+ role_name: member.name,
90
+ cowork_role: member.coworkRole,
91
+ persona: settings.persona,
92
+ },
93
+ roster: members.map(candidate => ({
94
+ role_name: candidate.name,
95
+ cowork_role: candidate.coworkRole,
96
+ })),
97
+ });
141
98
  }
142
- function launchMatches(dir, member, actionId, missionSha, gate) {
99
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
143
100
  const provenance = readProvenance(dir);
144
101
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
145
102
  return false;
146
103
  try {
147
104
  const role = parse(readFileSync(`${dir}/role.yaml`, 'utf8'));
105
+ const startup = role.roomMemberStartup;
148
106
  return role.identity === member.name
149
107
  && typeof role.mission === 'string'
150
- && sha256Text(role.mission) === missionSha
151
- && JSON.stringify(role.roomStartupGate) === JSON.stringify(gate);
108
+ && sha256Text(role.mission) === taskSha
109
+ && startup?.room_id === roomId
110
+ && startup.room_identity_cid === roomIdentityCid
111
+ && startup.identity_name === member.name
112
+ && startup.role === member.coworkRole
113
+ && startup.task === role.mission
114
+ && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
115
+ && typeof startup.invite === 'string'
116
+ && startup.invite.length > 0;
152
117
  }
153
118
  catch {
154
119
  return false;
155
120
  }
156
121
  }
157
- async function ensureLaunch(input) {
158
- const { provision, member, settings, gate } = input;
122
+ async function retainRunningLaunch(input) {
123
+ const { provision, member, task, roomIdentityCid } = input;
159
124
  let seat = getRoomRecord(provision.roomId).member_seats
160
125
  .find(candidate => candidate.role_name === member.name);
161
- const mission = startupMission(gate);
162
- const missionSha = sha256Text(mission);
163
126
  const dir = agentDir(member.name, true);
164
- if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched') && existsSync(dir)) {
165
- if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, seat.launch.mission_sha256 ?? '', gate)) {
166
- throw new Error(`existing launch for ${member.name} does not match its durable intent`);
127
+ const taskSha = sha256Text(task);
128
+ if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
129
+ || seat.launch?.state === 'failed') && existsSync(dir)) {
130
+ if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
131
+ const provenance = readProvenance(dir);
132
+ const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
133
+ && Boolean(seat.launch.caller_role)
134
+ && provenance?.surface === 'agent'
135
+ && provenance.callerRole === seat.launch.caller_role
136
+ && typeof provenance.creationActionId === 'string'
137
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
138
+ if (!adoptable)
139
+ throw new Error(`existing launch for ${member.name} does not match its durable intent`);
140
+ updateMemberStartup(provision.roomId, member.name, { launch: {
141
+ ...seat.launch, state: 'intent', action_id: provenance.creationActionId,
142
+ updated_at: new Date().toISOString(),
143
+ } });
144
+ seat = getRoomRecord(provision.roomId).member_seats
145
+ .find(candidate => candidate.role_name === member.name);
167
146
  }
168
147
  const supervisor = readTempSupervisor(dir);
169
148
  if (!supervisor || supervisor.role !== member.name)
@@ -171,209 +150,165 @@ async function ensureLaunch(input) {
171
150
  const live = await tempSupervisorLiveness(dir);
172
151
  if (live === 'unknown')
173
152
  throw new Error(`existing launch for ${member.name} has unknown liveness`);
153
+ const retainedLaunch = seat.launch;
174
154
  if (live === 'running') {
175
155
  updateMemberStartup(provision.roomId, member.name, { launch: {
176
- ...seat.launch, state: 'launched', launch_id: supervisor.launchId,
156
+ ...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
177
157
  updated_at: new Date().toISOString(),
178
158
  } });
179
- return;
159
+ return true;
180
160
  }
181
161
  await secureStoppedTempArchive(member.name, supervisor.launchId);
182
162
  updateMemberStartup(provision.roomId, member.name, { launch: {
183
- ...seat.launch, state: 'stopped', launch_id: supervisor.launchId,
163
+ ...retainedLaunch, state: 'stopped', launch_id: supervisor.launchId,
184
164
  updated_at: new Date().toISOString(),
185
165
  } });
186
- seat = getRoomRecord(provision.roomId).member_seats
187
- .find(candidate => candidate.role_name === member.name);
166
+ return false;
188
167
  }
189
- else if (seat.launch?.state === 'launched' && !existsSync(dir)) {
168
+ if (seat.launch?.state === 'launched' && !existsSync(dir)) {
190
169
  if (!seat.launch.launch_id || !seat.launch.action_id)
191
170
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
192
171
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
193
- if (!launchMatches(archive, member, seat.launch.action_id, missionSha, gate))
172
+ if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
194
173
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
174
+ }
195
175
  updateMemberStartup(provision.roomId, member.name, { launch: {
196
176
  ...seat.launch, state: 'stopped', updated_at: new Date().toISOString(),
197
177
  } });
198
- seat = getRoomRecord(provision.roomId).member_seats
199
- .find(candidate => candidate.role_name === member.name);
178
+ return false;
200
179
  }
201
- else if (seat.launch?.state === 'intent' && !existsSync(dir)) {
180
+ if (seat.launch?.state === 'intent' && !existsSync(dir)) {
202
181
  if (!seat.launch.action_id)
203
182
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
204
183
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
205
- if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, missionSha, gate)) {
184
+ if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
206
185
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
207
186
  }
208
187
  updateMemberStartup(provision.roomId, member.name, { launch: {
209
188
  ...seat.launch, state: 'stopped', launch_id: archive.launchId,
210
189
  updated_at: new Date().toISOString(),
211
190
  } });
212
- seat = getRoomRecord(provision.roomId).member_seats
213
- .find(candidate => candidate.role_name === member.name);
214
191
  }
215
- const actionId = seat.launch?.state === 'intent' && seat.launch.action_id
216
- ? seat.launch.action_id : randomUUID();
217
- const attempt = seat.launch?.state === 'intent'
218
- ? seat.launch.attempt : (seat.launch?.attempt ?? 0) + 1;
192
+ return false;
193
+ }
194
+ async function launchMember(input) {
195
+ const { provision, member, settings, startup } = input;
196
+ const seat = getRoomRecord(provision.roomId).member_seats
197
+ .find(candidate => candidate.role_name === member.name);
198
+ const actionId = randomUUID();
199
+ let effectiveActionId = actionId;
200
+ const attempt = (seat.launch?.attempt ?? 0) + 1;
201
+ const taskSha = sha256Text(startup.task);
202
+ const proxyCaller = process.env[FLEET_PROXY_STATE_DIR_ENV]
203
+ ? process.env[FLEET_PROXY_CALLER_ENV] : undefined;
219
204
  updateMemberStartup(provision.roomId, member.name, { launch: {
220
- state: 'intent', attempt, action_id: actionId, mission_sha256: missionSha,
205
+ state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
206
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
221
207
  updated_at: new Date().toISOString(),
222
208
  } });
223
- const launchedDir = await spawnTemp({
224
- name: member.name, temp: true, identity: member.name, mission,
225
- model: settings.model, harness: settings.harness, cwd: settings.cwd,
226
- surface: 'agent', creationActionId: actionId, roomStartupGate: gate,
227
- }, provision.binPath);
228
- const supervisor = readTempSupervisor(launchedDir);
229
- if (!supervisor || supervisor.role !== member.name
230
- || !launchMatches(launchedDir, member, actionId, missionSha, gate)) {
231
- throw new Error(`new launch for ${member.name} did not persist matching provenance`);
209
+ try {
210
+ const launched = await spawnRoomMember({
211
+ name: member.name,
212
+ temp: true,
213
+ identity: member.name,
214
+ mission: startup.task,
215
+ model: settings.model,
216
+ harness: settings.harness,
217
+ cwd: settings.cwd,
218
+ approval: settings.approval,
219
+ filesystem: settings.filesystem,
220
+ unattended: settings.unattended,
221
+ surface: 'agent',
222
+ creationActionId: actionId,
223
+ roomMemberStartup: startup,
224
+ }, provision.binPath);
225
+ const launchedDir = launched.statePath;
226
+ effectiveActionId = launched.creationActionId;
227
+ if (launched.creationActionId !== actionId) {
228
+ updateMemberStartup(provision.roomId, member.name, { launch: {
229
+ state: 'intent', attempt, action_id: launched.creationActionId,
230
+ mission_sha256: taskSha, updated_at: new Date().toISOString(),
231
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
232
+ } });
233
+ }
234
+ const supervisor = readTempSupervisor(launchedDir);
235
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
236
+ throw new Error(`new launch for ${member.name} did not persist matching provenance`);
237
+ }
238
+ updateMemberStartup(provision.roomId, member.name, { launch: {
239
+ state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
240
+ ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
241
+ launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
242
+ } });
243
+ }
244
+ catch (error) {
245
+ updateMemberStartup(provision.roomId, member.name, { launch: {
246
+ state: 'failed', attempt, action_id: effectiveActionId, mission_sha256: taskSha,
247
+ ...(proxyCaller ? { caller_role: proxyCaller } : {}),
248
+ updated_at: new Date().toISOString(),
249
+ error: error instanceof Error ? error.message : String(error),
250
+ } });
251
+ throw error;
232
252
  }
233
- updateMemberStartup(provision.roomId, member.name, { launch: {
234
- state: 'launched', attempt, action_id: actionId, mission_sha256: missionSha,
235
- launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
236
- } });
253
+ }
254
+ function exactCoworkSeat(observed, member, expectedInviteId) {
255
+ const named = observed.filter(seat => seat.display_name === member.name && seat.seat_state !== 'removed');
256
+ if (named.length > 1)
257
+ throw new Error(`Cowork has multiple live seats named ${member.name}`);
258
+ const seat = named[0];
259
+ if (!seat)
260
+ return undefined;
261
+ if (seat.role !== member.coworkRole)
262
+ throw new Error(`Cowork seat ${member.name} has role ${seat.role}; expected ${member.coworkRole}`);
263
+ if (expectedInviteId && seat.invite_id !== expectedInviteId) {
264
+ throw new Error(`Cowork seat ${member.name} used invite ${seat.invite_id}; expected ${expectedInviteId}`);
265
+ }
266
+ return seat;
267
+ }
268
+ function reconcileMemberSeats(roomId, members, observed) {
269
+ const current = getRoomRecord(roomId);
270
+ let complete = true;
271
+ const seats = current.member_seats.map(seat => {
272
+ const member = members.find(candidate => candidate.name === seat.role_name);
273
+ const found = exactCoworkSeat(observed, member, seat.invite_id);
274
+ if (!found || found.seat_state !== 'active') {
275
+ complete = false;
276
+ return seat;
277
+ }
278
+ return {
279
+ ...seat,
280
+ identity_cid: found.identity_cid,
281
+ invite_id: found.invite_id,
282
+ seat_state: 'active',
283
+ };
284
+ });
285
+ updateMemberSeats(roomId, seats);
286
+ return { complete, seats };
237
287
  }
238
288
  export async function provisionMembers(input) {
239
- const { cfg, cowork, roomId, taskId, template, binPath } = input;
289
+ const { cfg, cowork, roomId, taskId, template } = input;
240
290
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
241
- const plan = expandMembers(template, prefix);
242
- const createdIdentities = [];
243
- const members = [];
291
+ const members = expandMembers(template, prefix);
244
292
  const existing = getRoomRecord(roomId);
245
293
  if (!existing?.room_identity_cid)
246
294
  throw new Error(`room ${roomId} has no pinned room identity CID`);
247
295
  const roomIdentityCid = existing.room_identity_cid;
248
- const persistedSeats = existing?.member_seats ?? [];
249
- const resuming = plan.length > 0
250
- && plan.every(p => persistedSeats.some(s => s.role_name === p.name));
251
- let seats;
252
- if (resuming) {
253
- for (const planned of plan) {
254
- const seat = persistedSeats.find(s => s.role_name === planned.name);
255
- members.push({ ...planned, cid: seat.identity_cid });
256
- }
257
- seats = persistedSeats.map(seat => ({
258
- ...seat,
259
- launch: seat.launch ?? {
260
- state: 'pending', attempt: 0, updated_at: existing.created_at,
261
- },
262
- briefing: seat.briefing ?? {
263
- role: seat.cowork_role, state: 'pending', rejected_ack_count: 0,
264
- },
265
- }));
266
- updateMemberSeats(roomId, seats);
267
- }
268
- else {
269
- // create_members saga phase
296
+ const ownerSeatCid = existing.owner_seat_cid ?? null;
297
+ const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
298
+ const resuming = members.length > 0
299
+ && members.every(member => persistedNames.has(member.name));
300
+ if (!resuming) {
270
301
  advanceSaga(roomId, 'create_members', 3);
271
- try {
272
- for (const planned of plan) {
273
- const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
274
- const { cid } = await createMemberIdentity(planned.name, bio);
275
- createdIdentities.push(planned.name);
276
- members.push({ ...planned, cid });
277
- }
278
- }
279
- catch (error) {
280
- for (const name of createdIdentities)
281
- await removeMemberIdentity(name);
282
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
283
- if (taskId)
284
- failTask(taskId, error instanceof Error ? error.message : String(error));
285
- throw error;
286
- }
287
- seats = members.map(m => ({
288
- role_name: m.name,
289
- identity_cid: m.cid,
290
- slot: m.slot,
291
- cowork_role: m.coworkRole,
302
+ updateMemberSeats(roomId, members.map(member => ({
303
+ role_name: member.name,
304
+ slot: member.slot,
305
+ cowork_role: member.coworkRole,
292
306
  seat_state: 'pending',
293
307
  launch: { state: 'pending', attempt: 0, updated_at: new Date().toISOString() },
294
- briefing: {
295
- role: m.coworkRole, state: 'pending', rejected_ack_count: 0,
296
- },
297
- }));
298
- updateMemberSeats(roomId, seats);
299
- if (taskId) {
300
- const taskMembers = members.map(m => ({
301
- name: m.name,
302
- identity_cid: m.cid,
303
- slot: m.slot,
304
- cowork_role: m.coworkRole,
305
- }));
306
- updateTaskMembers(taskId, taskMembers);
307
- }
308
+ })));
308
309
  }
309
310
  const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg)]));
310
- const roster = members.map(member => ({
311
- role_name: member.name, cowork_role: member.coworkRole, identity_cid: member.cid,
312
- }));
313
- const definitions = new Map();
314
- for (const member of members) {
315
- const durable = getRoomRecord(roomId).role_briefings?.[member.coworkRole];
316
- const text = durable?.text ?? buildRoomMemberCharter({
317
- taskId, roomId, roomIdentityCid: existing.room_identity_cid,
318
- ownerSeatCid: existing.owner_seat_cid ?? null,
319
- goal: input.goal, brief: input.brief, contract: template.contract,
320
- member: { ...roster.find(role => role.role_name === member.name),
321
- persona: settings.get(member.name)?.persona },
322
- roster,
323
- });
324
- const sha256 = sha256Text(text);
325
- if (durable && durable.sha256 !== sha256)
326
- throw new Error(`persisted role briefing hash mismatch for ${member.coworkRole}`);
327
- const known = definitions.get(member.coworkRole);
328
- if (known && known.text !== text) {
329
- throw new Error(`members sharing Cowork role ${member.coworkRole} produced different briefing bytes`);
330
- }
331
- definitions.set(member.coworkRole, { text, sha256 });
332
- }
333
- advanceSaga(roomId, 'configure_briefings', 4);
334
- const remoteRoom = await cowork.getRoom(roomId);
335
- if (!remoteRoom)
336
- throw new Error(`Cowork room ${roomId} is missing during briefing configuration`);
337
- for (const [role, definition] of definitions) {
338
- const current = getRoomRecord(roomId).role_briefings?.[role];
339
- const attempt = (current?.attempts ?? 0) + 1;
340
- updateRoomRoleBriefing(roomId, role, {
341
- role, ...definition, ...(current?.version ? { version: current.version } : {}),
342
- state: 'pending', attempts: attempt,
343
- updated_at: new Date().toISOString(),
344
- });
345
- try {
346
- const remote = remoteRoom.role_briefings[role];
347
- if (remote && (remote.text !== definition.text
348
- || sha256Text(remote.text) !== definition.sha256
349
- || (current?.version !== undefined && remote.version !== current.version))) {
350
- throw new Error(`Cowork current role briefing drift for ${role}`);
351
- }
352
- if (!remote && current?.version !== undefined)
353
- throw new Error(`Cowork current role briefing is missing for ${role}`);
354
- const configured = remote ?? await cowork.setRoleBriefing(roomId, {
355
- role, text: definition.text,
356
- });
357
- if (configured.text !== definition.text)
358
- throw new Error(`Cowork returned different briefing bytes for role ${role}`);
359
- updateRoomRoleBriefing(roomId, role, {
360
- role, ...definition, version: configured.version, state: 'configured',
361
- attempts: attempt, updated_at: configured.updated_at,
362
- });
363
- }
364
- catch (error) {
365
- updateRoomRoleBriefing(roomId, role, {
366
- role, ...definition, ...(current?.version ? { version: current.version } : {}),
367
- state: 'failed', attempts: attempt,
368
- updated_at: new Date().toISOString(),
369
- last_error: error instanceof Error ? error.message : String(error),
370
- });
371
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role briefing configuration failed. Retry task or room recover.', 'member_failed');
372
- if (taskId)
373
- blockTask(taskId, error instanceof Error ? error.message : String(error));
374
- throw error;
375
- }
376
- }
311
+ const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid)]));
377
312
  const policy = {
378
313
  timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
379
314
  initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
@@ -381,178 +316,91 @@ export async function provisionMembers(input) {
381
316
  now: input.startupWait?.now ?? Date.now,
382
317
  sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
383
318
  };
384
- advanceSaga(roomId, 'join_role_groups', 5);
319
+ advanceSaga(roomId, 'join_role_groups', 4);
385
320
  try {
386
- const observedSeats = await cowork.getSeats(roomId);
321
+ const initialRoom = await cowork.recoverRoom(roomId);
322
+ reconcileMemberSeats(roomId, members, initialRoom.seats);
387
323
  for (const member of members) {
388
- const observed = observedSeats.find(seat => seat.identity_cid === member.cid
389
- && seat.seat_state !== 'removed');
390
- if (observed && observed.role !== member.coworkRole)
391
- throw new Error(`Cowork seat ${member.cid} has role ${observed.role}; expected ${member.coworkRole}`);
392
- }
393
- const admitted = new Set(observedSeats
394
- .filter(seat => seat.seat_state !== 'removed').map(seat => seat.identity_cid));
395
- const roleGroups = new Map();
396
- for (const member of members.filter(candidate => !admitted.has(candidate.cid))) {
397
- roleGroups.set(member.coworkRole, [
398
- ...(roleGroups.get(member.coworkRole) ?? []), member,
399
- ]);
400
- }
401
- const issuance = await Promise.allSettled([...roleGroups].map(async ([coworkRole, group]) => ({
402
- coworkRole,
403
- group,
404
- ...await cowork.issueInvite(roomId, { role: coworkRole, min_accepts: group.length }),
405
- })));
406
- const admissions = issuance.flatMap(result => result.status === 'fulfilled' ? [result.value] : []);
407
- const issuanceFailure = issuance.find(result => result.status === 'rejected');
408
- if (issuanceFailure?.status === 'rejected') {
409
- const cleanup = await Promise.allSettled(admissions.map(admission => cowork.revokeInvite(roomId, admission.invite_id)));
410
- const cleanupFailures = cleanup.filter(result => result.status === 'rejected');
411
- if (cleanupFailures.length > 0) {
412
- throw new AggregateError([issuanceFailure.reason, ...cleanupFailures.map(result => result.reason)], 'role descriptor issuance failed and one or more issued descriptors could not be revoked');
413
- }
414
- throw issuanceFailure.reason;
415
- }
416
- const redemptions = await Promise.allSettled(admissions.flatMap(({ group, invite }) => group.map(member => redeemRoomInviteAsMember(member, invite, roomIdentityCid))));
417
- const redemptionFailure = redemptions.find(result => result.status === 'rejected');
418
- if (redemptionFailure?.status === 'rejected') {
419
- const cleanup = await Promise.allSettled(admissions.map(admission => cowork.revokeInvite(roomId, admission.invite_id)));
420
- const cleanupFailures = cleanup.filter(result => result.status === 'rejected');
421
- if (cleanupFailures.length > 0) {
422
- throw new AggregateError([redemptionFailure.reason, ...cleanupFailures.map(result => result.reason)], 'member invite redemption failed and one or more issued descriptors could not be revoked');
423
- }
424
- throw redemptionFailure.reason;
425
- }
426
- const deadline = policy.now() + policy.timeoutMs;
427
- let delay = policy.initialDelayMs;
428
- for (;;) {
429
- const reconciled = await cowork.recoverRoom(roomId);
430
- const waiting = members.some(member => {
431
- const seat = reconciled.seats.find(candidate => candidate.identity_cid === member.cid && candidate.seat_state !== 'removed');
432
- if (seat && seat.role !== member.coworkRole) {
433
- throw new Error(`Cowork seat ${member.cid} has role ${seat.role}; expected ${member.coworkRole}`);
324
+ const task = tasks.get(member.name);
325
+ const currentSeat = getRoomRecord(roomId).member_seats
326
+ .find(seat => seat.role_name === member.name);
327
+ if (currentSeat.seat_state === 'active') {
328
+ if (!await retainRunningLaunch({
329
+ provision: input, member, task, roomIdentityCid,
330
+ })) {
331
+ throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
434
332
  }
435
- return !seat;
333
+ continue;
334
+ }
335
+ if (await retainRunningLaunch({ provision: input, member, task, roomIdentityCid }))
336
+ continue;
337
+ const issued = await cowork.issueInvite(roomId, {
338
+ mode: 'one_time', role: member.coworkRole, min_accepts: 1,
436
339
  });
437
- if (!waiting)
438
- break;
439
- if (policy.now() >= deadline) {
440
- throw new Error('Cowork did not authenticate every required room seat after invite redemption');
340
+ const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
341
+ updateMemberSeats(roomId, seats);
342
+ try {
343
+ await launchMember({
344
+ provision: input,
345
+ member,
346
+ settings: settings.get(member.name),
347
+ startup: {
348
+ room_id: roomId,
349
+ room_identity_cid: roomIdentityCid,
350
+ identity_name: member.name,
351
+ invite_id: issued.invite_id,
352
+ invite: issued.invite,
353
+ role: member.coworkRole,
354
+ task,
355
+ owner_seat_cid: ownerSeatCid,
356
+ },
357
+ });
358
+ }
359
+ catch (error) {
360
+ await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
361
+ throw error;
441
362
  }
442
- await policy.sleep(delay);
443
- delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
444
363
  }
445
364
  }
446
365
  catch (error) {
447
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
366
+ const reason = error instanceof Error ? error.message : String(error);
367
+ setSagaError(roomId, reason, 'Member invite or launch failed. Retry with `task recover`.', 'member_failed');
448
368
  if (taskId)
449
- blockTask(taskId, error instanceof Error ? error.message : String(error));
450
- throw error;
451
- }
452
- if (taskId && getTask(taskId).blocked)
453
- unblockTask(taskId);
454
- advanceSaga(roomId, 'wait_seats', 6);
455
- const coworkSeats = await cowork.getSeats(roomId);
456
- const memberCids = new Set(members.map(m => m.cid));
457
- const allActive = [...memberCids].every(cid => coworkSeats.some(s => s.identity_cid === cid
458
- && s.role === members.find(member => member.cid === cid)?.coworkRole
459
- && s.seat_state === 'active'));
460
- if (!allActive) {
461
- advanceSaga(roomId, 'wait_seats', 6, 'waiting_seats');
462
- // Seats are pending — return record for caller to retry later
463
- return getRoomRecord(roomId);
464
- }
465
- const activeSeats = seats.map(s => ({ ...s, seat_state: 'active' }));
466
- updateMemberSeats(roomId, activeSeats);
467
- advanceSaga(roomId, 'launch_work', 7);
468
- try {
469
- for (const member of members) {
470
- const definition = getRoomRecord(roomId).role_briefings?.[member.coworkRole];
471
- if (!definition?.version)
472
- throw new Error(`role ${member.coworkRole} briefing is not configured`);
473
- await ensureLaunch({
474
- provision: input, member, settings: settings.get(member.name),
475
- gate: {
476
- room_id: roomId, room_identity_cid: existing.room_identity_cid,
477
- briefing_role: member.coworkRole, briefing_version: definition.version,
478
- briefing_sha256: definition.sha256,
479
- owner_seat_cid: existing.owner_seat_cid ?? null,
480
- },
481
- });
482
- }
483
- }
484
- catch (error) {
485
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member launch failed. Some agents may be running. Retry with `task recover`.', 'member_failed');
369
+ blockTask(taskId, reason);
486
370
  throw error;
487
371
  }
372
+ advanceSaga(roomId, 'wait_seats', 5);
488
373
  const deadline = policy.now() + policy.timeoutMs;
489
374
  let delay = policy.initialDelayMs;
490
375
  for (;;) {
491
- const history = await roomHistory(cowork, roomId);
492
- let allAcknowledged = true;
493
- let relayFailed = false;
494
- for (const member of members) {
495
- const currentRoom = getRoomRecord(roomId);
496
- const definition = currentRoom.role_briefings[member.coworkRole];
497
- const previous = currentRoom.member_seats
498
- .find(seat => seat.role_name === member.name).briefing;
499
- const result = reconcileBriefingHistory(history.records, {
500
- roomId, roomIdentityCid: existing.room_identity_cid,
501
- ownerSeatCid: existing.owner_seat_cid ?? null,
502
- memberCid: member.cid, role: member.coworkRole,
503
- version: definition.version, sha256: definition.sha256,
504
- }, previous);
505
- if (result.drift) {
506
- setSagaError(roomId, result.drift, 'Cowork role briefing history drifted; inspect current room briefing state.', 'uncertain');
507
- if (taskId)
508
- blockTask(taskId, result.drift);
509
- throw new Error(result.drift);
510
- }
511
- updateMemberStartup(roomId, member.name, { briefing: result.briefing });
512
- allAcknowledged &&= result.briefing.state === 'acknowledged';
513
- relayFailed ||= result.briefing.state === 'relay_failed';
514
- }
515
- updateRoomHistoryCursor(roomId, history.cursor);
516
- if (!history.exhausted) {
517
- const reason = 'Cowork history backlog exceeded 20,000 raw records in one pass; '
518
- + 'cursor progress was preserved, retry recovery to continue.';
519
- setSagaError(roomId, reason, reason, 'uncertain');
520
- throw new Error(reason);
521
- }
522
- if (relayFailed) {
523
- const reason = 'Cowork recorded terminal send_failed for a role briefing; '
524
- + 'same-version redelivery requires Cowork support or seat replacement.';
525
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'briefing_delivery_failed');
526
- setSagaError(roomId, reason, reason, 'briefing_delivery_failed');
527
- if (taskId)
528
- blockTask(taskId, reason);
529
- return getRoomRecord(roomId);
530
- }
531
- if (allAcknowledged)
376
+ const remote = await cowork.recoverRoom(roomId);
377
+ const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
378
+ if (reconciled.complete)
532
379
  break;
533
380
  if (policy.now() >= deadline) {
534
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
381
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
535
382
  return getRoomRecord(roomId);
536
383
  }
537
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
384
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
538
385
  await policy.sleep(delay);
539
386
  delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
540
387
  }
541
- const finalDefinitions = new Map([...definitions].map(([role, definition]) => {
542
- const persisted = getRoomRecord(roomId).role_briefings[role];
543
- return [role, { ...definition, version: persisted.version }];
544
- }));
545
- try {
546
- await assertCurrentRoleBriefings(cowork, roomId, finalDefinitions);
547
- }
548
- catch (error) {
549
- const reason = error instanceof Error ? error.message : String(error);
550
- setSagaError(roomId, reason, 'Cowork current role briefing drifted before activation; inspect room state.', 'uncertain');
551
- if (taskId)
552
- blockTask(taskId, reason);
553
- throw error;
388
+ if (taskId) {
389
+ const taskMembers = getRoomRecord(roomId).member_seats.map(seat => {
390
+ if (!seat.identity_cid)
391
+ throw new Error(`active room member ${seat.role_name} has no authenticated identity CID`);
392
+ return {
393
+ name: seat.role_name,
394
+ identity_cid: seat.identity_cid,
395
+ slot: seat.slot,
396
+ cowork_role: seat.cowork_role,
397
+ };
398
+ });
399
+ updateTaskMembers(taskId, taskMembers);
400
+ if (getTask(taskId).blocked)
401
+ unblockTask(taskId);
554
402
  }
555
- advanceSaga(roomId, 'activate', 9);
403
+ advanceSaga(roomId, 'activate', 6);
556
404
  const record = activateRoom(roomId);
557
405
  if (taskId)
558
406
  activateTask(taskId);