@ours.network/fleet 1.2.0-nightly.5 → 1.2.0-nightly.7

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.
package/dist/docs.js CHANGED
@@ -1032,8 +1032,8 @@ reconcile explicitly; no chain preserves detection-only behavior.
1032
1032
  * \`forbidden\` is the more important half. The old skills prescribed
1033
1033
  * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
1034
1034
  * default while also telling the agent to stop at a failed doctor check — and
1035
- * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
1036
- * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
1035
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
1036
+ * guarantee unattended capabilities and \`deny\` makes the shortfall
1037
1037
  * fatal. Following the skill produced a role the CLI then refused.
1038
1038
  */
1039
1039
  export const SPAWN_SKILL_CONTRACT = {
@@ -1062,7 +1062,7 @@ export const SPAWN_SKILL_CONTRACT = {
1062
1062
  */
1063
1063
  forbidden: [
1064
1064
  // The contradictory blanket default both variants used to prescribe.
1065
- // `ask` grants an unattended role only `read-state`, and `deny` makes the
1065
+ // `ask` cannot guarantee unattended capabilities, and `deny` makes the
1066
1066
  // shortfall a doctor FAILURE — so the skill told you to build a role the
1067
1067
  // CLI then refused, in the same breath as telling you to trust doctor.
1068
1068
  '--approval ask --filesystem workspace --unattended deny',
@@ -117,6 +117,7 @@ export function consumeFleetAuditCollection() {
117
117
  const SAFE_READ = new Set(['docs', 'version', 'config', 'ls', 'peek', 'logs', 'status', 'doctor']);
118
118
  const AGENT_SURFACES = {
119
119
  spawn: new Set(['<none>']),
120
+ ours: new Set(['tools', 'call']),
120
121
  template: new Set(['list', 'show', 'validate']),
121
122
  task: new Set(['create', 'list', 'lists', 'list-create', 'list-rename', 'list-delete', 'move',
122
123
  'show', 'start', 'block', 'unblock', 'review', 'done', 'cancel', 'delete', 'work', 'finish']),
@@ -189,7 +190,7 @@ const sensitiveValueFlags = new Set([
189
190
  '--identity', '--invite', '--token', '--api-token', '--password', '--password-file',
190
191
  '--env', '--brief', '--brief-file', '--bio-file', '--persona-file', '--isolation-file', '--loops-file',
191
192
  '--configuration', '-c', '--public-invite', '--public-invite-file', '--invite-file',
192
- '--summary-file', '--text', '--message', '--summary', '--reason', '--goal', '--cwd',
193
+ '--args-file', '--summary-file', '--text', '--message', '--summary', '--reason', '--goal', '--cwd',
193
194
  '--identity-cid', '--owner-cid', '--contact-cid', '--codex-config', '--add-dir',
194
195
  ]);
195
196
  function redactUrl(value) {
@@ -6,8 +6,9 @@ import type { AcpSessionTransport } from './acp-session-transport.js';
6
6
  import type { CodexAppServerSessionTransport } from './codex-session.js';
7
7
  /**
8
8
  * What an unattended role can actually do under Codex's native settings.
9
- * `on-request` and `untrusted` stop to ask, and with no console attached that
10
- * request is refused rather than answered — so the role can only read.
9
+ * `on-request` and `untrusted` can ask even for startup file reads (for example,
10
+ * shell reads or files outside the sandbox). Without a controller the request
11
+ * waits or is denied according to policy. Neither guarantees `read-state`.
11
12
  */
12
13
  export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
13
14
  /** Defense in depth for callers which launch a role after validation was bypassed. */
@@ -25,12 +25,13 @@ const CODEX_PROXY_REAL_PATH_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
25
25
  const CODEX_PROXY_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
26
26
  /**
27
27
  * What an unattended role can actually do under Codex's native settings.
28
- * `on-request` and `untrusted` stop to ask, and with no console attached that
29
- * request is refused rather than answered — so the role can only read.
28
+ * `on-request` and `untrusted` can ask even for startup file reads (for example,
29
+ * shell reads or files outside the sandbox). Without a controller the request
30
+ * waits or is denied according to policy. Neither guarantees `read-state`.
30
31
  */
31
32
  export function codexCapabilities(approval, sandbox) {
32
33
  if (approval !== 'never')
33
- return ['read-state'];
34
+ return [];
34
35
  const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
35
36
  if (sandbox !== 'read-only')
36
37
  caps.push('write-state', 'workspace-edit');
@@ -14,6 +14,8 @@ export interface OwnerChannelOptions {
14
14
  harness: string;
15
15
  config: OwnerChannelConfig;
16
16
  session: AgentSession;
17
+ /** Ordinary owner input must not cancel the initial briefing turn. */
18
+ startupPending?: () => boolean;
17
19
  stateDir: string;
18
20
  env?: Record<string, string>;
19
21
  log(line: string): void;
@@ -269,6 +271,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
269
271
  private attachmentGroups;
270
272
  private handleAttachmentGroup;
271
273
  private handle;
274
+ private ownerPromptPolicy;
272
275
  /**
273
276
  * Deterministic command path: the message never becomes an agent prompt.
274
277
  * Authorization already happened — the managed-agent relay branch and the
@@ -1123,6 +1123,13 @@ export class OwnerChannel {
1123
1123
  const retrieved = unread.length
1124
1124
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1125
1125
  : [];
1126
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1127
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1128
+ // through the bound client and keep the daemon's integrity metadata for
1129
+ // admission below; never trust or remap the returned filesystem path.
1130
+ for (const file of retrieved) {
1131
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1132
+ }
1126
1133
  for (const file of historyRecovered) {
1127
1134
  if (!group.recovery)
1128
1135
  throw new Error('unexpected read attachment without recovery route');
@@ -1136,8 +1143,7 @@ export class OwnerChannel {
1136
1143
  await mkdir(outbox, { recursive: true, mode: 0o700 });
1137
1144
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1138
1145
  const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
1139
- interrupt: this.options.config.interrupt,
1140
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1146
+ ...this.ownerPromptPolicy(),
1141
1147
  origin: { kind: 'owner', requestId,
1142
1148
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
1143
1149
  });
@@ -1263,8 +1269,7 @@ export class OwnerChannel {
1263
1269
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1264
1270
  try {
1265
1271
  queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
1266
- interrupt: this.options.config.interrupt,
1267
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1272
+ ...this.ownerPromptPolicy(),
1268
1273
  origin: { kind: 'owner', requestId, displayText: text },
1269
1274
  });
1270
1275
  }
@@ -1309,6 +1314,14 @@ export class OwnerChannel {
1309
1314
  this.completionTasks.add(task);
1310
1315
  return true;
1311
1316
  }
1317
+ ownerPromptPolicy() {
1318
+ if (this.options.startupPending?.())
1319
+ return { interrupt: false, steer: true };
1320
+ return {
1321
+ interrupt: this.options.config.interrupt,
1322
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1323
+ };
1324
+ }
1312
1325
  /**
1313
1326
  * Deterministic command path: the message never becomes an agent prompt.
1314
1327
  * Authorization already happened — the managed-agent relay branch and the
@@ -1762,6 +1775,13 @@ export class OwnerChannel {
1762
1775
  const retrieved = unread.length
1763
1776
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1764
1777
  : [];
1778
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1779
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1780
+ // through the bound client and keep the daemon's integrity metadata for
1781
+ // admission below; never trust or remap the returned filesystem path.
1782
+ for (const file of retrieved) {
1783
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1784
+ }
1765
1785
  for (const file of historyRecovered) {
1766
1786
  if (!group.recovery)
1767
1787
  throw new Error('unexpected read attachment without recovery route');
@@ -1152,7 +1152,7 @@ export function registerTaskCommands(parent, cOpt) {
1152
1152
  const app = taskRoomService(opts.configuration);
1153
1153
  for (;;) {
1154
1154
  const before = app.taskProvisioningOutcome(id);
1155
- if (before.kind !== 'in_progress') {
1155
+ if (before.kind !== 'in_progress' || before.next_action) {
1156
1156
  await presentDetachedProvisioningOutcome(before);
1157
1157
  return;
1158
1158
  }
@@ -2,6 +2,7 @@ import { withFileLock } from '../atomic-file.js';
2
2
  import { type TempLifecycleDeps } from '../temp-lifecycle.js';
3
3
  import { type CoworkAdapter } from './cowork-adapter.js';
4
4
  import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
5
+ export declare function roomCloseLockPath(roomId: string): string;
5
6
  export interface RoomCloseDeps {
6
7
  inspectMember?(seat: RoomMemberSeat): Promise<{
7
8
  launchId: string;
@@ -4,13 +4,14 @@ import { randomUUID } from 'node:crypto';
4
4
  import { attachOursClient } from '@ours.network/sdk/client';
5
5
  import { withFileLock } from '../atomic-file.js';
6
6
  import { agentDir, stateRoot } from '../paths.js';
7
+ import { readClientProfile } from '../client-profile.js';
7
8
  import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
8
9
  import { CoworkProtocolError } from './cowork-adapter.js';
9
10
  import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
10
11
  const CLOSE_LOCK_STALE_MS = 5 * 60_000;
11
12
  const STOP_POLLS = 50;
12
13
  const STOP_POLL_MS = 100;
13
- function roomCloseLockPath(roomId) {
14
+ export function roomCloseLockPath(roomId) {
14
15
  return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
15
16
  }
16
17
  function errorText(error) {
@@ -54,16 +55,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
54
55
  throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
55
56
  }
56
57
  async function withIdentityClient(work) {
57
- const client = await attachOursClient({
58
+ const profile = readClientProfile(process.env);
59
+ const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
60
+ const client = await attachOursClient(profile ? {
61
+ endpoint: profile.endpoint,
62
+ expectedInstanceId: profile.expectedInstanceId,
63
+ credentialPath: profile.credentialPath,
64
+ sessionMode: 'external', env: {}, leaseToken,
65
+ } : {
58
66
  env: process.env,
59
- leaseToken: `ours-fleet-room-close-${process.pid}-${randomUUID()}`,
67
+ leaseToken,
60
68
  clientPid: process.pid,
61
69
  });
62
70
  try {
63
71
  return await work(client);
64
72
  }
65
73
  finally {
66
- await client.releaseLease().catch(() => { });
74
+ try {
75
+ await client.releaseLease().catch(() => { });
76
+ }
77
+ finally {
78
+ await client.close();
79
+ }
67
80
  }
68
81
  }
69
82
  function listedIdentity(rows, name) {
@@ -106,11 +119,36 @@ export async function removeExactMemberIdentity(seat) {
106
119
  }
107
120
  });
108
121
  }
122
+ async function assertMemberIdentityAbsent(seat) {
123
+ await withIdentityClient(async (client) => {
124
+ const rows = await client.listIdentities();
125
+ if (rows.some(row => row.name === seat.role_name ||
126
+ (seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
127
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
128
+ }
129
+ });
130
+ }
109
131
  async function retireMember(roomId, seat, deps) {
110
132
  let room = getRoomRecord(roomId);
111
133
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
112
134
  let retirement = current.retirement;
135
+ if (retirement?.phase === 'identity_absent') {
136
+ if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
137
+ if (existsSync(agentDir(current.role_name, true)))
138
+ throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
139
+ await assertMemberIdentityAbsent(current);
140
+ }
141
+ return;
142
+ }
113
143
  if (!retirement) {
144
+ if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
145
+ // A failure before applyRole (for example invite-secret validation) has
146
+ // no supervisor to stop or archive. Settle only proven absence; this
147
+ // path never removes an identity based on missing local evidence.
148
+ await assertMemberIdentityAbsent(current);
149
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
150
+ return;
151
+ }
114
152
  if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
115
153
  if (existsSync(agentDir(current.role_name, true))) {
116
154
  throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
@@ -159,8 +197,6 @@ export async function closeManagedRoom(input) {
159
197
  try {
160
198
  if (room.close?.phase === 'retire_members') {
161
199
  for (const seat of room.member_seats) {
162
- if (seat.retirement?.phase === 'identity_absent')
163
- continue;
164
200
  await retireMember(input.roomId, seat, deps);
165
201
  }
166
202
  room = advanceRoomClose(input.roomId, 'close_cowork');
@@ -58,6 +58,10 @@ export interface CoworkAdapter {
58
58
  briefing: string;
59
59
  quiet_membership?: boolean;
60
60
  anonymous?: boolean;
61
+ activation_requirements?: Array<{
62
+ role: string;
63
+ count: number;
64
+ }>;
61
65
  }): Promise<CoworkRoomCreateResult>;
62
66
  acceptInvite(roomId: string, invite: string, opts: {
63
67
  role: string;
@@ -303,6 +303,7 @@ export function createCoworkAdapter(options = {}) {
303
303
  briefing: opts.briefing,
304
304
  ...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
305
305
  ...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
306
+ ...(opts.activation_requirements === undefined ? {} : { activation_requirements: opts.activation_requirements }),
306
307
  }), 'room.create');
307
308
  if (!result.identity_cid)
308
309
  throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
@@ -21,6 +21,17 @@ export interface StartupWaitPolicy {
21
21
  sleep(ms: number): Promise<void>;
22
22
  }
23
23
  export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
24
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
25
+ export declare function reconcileExistingTaskMembers(input: {
26
+ taskId: string;
27
+ checkOnly?: boolean;
28
+ cowork: Pick<CoworkAdapter, 'getRoom'>;
29
+ expectedLaunches: ReadonlyArray<{
30
+ role_name: string;
31
+ launch_id: string;
32
+ identity_cid: string;
33
+ }>;
34
+ }): Promise<RoomOrchestrationRecord>;
24
35
  export declare function cleanupMembers(input: {
25
36
  roomId: string;
26
37
  taskId?: string;
@@ -14,7 +14,7 @@ import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js
14
14
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
15
15
  import { controlRequest } from '../session/control.js';
16
16
  import { SessionControlError } from '../session/types.js';
17
- import { closeManagedRoom } from './close.js';
17
+ import { closeManagedRoom, roomCloseLockPath } from './close.js';
18
18
  import { withFileLock } from '../atomic-file.js';
19
19
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
20
20
  import { TaskStateError, taskDeletionState } from './task-state.js';
@@ -244,12 +244,16 @@ async function retainRunningLaunch(input) {
244
244
  */
245
245
  async function launchMember(input) {
246
246
  const taskId = input.provision.taskId;
247
- if (!taskId)
247
+ const launch = () => withFileLock(roomCloseLockPath(input.provision.roomId), () => {
248
+ assertProvisioningOpen(input.provision);
248
249
  return launchMemberUnlocked(input);
250
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
251
+ if (!taskId)
252
+ return launch();
249
253
  return withFileLock(taskOperationLockPath(taskId), () => {
250
254
  if (taskDeletionState(taskId) !== 'none')
251
255
  throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
252
- return launchMemberUnlocked(input);
256
+ return launch();
253
257
  }, {}, TASK_OPERATION_LOCK_STALE_MS);
254
258
  }
255
259
  async function launchMemberUnlocked(input) {
@@ -371,8 +375,16 @@ function assertCoworkRoomPolicy(room, expectedAnonymous) {
371
375
  if ((room.anonymous ?? false) !== expectedAnonymous)
372
376
  throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
373
377
  }
378
+ function assertProvisioningOpen(input) {
379
+ if (input.taskId && getTask(input.taskId).terminal_intent)
380
+ throw new Error(`task ${input.taskId} has an accepted terminal intent; refusing to provision members`);
381
+ const room = getRoomRecord(input.roomId);
382
+ if (room?.state === 'closing' || room?.state === 'closed')
383
+ throw new Error(`room ${input.roomId} is ${room.state}; refusing to provision members`);
384
+ }
374
385
  export async function provisionMembers(input) {
375
386
  const { cfg, cowork, roomId, taskId, template } = input;
387
+ assertProvisioningOpen(input);
376
388
  // Deletion-epoch pre-check; each member launch re-checks under the lock.
377
389
  if (taskId && taskDeletionState(taskId) !== 'none')
378
390
  throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
@@ -446,12 +458,25 @@ export async function provisionMembers(input) {
446
458
  provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
447
459
  }))
448
460
  continue;
461
+ // A previous failed launch keeps its invite pointer durably. Never
462
+ // overwrite that requirement until the supported revoke has succeeded;
463
+ // a transport failure must fence subsequent invite issuance too.
464
+ if (currentSeat.invite_id) {
465
+ const observed = await cowork.getRoom(roomId);
466
+ if (!observed || observed.identity_cid !== roomIdentityCid)
467
+ throw new Error('cannot verify room before failed-attempt invite cleanup');
468
+ if (observed.seats.some(seat => seat.invite_id === currentSeat.invite_id
469
+ && seat.seat_state !== 'removed'))
470
+ throw new Error('failed-attempt invite has an admitted seat; reconcile before replacing the member');
471
+ await cowork.revokeInvite(roomId, currentSeat.invite_id);
472
+ }
449
473
  const issued = await cowork.issueInvite(roomId, {
450
474
  mode: 'one_time', role: member.coworkRole, min_accepts: 1,
451
475
  });
452
- const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
453
- updateMemberSeats(roomId, seats);
454
476
  try {
477
+ assertProvisioningOpen(input);
478
+ const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
479
+ updateMemberSeats(roomId, seats);
455
480
  await launchMember({
456
481
  provision: input,
457
482
  member,
@@ -470,7 +495,12 @@ export async function provisionMembers(input) {
470
495
  });
471
496
  }
472
497
  catch (error) {
473
- await cowork.revokeInvite(roomId, issued.invite_id).catch(() => { });
498
+ try {
499
+ await cowork.revokeInvite(roomId, issued.invite_id);
500
+ }
501
+ catch (cleanupError) {
502
+ throw new AggregateError([error, cleanupError], 'member launch failed and invite cleanup is unresolved; retry must revoke the retained requirement first');
503
+ }
474
504
  throw error;
475
505
  }
476
506
  }
@@ -498,7 +528,7 @@ export async function provisionMembers(input) {
498
528
  const remote = await cowork.recoverRoom(roomId);
499
529
  assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
500
530
  const reconciled = reconcileMemberSeats(roomId, members, remote.seats, ownerSeatCid ?? undefined);
501
- if (reconciled.complete)
531
+ if (reconciled.complete && remote.state === 'active')
502
532
  break;
503
533
  if (policy.now() >= deadline) {
504
534
  advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
@@ -538,6 +568,76 @@ export async function provisionMembers(input) {
538
568
  activateTask(taskId);
539
569
  return record;
540
570
  }
571
+ /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
572
+ export async function reconcileExistingTaskMembers(input) {
573
+ return withFileLock(taskOperationLockPath(input.taskId), async () => {
574
+ const task = getTask(input.taskId);
575
+ if (!['provisioning', 'active'].includes(task.state) || task.terminal_intent || taskDeletionState(input.taskId) !== 'none'
576
+ || !task.room_id)
577
+ throw new Error('task is not open for existing-member reconciliation');
578
+ const roomId = task.room_id;
579
+ return withFileLock(roomCloseLockPath(roomId), async () => {
580
+ const current = getRoomRecord(roomId);
581
+ if (!current || !['provisioning', 'active'].includes(current.state) || current.close
582
+ || current.task_id !== input.taskId || !current.room_identity_cid
583
+ || task.room_identity_cid !== current.room_identity_cid)
584
+ throw new Error('room is not open for existing-member reconciliation');
585
+ const names = new Set(input.expectedLaunches.map(seat => seat.role_name));
586
+ if (!names.size || names.size !== input.expectedLaunches.length
587
+ || current.member_seats.length !== names.size
588
+ || current.member_seats.some(seat => !names.has(seat.role_name)))
589
+ throw new Error('expected launch roster does not match the persisted room');
590
+ const remote = await input.cowork.getRoom(roomId);
591
+ if (!remote || remote.room_id !== roomId || remote.identity_cid !== current.room_identity_cid
592
+ || remote.state !== 'active')
593
+ throw new Error('Cowork room is not the exact active room');
594
+ assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
595
+ if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
596
+ throw new Error('expected Owner seat is not active');
597
+ const seats = [];
598
+ for (const seat of current.member_seats) {
599
+ const expected = input.expectedLaunches.find(item => item.role_name === seat.role_name);
600
+ const launch = seat.launch;
601
+ if (seat.seat_state === 'removed' || !seat.invite_id || launch?.state !== 'launched'
602
+ || launch.launch_id !== expected.launch_id || !launch.action_id || !launch.mission_sha256
603
+ || (seat.identity_cid && seat.identity_cid !== expected.identity_cid))
604
+ throw new Error('persisted member launch does not match reconciliation evidence');
605
+ const dir = agentDir(seat.role_name, true);
606
+ if (!launchMatches(dir, { name: seat.role_name, coworkRole: seat.cowork_role }, launch.action_id, launch.mission_sha256, roomId, current.room_identity_cid, seat.invite_id, storedRoomLaunchPolicy(current.room_policy).anonymous))
607
+ throw new Error('member startup provenance mismatch');
608
+ const supervisor = readTempSupervisor(dir);
609
+ if (!supervisor || supervisor.role !== seat.role_name || supervisor.launchId !== expected.launch_id
610
+ || await tempSupervisorLiveness(dir) !== 'running')
611
+ throw new Error('expected member supervisor is not running');
612
+ const ready = readRoomReadiness(current.room_identity_cid, seat.role_name);
613
+ const matches = remote.seats.filter(item => item.display_name === seat.role_name
614
+ && item.seat_state !== 'removed');
615
+ const found = matches[0];
616
+ if (!ready || ready.room !== roomId || ready.invite !== seat.invite_id
617
+ || ready.cid !== expected.identity_cid || matches.length !== 1 || !found
618
+ || found.identity_cid !== expected.identity_cid || found.role !== seat.cowork_role
619
+ || found.seat_state !== 'active' || found.invite_id !== seat.invite_id)
620
+ throw new Error('member readiness or authenticated Cowork seat mismatch');
621
+ seats.push({ ...seat, identity_cid: expected.identity_cid, seat_state: 'active' });
622
+ }
623
+ if (input.checkOnly)
624
+ return current;
625
+ // No mutation until the complete roster has passed; these writes are replayable
626
+ // under the same task/room lifecycle locks if interrupted between files.
627
+ updateMemberSeats(roomId, seats);
628
+ updateTaskMembers(input.taskId, seats.map(seat => ({ name: seat.role_name,
629
+ identity_cid: seat.identity_cid, slot: seat.slot, cowork_role: seat.cowork_role })));
630
+ if (getTask(input.taskId).blocked)
631
+ unblockTask(input.taskId);
632
+ if (current.state !== 'active')
633
+ advanceSaga(roomId, 'activate', 6);
634
+ const activated = current.state === 'active' ? getRoomRecord(roomId) : activateRoom(roomId);
635
+ if (task.state !== 'active')
636
+ activateTask(input.taskId);
637
+ return activated;
638
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
639
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
640
+ }
541
641
  export async function cleanupMembers(input) {
542
642
  const room = getRoomRecord(input.roomId);
543
643
  if (!room)
@@ -112,6 +112,8 @@ export function setOwnerSeat(id, ownerSeatCid, inviteFingerprint) {
112
112
  }
113
113
  export function updateMemberSeats(id, seats) {
114
114
  const r = readRoom(id);
115
+ if (r.state === 'closing' || r.state === 'closed')
116
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
115
117
  r.member_seats = seats;
116
118
  writeRoom(r);
117
119
  return r;
@@ -135,6 +137,8 @@ const LAUNCH_ORDER = [
135
137
  ];
136
138
  export function updateMemberStartup(id, roleName, update) {
137
139
  const r = readRoom(id);
140
+ if (r.state === 'closing' || r.state === 'closed')
141
+ throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
138
142
  const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
139
143
  if (!seat)
140
144
  throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
@@ -151,6 +155,8 @@ export function updateMemberStartup(id, roleName, update) {
151
155
  }
152
156
  export function activateRoom(id) {
153
157
  const r = readRoom(id);
158
+ if (r.state === 'closing' || r.state === 'closed')
159
+ throw new RoomStateError(`room ${id} is ${r.state}; activation is fenced`);
154
160
  r.state = 'active';
155
161
  r.saga = { phase: 'completed', step_index: 0 };
156
162
  delete r.provisioning_detail;
package/dist/runner.js CHANGED
@@ -662,6 +662,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
662
662
  harness: role.harness,
663
663
  config: role.owner_channel,
664
664
  session: arbiter,
665
+ startupPending: () => !sessionStartupComplete,
665
666
  stateDir: dir,
666
667
  env: role.env,
667
668
  log: deps.log,
@@ -756,6 +757,26 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
756
757
  // interruption to steering until this startup turn reaches a terminal
757
758
  // success, so there is neither a deaf gap nor a boot-cancellation loop.
758
759
  monitorLoop = monitor?.run(pid);
760
+ // Owner traffic and agent replies must be observed during a long first
761
+ // turn. Ordinary owner input steers until startup has proved successful.
762
+ if (ownerChannel) {
763
+ try {
764
+ await ownerChannel.start();
765
+ control.setOwnerChannel(ownerChannel);
766
+ }
767
+ catch (error) {
768
+ monitor?.stop();
769
+ if (monitorLoop)
770
+ await monitorLoop;
771
+ await control.close();
772
+ await ownerChannel.close().catch(() => undefined);
773
+ ownerBinder?.release();
774
+ await agentSession.close();
775
+ unsubscribeRecovery?.();
776
+ throw new Error(`[${name}] owner channel failed to start: `
777
+ + `${error?.message ?? String(error)}`);
778
+ }
779
+ }
759
780
  const started = await starting;
760
781
  // A temporary role's first turn can be the active turn when an ours wake
761
782
  // needs immediate attention. A typed console/monitor cancellation ends
@@ -768,6 +789,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
768
789
  if (!started.succeeded && !interruptedForWake) {
769
790
  monitor?.stop();
770
791
  await control.close();
792
+ await ownerChannel?.close().catch(() => undefined);
771
793
  ownerBinder?.release();
772
794
  await agentSession.close();
773
795
  unsubscribeRecovery?.();
@@ -795,26 +817,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
795
817
  deps.log(`[${name}] ${sessionLabel} startup prompt cancelled by ${started.cancellationSource}; `
796
818
  + 'keeping temporary supervisor alive');
797
819
  sessionStartupComplete = true;
798
- if (ownerChannel) {
799
- try {
800
- await ownerChannel.start();
801
- }
802
- catch (error) {
803
- monitor?.stop();
804
- if (monitorLoop)
805
- await monitorLoop;
806
- await ownerChannel.close().catch(() => undefined);
807
- await control.close();
808
- ownerBinder?.release();
809
- await agentSession.close();
810
- unsubscribeRecovery?.();
811
- throw new Error(`[${name}] owner channel failed to start: `
812
- + `${error?.message ?? String(error)}`);
813
- }
814
- }
815
- if (ownerChannel) {
816
- control.setOwnerChannel(ownerChannel);
817
- }
818
820
  reloadLoopConfig = async () => {
819
821
  const nextRole = findRole(loadConfig(configPath), name);
820
822
  const definitions = nextRole.loops ?? [];
@@ -1,3 +1,4 @@
1
+ import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
1
2
  import { type FastifyInstance } from 'fastify';
2
3
  import type { FleetQueryService } from '../application/fleet-query-service.js';
3
4
  import type { RoleRepository } from '../application/role-repository.js';
@@ -31,6 +32,7 @@ export interface WebServices {
31
32
  topologyPromote?: TopologyPromoteService;
32
33
  removal?: RoleRemovalService;
33
34
  taskRooms?: TaskRoomApplicationService;
35
+ oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
34
36
  }
35
37
  export interface WebServer {
36
38
  app: FastifyInstance;
@@ -1,3 +1,4 @@
1
+ import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -51,7 +52,11 @@ export async function buildWebServer(services, boundary, options = {}) {
51
52
  }
52
53
  });
53
54
  app.setErrorHandler(async (error, request, reply) => {
54
- const fleetError = normalizeError(error, request.id);
55
+ const privateToolParseError = request.routeOptions.url === '/api/v1/roles/:id/ours/call'
56
+ && error instanceof Error && 'code' in error
57
+ && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
58
+ const fleetError = normalizeError(privateToolParseError
59
+ ? new FleetError('invalid_request', 'expected a valid JSON tool request') : error, request.id);
55
60
  await audit.record({
56
61
  requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
57
62
  result: 'rejected', errorCode: fleetError.code,
@@ -311,6 +316,18 @@ export async function buildWebServer(services, boundary, options = {}) {
311
316
  });
312
317
  return result;
313
318
  });
319
+ const oursTools = services.oursTools ?? new SupervisorOursTools();
320
+ app.get('/api/v1/roles/:id/ours/tools', async (request) => {
321
+ auth.authenticate(request);
322
+ return oursTools.list(request.params.id);
323
+ });
324
+ app.post('/api/v1/roles/:id/ours/call', async (request) => {
325
+ const session = auth.authenticate(request, true);
326
+ const result = await oursTools.call(request.params.id, request.body);
327
+ await audit.record({ requestId: request.id, browser: session.id,
328
+ action: 'ours.call', result: result.result.isError === true ? 'tool_error' : 'succeeded' });
329
+ return result;
330
+ });
314
331
  app.get('/api/v1/roles/:id', async (request) => {
315
332
  auth.authenticate(request);
316
333
  return services.query.detail(request.params.id);