@ours.network/fleet 1.0.0 → 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,41 +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
37
  function settingsFor(member, cfg) {
74
38
  let refRole;
75
39
  try {
@@ -83,66 +47,57 @@ function settingsFor(member, cfg) {
83
47
  persona: member.overrides?.persona ?? refRole?.persona,
84
48
  };
85
49
  }
86
- async function roomHistory(cowork, roomId) {
87
- const records = [];
88
- let after = getRoomRecord(roomId)?.history_cursor ?? 0;
89
- for (let page = 0; page < 100; page++) {
90
- const next = await cowork.getHistory(roomId, { after, limit: 200 });
91
- records.push(...next.records);
92
- if (next.raw_count === 0)
93
- return { records, cursor: after, exhausted: true };
94
- if (next.next_after <= after)
95
- throw new Error('Cowork room history cursor did not advance');
96
- after = next.next_after;
97
- if (next.raw_count < 200)
98
- return { records, cursor: after, exhausted: true };
99
- }
100
- return { records, cursor: after, exhausted: false };
101
- }
102
- async function assertCurrentRoleBriefings(cowork, roomId, definitions) {
103
- const room = await cowork.getRoom(roomId);
104
- if (!room)
105
- throw new Error(`Cowork room ${roomId} is missing during briefing verification`);
106
- for (const [role, expected] of definitions) {
107
- const current = room.role_briefings[role];
108
- if (!current || current.text !== expected.text
109
- || sha256Text(current.text) !== expected.sha256
110
- || (expected.version !== undefined && current.version !== expected.version)) {
111
- throw new Error(`Cowork current role briefing drift for ${role}`);
112
- }
113
- }
114
- }
115
- function startupMission(gate) {
116
- return [
117
- 'Complete the dedicated room startup gate in briefing.md before readiness.',
118
- `Expected room ${gate.room_id} (${gate.room_identity_cid}), role ${gate.briefing_role},`,
119
- `briefing version ${gate.briefing_version}, sha256 ${gate.briefing_sha256}.`,
120
- ].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
+ });
121
69
  }
122
- function launchMatches(dir, member, actionId, missionSha, gate) {
70
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
123
71
  const provenance = readProvenance(dir);
124
72
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
125
73
  return false;
126
74
  try {
127
75
  const role = parse(readFileSync(`${dir}/role.yaml`, 'utf8'));
76
+ const startup = role.roomMemberStartup;
128
77
  return role.identity === member.name
129
78
  && typeof role.mission === 'string'
130
- && sha256Text(role.mission) === missionSha
131
- && 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;
132
88
  }
133
89
  catch {
134
90
  return false;
135
91
  }
136
92
  }
137
- async function ensureLaunch(input) {
138
- const { provision, member, settings, gate } = input;
93
+ async function retainRunningLaunch(input) {
94
+ const { provision, member, task, roomIdentityCid } = input;
139
95
  let seat = getRoomRecord(provision.roomId).member_seats
140
96
  .find(candidate => candidate.role_name === member.name);
141
- const mission = startupMission(gate);
142
- const missionSha = sha256Text(mission);
143
97
  const dir = agentDir(member.name, true);
98
+ const taskSha = sha256Text(task);
144
99
  if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched') && existsSync(dir)) {
145
- 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)) {
146
101
  throw new Error(`existing launch for ${member.name} does not match its durable intent`);
147
102
  }
148
103
  const supervisor = readTempSupervisor(dir);
@@ -156,348 +111,233 @@ async function ensureLaunch(input) {
156
111
  ...seat.launch, state: 'launched', launch_id: supervisor.launchId,
157
112
  updated_at: new Date().toISOString(),
158
113
  } });
159
- return;
114
+ return true;
160
115
  }
161
116
  await secureStoppedTempArchive(member.name, supervisor.launchId);
162
117
  updateMemberStartup(provision.roomId, member.name, { launch: {
163
118
  ...seat.launch, state: 'stopped', launch_id: supervisor.launchId,
164
119
  updated_at: new Date().toISOString(),
165
120
  } });
166
- seat = getRoomRecord(provision.roomId).member_seats
167
- .find(candidate => candidate.role_name === member.name);
121
+ return false;
168
122
  }
169
- else if (seat.launch?.state === 'launched' && !existsSync(dir)) {
123
+ if (seat.launch?.state === 'launched' && !existsSync(dir)) {
170
124
  if (!seat.launch.launch_id || !seat.launch.action_id)
171
125
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
172
126
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
173
- 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)) {
174
128
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
129
+ }
175
130
  updateMemberStartup(provision.roomId, member.name, { launch: {
176
131
  ...seat.launch, state: 'stopped', updated_at: new Date().toISOString(),
177
132
  } });
178
- seat = getRoomRecord(provision.roomId).member_seats
179
- .find(candidate => candidate.role_name === member.name);
133
+ return false;
180
134
  }
181
- else if (seat.launch?.state === 'intent' && !existsSync(dir)) {
135
+ if (seat.launch?.state === 'intent' && !existsSync(dir)) {
182
136
  if (!seat.launch.action_id)
183
137
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
184
138
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
185
- 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)) {
186
140
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
187
141
  }
188
142
  updateMemberStartup(provision.roomId, member.name, { launch: {
189
143
  ...seat.launch, state: 'stopped', launch_id: archive.launchId,
190
144
  updated_at: new Date().toISOString(),
191
145
  } });
192
- seat = getRoomRecord(provision.roomId).member_seats
193
- .find(candidate => candidate.role_name === member.name);
194
146
  }
195
- const actionId = seat.launch?.state === 'intent' && seat.launch.action_id
196
- ? seat.launch.action_id : randomUUID();
197
- const attempt = seat.launch?.state === 'intent'
198
- ? 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);
199
156
  updateMemberStartup(provision.roomId, member.name, { launch: {
200
- state: 'intent', attempt, action_id: actionId, mission_sha256: missionSha,
157
+ state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
201
158
  updated_at: new Date().toISOString(),
202
159
  } });
203
- const launchedDir = await spawnTemp({
204
- name: member.name, temp: true, identity: member.name, mission,
205
- model: settings.model, harness: settings.harness, cwd: settings.cwd,
206
- surface: 'agent', creationActionId: actionId, roomStartupGate: gate,
207
- }, provision.binPath);
208
- const supervisor = readTempSupervisor(launchedDir);
209
- if (!supervisor || supervisor.role !== member.name
210
- || !launchMatches(launchedDir, member, actionId, missionSha, gate)) {
211
- 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
+ } });
212
181
  }
213
- updateMemberStartup(provision.roomId, member.name, { launch: {
214
- state: 'launched', attempt, action_id: actionId, mission_sha256: missionSha,
215
- launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
216
- } });
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;
189
+ }
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 };
217
224
  }
218
225
  export async function provisionMembers(input) {
219
- const { cfg, cowork, roomId, taskId, template, binPath } = input;
226
+ const { cfg, cowork, roomId, taskId, template } = input;
220
227
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
221
- const plan = expandMembers(template, prefix);
222
- const createdIdentities = [];
223
- const members = [];
228
+ const members = expandMembers(template, prefix);
224
229
  const existing = getRoomRecord(roomId);
225
230
  if (!existing?.room_identity_cid)
226
231
  throw new Error(`room ${roomId} has no pinned room identity CID`);
227
- const persistedSeats = existing?.member_seats ?? [];
228
- const resuming = plan.length > 0
229
- && plan.every(p => persistedSeats.some(s => s.role_name === p.name));
230
- let seats;
231
- if (resuming) {
232
- for (const planned of plan) {
233
- const seat = persistedSeats.find(s => s.role_name === planned.name);
234
- members.push({ ...planned, cid: seat.identity_cid });
235
- }
236
- seats = persistedSeats.map(seat => ({
237
- ...seat,
238
- launch: seat.launch ?? {
239
- state: 'pending', attempt: 0, updated_at: existing.created_at,
240
- },
241
- briefing: seat.briefing ?? {
242
- role: seat.cowork_role, state: 'pending', rejected_ack_count: 0,
243
- },
244
- }));
245
- updateMemberSeats(roomId, seats);
246
- }
247
- else {
248
- // create_members saga phase
232
+ const roomIdentityCid = existing.room_identity_cid;
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) {
249
238
  advanceSaga(roomId, 'create_members', 3);
250
- try {
251
- for (const planned of plan) {
252
- const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
253
- const { cid } = await createMemberIdentity(planned.name, bio);
254
- createdIdentities.push(planned.name);
255
- members.push({ ...planned, cid });
256
- }
257
- }
258
- catch (error) {
259
- for (const name of createdIdentities)
260
- await removeMemberIdentity(name);
261
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
262
- if (taskId)
263
- failTask(taskId, error instanceof Error ? error.message : String(error));
264
- throw error;
265
- }
266
- seats = members.map(m => ({
267
- role_name: m.name,
268
- identity_cid: m.cid,
269
- slot: m.slot,
270
- cowork_role: m.coworkRole,
239
+ updateMemberSeats(roomId, members.map(member => ({
240
+ role_name: member.name,
241
+ slot: member.slot,
242
+ cowork_role: member.coworkRole,
271
243
  seat_state: 'pending',
272
244
  launch: { state: 'pending', attempt: 0, updated_at: new Date().toISOString() },
273
- briefing: {
274
- role: m.coworkRole, state: 'pending', rejected_ack_count: 0,
275
- },
276
- }));
277
- updateMemberSeats(roomId, seats);
278
- if (taskId) {
279
- const taskMembers = members.map(m => ({
280
- name: m.name,
281
- identity_cid: m.cid,
282
- slot: m.slot,
283
- cowork_role: m.coworkRole,
284
- }));
285
- updateTaskMembers(taskId, taskMembers);
286
- }
245
+ })));
287
246
  }
288
247
  const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg)]));
289
- const roster = members.map(member => ({
290
- role_name: member.name, cowork_role: member.coworkRole, identity_cid: member.cid,
291
- }));
292
- const definitions = new Map();
293
- for (const member of members) {
294
- const durable = getRoomRecord(roomId).role_briefings?.[member.coworkRole];
295
- const text = durable?.text ?? buildRoomMemberCharter({
296
- taskId, roomId, roomIdentityCid: existing.room_identity_cid,
297
- ownerSeatCid: existing.owner_seat_cid ?? null,
298
- goal: input.goal, brief: input.brief, contract: template.contract,
299
- member: { ...roster.find(role => role.role_name === member.name),
300
- persona: settings.get(member.name)?.persona },
301
- roster,
302
- });
303
- const sha256 = sha256Text(text);
304
- if (durable && durable.sha256 !== sha256)
305
- throw new Error(`persisted role briefing hash mismatch for ${member.coworkRole}`);
306
- const known = definitions.get(member.coworkRole);
307
- if (known && known.text !== text) {
308
- throw new Error(`members sharing Cowork role ${member.coworkRole} produced different briefing bytes`);
309
- }
310
- definitions.set(member.coworkRole, { text, sha256 });
311
- }
312
- advanceSaga(roomId, 'configure_briefings', 4);
313
- const remoteRoom = await cowork.getRoom(roomId);
314
- if (!remoteRoom)
315
- throw new Error(`Cowork room ${roomId} is missing during briefing configuration`);
316
- for (const [role, definition] of definitions) {
317
- const current = getRoomRecord(roomId).role_briefings?.[role];
318
- const attempt = (current?.attempts ?? 0) + 1;
319
- updateRoomRoleBriefing(roomId, role, {
320
- role, ...definition, ...(current?.version ? { version: current.version } : {}),
321
- state: 'pending', attempts: attempt,
322
- updated_at: new Date().toISOString(),
323
- });
324
- try {
325
- const remote = remoteRoom.role_briefings[role];
326
- if (remote && (remote.text !== definition.text
327
- || sha256Text(remote.text) !== definition.sha256
328
- || (current?.version !== undefined && remote.version !== current.version))) {
329
- throw new Error(`Cowork current role briefing drift for ${role}`);
330
- }
331
- if (!remote && current?.version !== undefined)
332
- throw new Error(`Cowork current role briefing is missing for ${role}`);
333
- const configured = remote ?? await cowork.setRoleBriefing(roomId, {
334
- role, text: definition.text,
335
- });
336
- if (configured.text !== definition.text)
337
- throw new Error(`Cowork returned different briefing bytes for role ${role}`);
338
- updateRoomRoleBriefing(roomId, role, {
339
- role, ...definition, version: configured.version, state: 'configured',
340
- attempts: attempt, updated_at: configured.updated_at,
341
- });
342
- }
343
- catch (error) {
344
- updateRoomRoleBriefing(roomId, role, {
345
- role, ...definition, ...(current?.version ? { version: current.version } : {}),
346
- state: 'failed', attempts: attempt,
347
- updated_at: new Date().toISOString(),
348
- last_error: error instanceof Error ? error.message : String(error),
349
- });
350
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role briefing configuration failed. Retry task or room recover.', 'member_failed');
351
- if (taskId)
352
- blockTask(taskId, error instanceof Error ? error.message : String(error));
353
- throw error;
354
- }
355
- }
356
- advanceSaga(roomId, 'join_role_groups', 5);
248
+ const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid)]));
249
+ const policy = {
250
+ timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
251
+ initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
252
+ maxDelayMs: input.startupWait?.maxDelayMs ?? 2_000,
253
+ now: input.startupWait?.now ?? Date.now,
254
+ sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
255
+ };
256
+ advanceSaga(roomId, 'join_role_groups', 4);
357
257
  try {
358
- const observedSeats = await cowork.getSeats(roomId);
258
+ const initialRoom = await cowork.recoverRoom(roomId);
259
+ reconcileMemberSeats(roomId, members, initialRoom.seats);
359
260
  for (const member of members) {
360
- const observed = observedSeats.find(seat => seat.identity_cid === member.cid
361
- && seat.seat_state !== 'removed');
362
- if (observed && observed.role !== member.coworkRole)
363
- throw new Error(`Cowork seat ${member.cid} has role ${observed.role}; expected ${member.coworkRole}`);
364
- }
365
- const admitted = new Set(observedSeats
366
- .filter(seat => seat.seat_state !== 'removed').map(seat => seat.identity_cid));
367
- const roleGroups = new Map();
368
- for (const member of members.filter(candidate => !admitted.has(candidate.cid))) {
369
- roleGroups.set(member.coworkRole, [
370
- ...(roleGroups.get(member.coworkRole) ?? []), member,
371
- ]);
372
- }
373
- for (const [coworkRole, group] of roleGroups) {
374
- const { invite } = await cowork.issueInvite(roomId, {
375
- role: coworkRole, min_accepts: group.length,
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`);
269
+ }
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,
376
276
  });
377
- for (const member of group) {
378
- await cowork.acceptInvite(roomId, invite, {
379
- role: coworkRole, expected_cid: member.cid,
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
+ },
380
294
  });
381
295
  }
296
+ catch (error) {
297
+ await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
298
+ throw error;
299
+ }
382
300
  }
383
301
  }
384
302
  catch (error) {
385
- 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');
386
305
  if (taskId)
387
- blockTask(taskId, error instanceof Error ? error.message : String(error));
388
- throw error;
389
- }
390
- if (taskId && getTask(taskId).blocked)
391
- unblockTask(taskId);
392
- advanceSaga(roomId, 'wait_seats', 6);
393
- const coworkSeats = await cowork.getSeats(roomId);
394
- const memberCids = new Set(members.map(m => m.cid));
395
- const allActive = [...memberCids].every(cid => coworkSeats.some(s => s.identity_cid === cid
396
- && s.role === members.find(member => member.cid === cid)?.coworkRole
397
- && s.seat_state === 'active'));
398
- if (!allActive) {
399
- advanceSaga(roomId, 'wait_seats', 6, 'waiting_seats');
400
- // Seats are pending — return record for caller to retry later
401
- return getRoomRecord(roomId);
402
- }
403
- const activeSeats = seats.map(s => ({ ...s, seat_state: 'active' }));
404
- updateMemberSeats(roomId, activeSeats);
405
- advanceSaga(roomId, 'launch_work', 7);
406
- try {
407
- for (const member of members) {
408
- const definition = getRoomRecord(roomId).role_briefings?.[member.coworkRole];
409
- if (!definition?.version)
410
- throw new Error(`role ${member.coworkRole} briefing is not configured`);
411
- await ensureLaunch({
412
- provision: input, member, settings: settings.get(member.name),
413
- gate: {
414
- room_id: roomId, room_identity_cid: existing.room_identity_cid,
415
- briefing_role: member.coworkRole, briefing_version: definition.version,
416
- briefing_sha256: definition.sha256,
417
- owner_seat_cid: existing.owner_seat_cid ?? null,
418
- },
419
- });
420
- }
421
- }
422
- catch (error) {
423
- 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);
424
307
  throw error;
425
308
  }
426
- const policy = {
427
- timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
428
- initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
429
- maxDelayMs: input.startupWait?.maxDelayMs ?? 2_000,
430
- now: input.startupWait?.now ?? Date.now,
431
- sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
432
- };
309
+ advanceSaga(roomId, 'wait_seats', 5);
433
310
  const deadline = policy.now() + policy.timeoutMs;
434
311
  let delay = policy.initialDelayMs;
435
312
  for (;;) {
436
- const history = await roomHistory(cowork, roomId);
437
- let allAcknowledged = true;
438
- let relayFailed = false;
439
- for (const member of members) {
440
- const currentRoom = getRoomRecord(roomId);
441
- const definition = currentRoom.role_briefings[member.coworkRole];
442
- const previous = currentRoom.member_seats
443
- .find(seat => seat.role_name === member.name).briefing;
444
- const result = reconcileBriefingHistory(history.records, {
445
- roomId, roomIdentityCid: existing.room_identity_cid,
446
- ownerSeatCid: existing.owner_seat_cid ?? null,
447
- memberCid: member.cid, role: member.coworkRole,
448
- version: definition.version, sha256: definition.sha256,
449
- }, previous);
450
- if (result.drift) {
451
- setSagaError(roomId, result.drift, 'Cowork role briefing history drifted; inspect current room briefing state.', 'uncertain');
452
- if (taskId)
453
- blockTask(taskId, result.drift);
454
- throw new Error(result.drift);
455
- }
456
- updateMemberStartup(roomId, member.name, { briefing: result.briefing });
457
- allAcknowledged &&= result.briefing.state === 'acknowledged';
458
- relayFailed ||= result.briefing.state === 'relay_failed';
459
- }
460
- updateRoomHistoryCursor(roomId, history.cursor);
461
- if (!history.exhausted) {
462
- const reason = 'Cowork history backlog exceeded 20,000 raw records in one pass; '
463
- + 'cursor progress was preserved, retry recovery to continue.';
464
- setSagaError(roomId, reason, reason, 'uncertain');
465
- throw new Error(reason);
466
- }
467
- if (relayFailed) {
468
- const reason = 'Cowork recorded terminal send_failed for a role briefing; '
469
- + 'same-version redelivery requires Cowork support or seat replacement.';
470
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'briefing_delivery_failed');
471
- setSagaError(roomId, reason, reason, 'briefing_delivery_failed');
472
- if (taskId)
473
- blockTask(taskId, reason);
474
- return getRoomRecord(roomId);
475
- }
476
- if (allAcknowledged)
313
+ const remote = await cowork.recoverRoom(roomId);
314
+ const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
315
+ if (reconciled.complete)
477
316
  break;
478
317
  if (policy.now() >= deadline) {
479
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
318
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
480
319
  return getRoomRecord(roomId);
481
320
  }
482
- advanceSaga(roomId, 'wait_briefing_acks', 8, 'waiting_briefing_acks');
321
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
483
322
  await policy.sleep(delay);
484
323
  delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
485
324
  }
486
- const finalDefinitions = new Map([...definitions].map(([role, definition]) => {
487
- const persisted = getRoomRecord(roomId).role_briefings[role];
488
- return [role, { ...definition, version: persisted.version }];
489
- }));
490
- try {
491
- await assertCurrentRoleBriefings(cowork, roomId, finalDefinitions);
492
- }
493
- catch (error) {
494
- const reason = error instanceof Error ? error.message : String(error);
495
- setSagaError(roomId, reason, 'Cowork current role briefing drifted before activation; inspect room state.', 'uncertain');
496
- if (taskId)
497
- blockTask(taskId, reason);
498
- 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);
499
339
  }
500
- advanceSaga(roomId, 'activate', 9);
340
+ advanceSaga(roomId, 'activate', 6);
501
341
  const record = activateRoom(roomId);
502
342
  if (taskId)
503
343
  activateTask(taskId);