@ours.network/fleet 1.0.0 → 1.0.1

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,9 +1,9 @@
1
1
  {
2
- "version": "1.0.0",
3
- "buildId": "76df3ad96722",
4
- "commit": "2bd6fc54e4f3e0d65c2e6b7258a3635557e868fb",
2
+ "version": "1.0.1",
3
+ "buildId": "bb9ae2d6a815",
4
+ "commit": "52468879ecc67b45ddeb632f195ac8ee895a8b1d",
5
5
  "dirty": false,
6
- "builtAt": "2026-08-24T15:55:27.110Z",
6
+ "builtAt": "2026-08-25T07:16:56.275Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
@@ -100,6 +100,14 @@ async function retireMember(roomId, seat, deps) {
100
100
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
101
101
  let retirement = current.retirement;
102
102
  if (!retirement) {
103
+ if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
104
+ if (existsSync(agentDir(current.role_name, true))) {
105
+ throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
106
+ }
107
+ await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
108
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'never-launched');
109
+ return;
110
+ }
103
111
  const ownership = await (deps.inspectMember ?? inspectMember)(current);
104
112
  room = advanceMemberRetirement(roomId, current.role_name, 'stop_requested', ownership.launchId);
105
113
  current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
@@ -17,6 +17,7 @@ export interface CoworkInviteAcceptResult {
17
17
  }
18
18
  export interface CoworkInviteResult {
19
19
  invite: string;
20
+ invite_id: string;
20
21
  min_accepts: number;
21
22
  }
22
23
  export interface CoworkSeatInfo {
@@ -63,6 +64,7 @@ export interface CoworkAdapter {
63
64
  role: string;
64
65
  min_accepts: number;
65
66
  }): Promise<CoworkInviteResult>;
67
+ revokeInvite(roomId: string, inviteId: string): Promise<void>;
66
68
  setRoleBriefing(roomId: string, opts: {
67
69
  role: string;
68
70
  text: string;
@@ -318,10 +318,14 @@ export function createCoworkAdapter(options = {}) {
318
318
  const invite = object(receipt.invite, 'room.invite', 'receipt.invite');
319
319
  return {
320
320
  invite: string(receipt.blob, 'room.invite', 'receipt.blob'),
321
+ invite_id: string(invite.invite_id, 'room.invite', 'receipt.invite.invite_id'),
321
322
  min_accepts: typeof invite.min_accepts === 'number'
322
323
  ? invite.min_accepts : opts.min_accepts,
323
324
  };
324
325
  },
326
+ async revokeInvite(roomId, inviteId) {
327
+ await call('room.revoke', { room_id: roomId, invite_id: inviteId });
328
+ },
325
329
  async setRoleBriefing(roomId, opts) {
326
330
  const result = object(await call('room.briefing.role.set', {
327
331
  room_id: roomId, role: opts.role, text: opts.text,
@@ -70,6 +70,26 @@ async function removeMemberIdentity(name) {
70
70
  }
71
71
  catch { /* best-effort cleanup */ }
72
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
+ }
73
93
  function settingsFor(member, cfg) {
74
94
  let refRole;
75
95
  try {
@@ -224,6 +244,7 @@ export async function provisionMembers(input) {
224
244
  const existing = getRoomRecord(roomId);
225
245
  if (!existing?.room_identity_cid)
226
246
  throw new Error(`room ${roomId} has no pinned room identity CID`);
247
+ const roomIdentityCid = existing.room_identity_cid;
227
248
  const persistedSeats = existing?.member_seats ?? [];
228
249
  const resuming = plan.length > 0
229
250
  && plan.every(p => persistedSeats.some(s => s.role_name === p.name));
@@ -353,6 +374,13 @@ export async function provisionMembers(input) {
353
374
  throw error;
354
375
  }
355
376
  }
377
+ const policy = {
378
+ timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
379
+ initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
380
+ maxDelayMs: input.startupWait?.maxDelayMs ?? 2_000,
381
+ now: input.startupWait?.now ?? Date.now,
382
+ sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
383
+ };
356
384
  advanceSaga(roomId, 'join_role_groups', 5);
357
385
  try {
358
386
  const observedSeats = await cowork.getSeats(roomId);
@@ -370,15 +398,49 @@ export async function provisionMembers(input) {
370
398
  ...(roleGroups.get(member.coworkRole) ?? []), member,
371
399
  ]);
372
400
  }
373
- for (const [coworkRole, group] of roleGroups) {
374
- const { invite } = await cowork.issueInvite(roomId, {
375
- role: coworkRole, min_accepts: group.length,
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}`);
434
+ }
435
+ return !seat;
376
436
  });
377
- for (const member of group) {
378
- await cowork.acceptInvite(roomId, invite, {
379
- role: coworkRole, expected_cid: member.cid,
380
- });
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');
381
441
  }
442
+ await policy.sleep(delay);
443
+ delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
382
444
  }
383
445
  }
384
446
  catch (error) {
@@ -423,13 +485,6 @@ export async function provisionMembers(input) {
423
485
  setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member launch failed. Some agents may be running. Retry with `task recover`.', 'member_failed');
424
486
  throw error;
425
487
  }
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
- };
433
488
  const deadline = policy.now() + policy.timeoutMs;
434
489
  let delay = policy.initialDelayMs;
435
490
  for (;;) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",