@ours.network/fleet 1.0.1 → 1.0.2

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,12 @@
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';
9
8
  import { closeManagedRoom } from './close.js';
10
- import { buildRoomMemberCharter, reconcileBriefingHistory, sha256Text, } from './member-startup.js';
9
+ import { buildRoomMemberTask, sha256Text } from './member-startup.js';
11
10
  import { agentDir } from '../paths.js';
12
11
  import { readProvenance } from '../creation.js';
13
12
  import { readTempSupervisor, secureStoppedTempArchive, tempArchiveForCreationAction, tempSupervisorLiveness, } from '../temp-lifecycle.js';
@@ -35,61 +34,6 @@ function expandMembers(template, prefix) {
35
34
  }
36
35
  return result;
37
36
  }
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
37
  function settingsFor(member, cfg) {
94
38
  let refRole;
95
39
  try {
@@ -103,66 +47,57 @@ function settingsFor(member, cfg) {
103
47
  persona: member.overrides?.persona ?? refRole?.persona,
104
48
  };
105
49
  }
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(' ');
50
+ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
51
+ return buildRoomMemberTask({
52
+ taskId: input.taskId,
53
+ roomId: input.roomId,
54
+ roomIdentityCid,
55
+ ownerSeatCid,
56
+ goal: input.goal,
57
+ brief: input.brief,
58
+ contract: input.template.contract,
59
+ member: {
60
+ role_name: member.name,
61
+ cowork_role: member.coworkRole,
62
+ persona: settings.persona,
63
+ },
64
+ roster: members.map(candidate => ({
65
+ role_name: candidate.name,
66
+ cowork_role: candidate.coworkRole,
67
+ })),
68
+ });
141
69
  }
142
- function launchMatches(dir, member, actionId, missionSha, gate) {
70
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
143
71
  const provenance = readProvenance(dir);
144
72
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
145
73
  return false;
146
74
  try {
147
75
  const role = parse(readFileSync(`${dir}/role.yaml`, 'utf8'));
76
+ const startup = role.roomMemberStartup;
148
77
  return role.identity === member.name
149
78
  && typeof role.mission === 'string'
150
- && sha256Text(role.mission) === missionSha
151
- && JSON.stringify(role.roomStartupGate) === JSON.stringify(gate);
79
+ && sha256Text(role.mission) === taskSha
80
+ && startup?.room_id === roomId
81
+ && startup.room_identity_cid === roomIdentityCid
82
+ && startup.identity_name === member.name
83
+ && startup.role === member.coworkRole
84
+ && startup.task === role.mission
85
+ && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
86
+ && typeof startup.invite === 'string'
87
+ && startup.invite.length > 0;
152
88
  }
153
89
  catch {
154
90
  return false;
155
91
  }
156
92
  }
157
- async function ensureLaunch(input) {
158
- const { provision, member, settings, gate } = input;
93
+ async function retainRunningLaunch(input) {
94
+ const { provision, member, task, roomIdentityCid } = input;
159
95
  let seat = getRoomRecord(provision.roomId).member_seats
160
96
  .find(candidate => candidate.role_name === member.name);
161
- const mission = startupMission(gate);
162
- const missionSha = sha256Text(mission);
163
97
  const dir = agentDir(member.name, true);
98
+ const taskSha = sha256Text(task);
164
99
  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)) {
100
+ if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
166
101
  throw new Error(`existing launch for ${member.name} does not match its durable intent`);
167
102
  }
168
103
  const supervisor = readTempSupervisor(dir);
@@ -176,204 +111,141 @@ async function ensureLaunch(input) {
176
111
  ...seat.launch, state: 'launched', launch_id: supervisor.launchId,
177
112
  updated_at: new Date().toISOString(),
178
113
  } });
179
- return;
114
+ return true;
180
115
  }
181
116
  await secureStoppedTempArchive(member.name, supervisor.launchId);
182
117
  updateMemberStartup(provision.roomId, member.name, { launch: {
183
118
  ...seat.launch, state: 'stopped', launch_id: supervisor.launchId,
184
119
  updated_at: new Date().toISOString(),
185
120
  } });
186
- seat = getRoomRecord(provision.roomId).member_seats
187
- .find(candidate => candidate.role_name === member.name);
121
+ return false;
188
122
  }
189
- else if (seat.launch?.state === 'launched' && !existsSync(dir)) {
123
+ if (seat.launch?.state === 'launched' && !existsSync(dir)) {
190
124
  if (!seat.launch.launch_id || !seat.launch.action_id)
191
125
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
192
126
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
193
- if (!launchMatches(archive, member, seat.launch.action_id, missionSha, gate))
127
+ if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
194
128
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
129
+ }
195
130
  updateMemberStartup(provision.roomId, member.name, { launch: {
196
131
  ...seat.launch, state: 'stopped', updated_at: new Date().toISOString(),
197
132
  } });
198
- seat = getRoomRecord(provision.roomId).member_seats
199
- .find(candidate => candidate.role_name === member.name);
133
+ return false;
200
134
  }
201
- else if (seat.launch?.state === 'intent' && !existsSync(dir)) {
135
+ if (seat.launch?.state === 'intent' && !existsSync(dir)) {
202
136
  if (!seat.launch.action_id)
203
137
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
204
138
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
205
- if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, missionSha, gate)) {
139
+ if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
206
140
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
207
141
  }
208
142
  updateMemberStartup(provision.roomId, member.name, { launch: {
209
143
  ...seat.launch, state: 'stopped', launch_id: archive.launchId,
210
144
  updated_at: new Date().toISOString(),
211
145
  } });
212
- seat = getRoomRecord(provision.roomId).member_seats
213
- .find(candidate => candidate.role_name === member.name);
214
146
  }
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;
147
+ return false;
148
+ }
149
+ async function launchMember(input) {
150
+ const { provision, member, settings, startup } = input;
151
+ const seat = getRoomRecord(provision.roomId).member_seats
152
+ .find(candidate => candidate.role_name === member.name);
153
+ const actionId = randomUUID();
154
+ const attempt = (seat.launch?.attempt ?? 0) + 1;
155
+ const taskSha = sha256Text(startup.task);
219
156
  updateMemberStartup(provision.roomId, member.name, { launch: {
220
- state: 'intent', attempt, action_id: actionId, mission_sha256: missionSha,
157
+ state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
221
158
  updated_at: new Date().toISOString(),
222
159
  } });
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`);
160
+ try {
161
+ const launchedDir = await spawnTemp({
162
+ name: member.name,
163
+ temp: true,
164
+ identity: member.name,
165
+ mission: startup.task,
166
+ model: settings.model,
167
+ harness: settings.harness,
168
+ cwd: settings.cwd,
169
+ surface: 'agent',
170
+ creationActionId: actionId,
171
+ roomMemberStartup: startup,
172
+ }, provision.binPath);
173
+ const supervisor = readTempSupervisor(launchedDir);
174
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, actionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
175
+ throw new Error(`new launch for ${member.name} did not persist matching provenance`);
176
+ }
177
+ updateMemberStartup(provision.roomId, member.name, { launch: {
178
+ state: 'launched', attempt, action_id: actionId, mission_sha256: taskSha,
179
+ launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
180
+ } });
181
+ }
182
+ catch (error) {
183
+ updateMemberStartup(provision.roomId, member.name, { launch: {
184
+ state: 'failed', attempt, action_id: actionId, mission_sha256: taskSha,
185
+ updated_at: new Date().toISOString(),
186
+ error: error instanceof Error ? error.message : String(error),
187
+ } });
188
+ throw error;
232
189
  }
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
- } });
190
+ }
191
+ function exactCoworkSeat(observed, member, expectedInviteId) {
192
+ const named = observed.filter(seat => seat.display_name === member.name && seat.seat_state !== 'removed');
193
+ if (named.length > 1)
194
+ throw new Error(`Cowork has multiple live seats named ${member.name}`);
195
+ const seat = named[0];
196
+ if (!seat)
197
+ return undefined;
198
+ if (seat.role !== member.coworkRole)
199
+ throw new Error(`Cowork seat ${member.name} has role ${seat.role}; expected ${member.coworkRole}`);
200
+ if (expectedInviteId && seat.invite_id !== expectedInviteId) {
201
+ throw new Error(`Cowork seat ${member.name} used invite ${seat.invite_id}; expected ${expectedInviteId}`);
202
+ }
203
+ return seat;
204
+ }
205
+ function reconcileMemberSeats(roomId, members, observed) {
206
+ const current = getRoomRecord(roomId);
207
+ let complete = true;
208
+ const seats = current.member_seats.map(seat => {
209
+ const member = members.find(candidate => candidate.name === seat.role_name);
210
+ const found = exactCoworkSeat(observed, member, seat.invite_id);
211
+ if (!found || found.seat_state !== 'active') {
212
+ complete = false;
213
+ return seat;
214
+ }
215
+ return {
216
+ ...seat,
217
+ identity_cid: found.identity_cid,
218
+ invite_id: found.invite_id,
219
+ seat_state: 'active',
220
+ };
221
+ });
222
+ updateMemberSeats(roomId, seats);
223
+ return { complete, seats };
237
224
  }
238
225
  export async function provisionMembers(input) {
239
- const { cfg, cowork, roomId, taskId, template, binPath } = input;
226
+ const { cfg, cowork, roomId, taskId, template } = input;
240
227
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
241
- const plan = expandMembers(template, prefix);
242
- const createdIdentities = [];
243
- const members = [];
228
+ const members = expandMembers(template, prefix);
244
229
  const existing = getRoomRecord(roomId);
245
230
  if (!existing?.room_identity_cid)
246
231
  throw new Error(`room ${roomId} has no pinned room identity CID`);
247
232
  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
233
+ const ownerSeatCid = existing.owner_seat_cid ?? null;
234
+ const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
235
+ const resuming = members.length > 0
236
+ && members.every(member => persistedNames.has(member.name));
237
+ if (!resuming) {
270
238
  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,
239
+ updateMemberSeats(roomId, members.map(member => ({
240
+ role_name: member.name,
241
+ slot: member.slot,
242
+ cowork_role: member.coworkRole,
292
243
  seat_state: 'pending',
293
244
  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
- }
245
+ })));
308
246
  }
309
247
  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
- }
248
+ const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid)]));
377
249
  const policy = {
378
250
  timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
379
251
  initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
@@ -381,178 +253,91 @@ export async function provisionMembers(input) {
381
253
  now: input.startupWait?.now ?? Date.now,
382
254
  sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
383
255
  };
384
- advanceSaga(roomId, 'join_role_groups', 5);
256
+ advanceSaga(roomId, 'join_role_groups', 4);
385
257
  try {
386
- const observedSeats = await cowork.getSeats(roomId);
258
+ const initialRoom = await cowork.recoverRoom(roomId);
259
+ reconcileMemberSeats(roomId, members, initialRoom.seats);
387
260
  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}`);
261
+ const task = tasks.get(member.name);
262
+ const currentSeat = getRoomRecord(roomId).member_seats
263
+ .find(seat => seat.role_name === member.name);
264
+ if (currentSeat.seat_state === 'active') {
265
+ if (!await retainRunningLaunch({
266
+ provision: input, member, task, roomIdentityCid,
267
+ })) {
268
+ throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
434
269
  }
435
- return !seat;
270
+ continue;
271
+ }
272
+ if (await retainRunningLaunch({ provision: input, member, task, roomIdentityCid }))
273
+ continue;
274
+ const issued = await cowork.issueInvite(roomId, {
275
+ mode: 'one_time', role: member.coworkRole, min_accepts: 1,
436
276
  });
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');
277
+ const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
278
+ updateMemberSeats(roomId, seats);
279
+ try {
280
+ await launchMember({
281
+ provision: input,
282
+ member,
283
+ settings: settings.get(member.name),
284
+ startup: {
285
+ room_id: roomId,
286
+ room_identity_cid: roomIdentityCid,
287
+ identity_name: member.name,
288
+ invite_id: issued.invite_id,
289
+ invite: issued.invite,
290
+ role: member.coworkRole,
291
+ task,
292
+ owner_seat_cid: ownerSeatCid,
293
+ },
294
+ });
295
+ }
296
+ catch (error) {
297
+ await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
298
+ throw error;
441
299
  }
442
- await policy.sleep(delay);
443
- delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
444
300
  }
445
301
  }
446
302
  catch (error) {
447
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
303
+ const reason = error instanceof Error ? error.message : String(error);
304
+ setSagaError(roomId, reason, 'Member invite or launch failed. Retry with `task recover`.', 'member_failed');
448
305
  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');
306
+ blockTask(taskId, reason);
486
307
  throw error;
487
308
  }
309
+ advanceSaga(roomId, 'wait_seats', 5);
488
310
  const deadline = policy.now() + policy.timeoutMs;
489
311
  let delay = policy.initialDelayMs;
490
312
  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)
313
+ const remote = await cowork.recoverRoom(roomId);
314
+ const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
315
+ if (reconciled.complete)
532
316
  break;
533
317
  if (policy.now() >= deadline) {
534
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
318
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
535
319
  return getRoomRecord(roomId);
536
320
  }
537
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
321
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
538
322
  await policy.sleep(delay);
539
323
  delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
540
324
  }
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;
325
+ if (taskId) {
326
+ const taskMembers = getRoomRecord(roomId).member_seats.map(seat => {
327
+ if (!seat.identity_cid)
328
+ throw new Error(`active room member ${seat.role_name} has no authenticated identity CID`);
329
+ return {
330
+ name: seat.role_name,
331
+ identity_cid: seat.identity_cid,
332
+ slot: seat.slot,
333
+ cowork_role: seat.cowork_role,
334
+ };
335
+ });
336
+ updateTaskMembers(taskId, taskMembers);
337
+ if (getTask(taskId).blocked)
338
+ unblockTask(taskId);
554
339
  }
555
- advanceSaga(roomId, 'activate', 9);
340
+ advanceSaga(roomId, 'activate', 6);
556
341
  const record = activateRoom(roomId);
557
342
  if (taskId)
558
343
  activateTask(taskId);