@ours.network/fleet 2.0.4 → 2.1.0

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.
@@ -14,6 +14,7 @@ import { loadConfig, splitRootFor } from '../config.js';
14
14
  import { assertLayoutFile, validLayoutKey, validateRoomLayout } from '../rooms-tasks/layout-config.js';
15
15
  import { FleetError } from './errors.js';
16
16
  import { layoutDefinitionHash } from './task-layouts.js';
17
+ import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
17
18
  export const ABSENT_REVISION = 'absent';
18
19
  const revisionOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
19
20
  export class RoomLayoutDefinitions {
@@ -25,6 +26,8 @@ export class RoomLayoutDefinitions {
25
26
  }
26
27
  root() { return join(splitRootFor(this.configPath ?? defaultConfigPath()), 'room_layouts'); }
27
28
  pathFor(name) {
29
+ if (name === EMPTY_TASK_LAYOUT)
30
+ throw new FleetError('invalid_request', 'the built-in empty layout cannot be edited or deleted');
28
31
  if (!validLayoutKey(name))
29
32
  throw new FleetError('invalid_request', 'layout name must start with a letter and use letters, digits, - or _');
30
33
  return join(this.root(), `${name}.yaml`);
@@ -68,6 +71,8 @@ export class RoomLayoutDefinitions {
68
71
  return readdirSync(root).filter(file => /\.ya?ml$/.test(file)).sort().map(file => {
69
72
  const name = basename(file, extname(file)), path = join(root, file);
70
73
  try {
74
+ if (name === EMPTY_TASK_LAYOUT)
75
+ throw Error('reserved built-in layout name');
71
76
  if (!validLayoutKey(name))
72
77
  throw Error(`invalid room layout id: ${name}`);
73
78
  assertLayoutFile(path);
@@ -1,7 +1,7 @@
1
1
  import { type RoomLayoutDefinition } from '../rooms-tasks/layout-config.js';
2
2
  import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
3
3
  import type { TaskLayoutLink, TaskRecord } from '../rooms-tasks/types.js';
4
- export type TaskLayoutOperation = 'open' | 'close-room' | 'close';
4
+ export type TaskLayoutOperation = 'start' | 'open' | 'close-room' | 'close';
5
5
  export interface TaskLayoutOperationRecord {
6
6
  /** Only the worker holding the current ID may update the record. */
7
7
  id: string;
@@ -73,6 +73,15 @@ export declare class TaskLayouts {
73
73
  runExists(link: TaskLayoutLink): boolean;
74
74
  /** Idempotent: reuse a run whose provenance matches this task, or snapshot one from the pinned source. */
75
75
  ensureRun(task: TaskRecord, precondition?: (current: TaskRecord) => void): Promise<void>;
76
+ /** Starting a mission initializes every room and its required agents before Active. */
77
+ initializeRooms(task: TaskRecord): Promise<void>;
78
+ addMember(task: TaskRecord, input: {
79
+ key: string;
80
+ role: string;
81
+ requestId: string;
82
+ hash: string;
83
+ definition: import('../config.js').AgentTemplateDefinition;
84
+ }): Promise<void>;
76
85
  private verifyProvenance;
77
86
  readOperation(runId: string): TaskLayoutOperationRecord | undefined;
78
87
  /** Update only the still-current operation; a superseded or forgotten one is never resurrected. */
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { canonicalJson } from '../canonical-json.js';
12
12
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
13
13
  import { assertLayoutFile } from '../rooms-tasks/layout-config.js';
14
- import { getTask as readTask } from '../rooms-tasks/task-state.js';
14
+ import { getTask as readTask, activateTask, blockTask } from '../rooms-tasks/task-state.js';
15
15
  import { stateRoot } from '../paths.js';
16
16
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
17
17
  import { layoutSupervisorId } from '../rooms-tasks/layout-control.js';
@@ -20,6 +20,7 @@ import { FleetError } from './errors.js';
20
20
  import { ownedLayoutRetirementSeats } from '../rooms-tasks/layout-member-retirement.js';
21
21
  import { eraseMemberArtifacts } from '../rooms-tasks/erasure.js';
22
22
  import { assertTaskRegistrationsAbsent } from '../supervisor/catalog.js';
23
+ import { EMPTY_TASK_LAYOUT, EMPTY_TASK_ROOM } from '../rooms-tasks/empty-layout.js';
23
24
  /** Hash of the authorable definition only (never the source file path). */
24
25
  export function layoutDefinitionHash(definition) {
25
26
  const { version, description, participants, rooms } = definition;
@@ -95,12 +96,35 @@ export class TaskLayouts {
95
96
  if (layoutDefinitionHash(source) !== link.definition_hash)
96
97
  throw Error(`room layout ${link.name} changed before this task's layout run was created; create a new task to use the edited layout`);
97
98
  const scoped = taskScopedDefinition(structuredClone(source), current);
99
+ if (link.name === EMPTY_TASK_LAYOUT)
100
+ scoped.rooms[EMPTY_TASK_ROOM].goal = current.brief?.trim() || current.title;
98
101
  const provenance = { task_id: current.task_id, source_hash: link.definition_hash, scoped_hash: layoutDefinitionHash(scoped) };
99
102
  // Provenance precedes the run: a crash in between is re-derived from the same pinned source.
100
103
  replaceFileAtomically(this.provenancePath(link.run_id), JSON.stringify(provenance), 0o600);
101
104
  await service.create(link.name, {}, link.run_id, { source: scoped, cwd: current.workspace?.path });
102
105
  });
103
106
  }
107
+ /** Starting a mission initializes every room and its required agents before Active. */
108
+ async initializeRooms(task) {
109
+ await this.withTaskLock(task.layout.run_id, async () => {
110
+ const current = readTask(task.task_id);
111
+ if (current.deletion?.status === 'pending' || current.terminal_intent || !['provisioning', 'active'].includes(current.state))
112
+ throw new FleetError('conflict', 'task is no longer starting; its room cannot be opened');
113
+ this.verifyProvenance(current);
114
+ const engine = this.service().open(task.layout.run_id);
115
+ for (const key of Object.keys(engine.snapshot().definition.rooms))
116
+ await engine.activate(layoutSupervisorId(), key);
117
+ });
118
+ }
119
+ async addMember(task, input) {
120
+ const link = task.layout;
121
+ await this.withTaskLock(link.run_id, async () => {
122
+ const current = readTask(task.task_id);
123
+ assertLayoutOpenable(current);
124
+ this.verifyProvenance(current);
125
+ await this.service().open(link.run_id).addMember(layoutSupervisorId(), { ...input, room: EMPTY_TASK_ROOM });
126
+ });
127
+ }
104
128
  verifyProvenance(task) {
105
129
  const link = task.layout, path = this.provenancePath(link.run_id);
106
130
  let provenance;
@@ -151,15 +175,18 @@ export class TaskLayouts {
151
175
  ...(state.uncertain ? { uncertain: state.uncertain } : {}),
152
176
  rooms: Object.entries(state.definition.rooms).map(([key, spec]) => {
153
177
  const room = state.rooms[key];
178
+ const additions = Object.entries(state.additional_members ?? {}).filter(([, member]) => member.room === key);
179
+ const roles = { ...spec.roles, ...Object.fromEntries(additions.map(([name, member]) => [name, member.role])) };
154
180
  return {
155
- key, goal: spec.goal, members: [...spec.members], ...(spec.roles ? { roles: { ...spec.roles } } : {}),
181
+ key, goal: spec.goal, members: [...spec.members, ...additions.map(([name]) => name)],
182
+ ...(Object.keys(roles).length ? { roles } : {}),
156
183
  state: room?.state ?? 'declared', ready: [...(room?.ready ?? [])],
157
184
  ...(room?.native ? { room_id: room.native.room_id, identity_cid: room.native.identity_cid } : {}),
158
185
  };
159
186
  }),
160
187
  participants: Object.entries(state.participants).map(([key, p]) => ({
161
188
  key, ...(p.participant ? { participant: p.participant, room: p.room } : {}),
162
- agent_template: state.definition.participants[p.participant ?? key]?.agent_template, owned: p.owned, retired: Boolean(p.retired),
189
+ agent_template: state.definition.participants[p.participant ?? key]?.agent_template ?? state.additional_members?.[key]?.template, owned: p.owned, retired: Boolean(p.retired),
163
190
  ...(p.instance?.agent ? { agent: p.instance.agent } : {}), ...(p.instance?.cid ? { cid: p.instance.cid } : {}),
164
191
  })),
165
192
  };
@@ -169,7 +196,7 @@ export class TaskLayouts {
169
196
  const link = task.layout;
170
197
  if (!link)
171
198
  throw Error(`task ${task.task_id} has no layout`);
172
- if (operation !== 'close' && (!room || !ROOM_KEY.test(room)))
199
+ if (!['start', 'close'].includes(operation) && (!room || !ROOM_KEY.test(room)))
173
200
  throw new FleetError('invalid_request', 'a valid room key is required');
174
201
  if (room) {
175
202
  const definition = this.runExists(link) ? this.service().open(link.run_id, true).snapshot().definition : undefined;
@@ -177,13 +204,20 @@ export class TaskLayouts {
177
204
  throw new FleetError('resource_not_found', `unknown layout room: ${room}`);
178
205
  }
179
206
  const record = { id: randomUUID(), operation, ...(room ? { room } : {}), status: 'launching' };
180
- await withFileLock(this.operationPath(link.run_id) + '.lock', () => {
207
+ let accepted = false;
208
+ const selected = await withFileLock(this.operationPath(link.run_id) + '.lock', () => {
181
209
  const current = this.readOperation(link.run_id);
182
210
  // Full close always proceeds: terminal cleanup must not wait behind an open; the engine lock serializes them.
211
+ if (operation === 'start' && current?.operation === 'start' && ['launching', 'running'].includes(current.status) && Date.now() - Date.parse(current.updated_at) < 10 * 60_000)
212
+ return current;
183
213
  if (operation !== 'close' && current && ['launching', 'running'].includes(current.status) && Date.now() - Date.parse(current.updated_at) < 10 * 60_000)
184
214
  throw new FleetError('conflict', `layout operation already in progress: ${current.operation}${current.room ? ' ' + current.room : ''}`);
185
215
  this.writeOperation(link.run_id, record);
216
+ accepted = true;
217
+ return record;
186
218
  });
219
+ if (!accepted)
220
+ return selected;
187
221
  const args = ['task', '_layout', task.task_id, operation, ...(room ? [room] : []), '--operation-id', record.id];
188
222
  const launch = this.deps.launch
189
223
  ?? ((cli, name) => launchFleetWorker(cli, name, this.configPath, this.deps.binPath?.()));
@@ -218,6 +252,20 @@ export class TaskLayouts {
218
252
  }
219
253
  async perform(task, operation, room) {
220
254
  const link = task.layout;
255
+ if (operation === 'start') {
256
+ try {
257
+ await this.initializeRooms(task);
258
+ if (readTask(task.task_id).state !== 'active')
259
+ activateTask(task.task_id);
260
+ }
261
+ catch (error) {
262
+ const current = readTask(task.task_id);
263
+ if (current.state === 'provisioning' && !current.terminal_intent && current.deletion?.status !== 'pending')
264
+ blockTask(task.task_id, `layout startup failed: ${error.message}`);
265
+ throw error;
266
+ }
267
+ return;
268
+ }
221
269
  if (operation === 'open') {
222
270
  // Re-read under the task-layout lock: a deletion or terminal action accepted after HTTP admission wins.
223
271
  await this.ensureRun(task, assertLayoutOpenable);
@@ -79,6 +79,8 @@ export interface CreateTaskRequest {
79
79
  anonymous?: boolean;
80
80
  /** Room Layout name: the task's rooms and participants come from a layout run. */
81
81
  layout?: string;
82
+ /** HTTP acceptance returns while a detached worker initializes the layout. */
83
+ deferLayoutStart?: boolean;
82
84
  }
83
85
  export interface CreateRoomRequest {
84
86
  actor: TaskRoomActor;
@@ -167,7 +169,7 @@ export declare class TaskRoomApplicationService {
167
169
  runTaskLayoutOperation(input: {
168
170
  actor: TaskRoomActor;
169
171
  taskId: string;
170
- operation: 'open' | 'close-room' | 'close';
172
+ operation: 'start' | 'open' | 'close-room' | 'close';
171
173
  room?: string;
172
174
  operationId?: string;
173
175
  }): Promise<import("./task-layouts.js").TaskLayoutView>;
@@ -179,6 +181,7 @@ export declare class TaskRoomApplicationService {
179
181
  template?: string;
180
182
  members?: MemberOverrides;
181
183
  anonymous?: boolean;
184
+ deferLayoutStart?: boolean;
182
185
  }): Promise<TaskRecord>;
183
186
  listTasks(filter?: {
184
187
  state?: TaskState | TaskState[];
@@ -404,6 +407,7 @@ export declare class TaskRoomApplicationService {
404
407
  template?: string;
405
408
  members?: MemberOverrides;
406
409
  anonymous?: boolean;
410
+ deferLayoutStart?: boolean;
407
411
  }): Promise<{
408
412
  task: TaskRecord;
409
413
  status: 'ready' | 'already_active' | 'in_progress';
@@ -1,4 +1,5 @@
1
1
  import { TaskLayouts, assertLayoutOpenable, writeTaskBrief } from './task-layouts.js';
2
+ import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
2
3
  import { FleetError } from './errors.js';
3
4
  import { readFileSync } from 'node:fs';
4
5
  import { taskLiveReadiness } from '../rooms-tasks/live-readiness.js';
@@ -78,7 +79,7 @@ export class TaskRoomApplicationService {
78
79
  const { requestMemberAddition } = await import('../rooms-tasks/add-member.js');
79
80
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
80
81
  return requestMemberAddition({ taskId, request, cfg, configPath: this.configurationPath,
81
- cowork: (this.deps.cowork ?? createCoworkAdapter)(cfg), binPath: (this.deps.binPath ?? getBinPath)(), provision: this.deps.provisionMembers });
82
+ cowork: readTask(taskId).layout ? undefined : (this.deps.cowork ?? createCoworkAdapter)(cfg), binPath: (this.deps.binPath ?? getBinPath)(), provision: this.deps.provisionMembers, layouts: this.layouts() });
82
83
  }
83
84
  async memberAddition(taskId, requestId) { const { memberAddition } = await import('../rooms-tasks/add-member.js'); return memberAddition(taskId, requestId); }
84
85
  layouts() {
@@ -103,10 +104,10 @@ export class TaskRoomApplicationService {
103
104
  recordFleetAuditPresentation({ kind: 'task', operation: 'create', id: task.task_id,
104
105
  title: task.title, previousState: 'none', newState: task.state, revision: task.created_at,
105
106
  list: task.list_name ?? 'default', roomId: undefined, template: `layout:${layout.name}`, agents: [] });
106
- return task.state === 'provisioning' ? (await this.ensureLayoutWork(task)).task : task;
107
+ return task.state === 'provisioning' ? (await this.ensureLayoutWork(task, request.deferLayoutStart)).task : task;
107
108
  }
108
109
  /** Layout tasks never provision the legacy single room; their run is a local snapshot. */
109
- async ensureLayoutWork(task) {
110
+ async ensureLayoutWork(task, defer = false) {
110
111
  if (TASK_TERMINAL_STATES.includes(task.state))
111
112
  throw new TaskRoomApplicationError('task_terminal', 'task terminal', { task: task.task_id, state: task.state });
112
113
  if (task.state === 'active' || task.state === 'review')
@@ -119,6 +120,11 @@ export class TaskRoomApplicationService {
119
120
  }
120
121
  try {
121
122
  await this.layouts().ensureRun(task);
123
+ if (defer) {
124
+ await this.layouts().launch(task, 'start');
125
+ return { task: readTask(task.task_id), status: 'in_progress' };
126
+ }
127
+ await this.layouts().initializeRooms(task);
122
128
  }
123
129
  catch (error) {
124
130
  const reason = `layout run could not be created: ${error.message}`;
@@ -362,10 +368,11 @@ export class TaskRoomApplicationService {
362
368
  const blocker = task.outcome?.summary ?? task.blocked?.reason;
363
369
  return {
364
370
  kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room: undefined,
365
- launch: { template: `layout:${task.layout.name}`, anonymous: false, owner_attached: false },
371
+ launch: { template: `layout:${task.layout.name}`, anonymous: false,
372
+ owner_attached: task.layout.name === EMPTY_TASK_LAYOUT && this.layouts().view(task)?.rooms.some(room => room.state === 'active') === true },
366
373
  members: { expected: 0, active: 0, launched: 0 },
367
374
  ...(blocker ? { blocker } : {}),
368
- ...(!ready && !failed ? { next_action: `Correct the blocker, then run ours-fleet task start ${task.task_id}.` } : {}),
375
+ ...(!ready && !failed && blocker ? { next_action: `Correct the blocker, then run ours-fleet task start ${task.task_id}.` } : {}),
369
376
  };
370
377
  }
371
378
  const room = task.room_id ? getRoomRecord(task.room_id) : undefined;
@@ -584,6 +591,8 @@ export class TaskRoomApplicationService {
584
591
  }
585
592
  async launchTaskProvisioning(taskId) {
586
593
  const outcome = this.taskProvisioningOutcome(taskId);
594
+ if (outcome.task.layout)
595
+ return outcome; // layout startup already has its own durable operation/worker
587
596
  if (outcome.kind === 'in_progress' && !outcome.next_action) {
588
597
  await launchFleetWorker(['task', '_provision', taskId], `task-provision-${taskId}`, this.configurationPath, this.deps.binPath?.());
589
598
  }
@@ -882,7 +891,7 @@ export class TaskRoomApplicationService {
882
891
  if (task.layout) {
883
892
  if (input.template || input.members || input.anonymous !== undefined)
884
893
  throw new FleetError('invalid_request', 'a layout task cannot take a room template or member overrides');
885
- return this.ensureLayoutWork(task);
894
+ return this.ensureLayoutWork(task, input.deferLayoutStart);
886
895
  }
887
896
  const recordedRoom = task.room_id ? getRoomRecord(task.room_id) : undefined;
888
897
  const recordedPolicy = storedRoomLaunchPolicy(recordedRoom?.room_policy ?? task.execution_plan?.room_policy);
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.0.4",
3
- "buildId": "26fbfdb783d8",
4
- "commit": "b1e84b5fad5d06ca583aa5a9c1b45131fba62c6e",
2
+ "version": "2.1.0",
3
+ "buildId": "697e8bf9e53c",
4
+ "commit": "2805e0e84c2a3bad05efdbe895fd8fb6e9d2aff4",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-25T18:18:29.763Z",
6
+ "builtAt": "2026-10-27T12:22:22.859Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -2,6 +2,7 @@ import type { FleetConfig } from '../config.js';
2
2
  import { TaskStateError } from './task-state.js';
3
3
  import { provisionMembers } from './provision.js';
4
4
  import type { CoworkAdapter } from './cowork-adapter.js';
5
+ import type { TaskLayouts } from '../application/task-layouts.js';
5
6
  export declare class MemberAdditionRejected extends TaskStateError {
6
7
  readonly accepted = false;
7
8
  }
@@ -29,10 +30,11 @@ export declare function requestMemberAddition(input: {
29
30
  taskId: string;
30
31
  request: AddMemberRequest;
31
32
  cfg: FleetConfig;
32
- cowork: CoworkAdapter;
33
+ cowork?: CoworkAdapter;
33
34
  configPath?: string;
34
35
  binPath: string;
35
36
  provision?: typeof provisionMembers;
37
+ layouts?: TaskLayouts;
36
38
  }): Promise<{
37
39
  requestId: string;
38
40
  taskId: string;
@@ -10,6 +10,9 @@ import { getRoomRecord } from './room-state.js';
10
10
  import { prepareExecutionPlan } from './member-overrides.js';
11
11
  import { sealTemplateSnapshot } from './templates.js';
12
12
  import { provisionMembers } from './provision.js';
13
+ import { EMPTY_TASK_LAYOUT, EMPTY_TASK_ROOM } from './empty-layout.js';
14
+ import { layoutOwnedMemberName } from './layout-member-retirement.js';
15
+ import { validLayoutKey } from './layout-config.js';
13
16
  export class MemberAdditionRejected extends TaskStateError {
14
17
  accepted = false;
15
18
  }
@@ -46,6 +49,43 @@ export async function requestMemberAddition(input) {
46
49
  }
47
50
  const task = getTask(taskId);
48
51
  const room = task.room_id && getRoomRecord(task.room_id);
52
+ if (task.layout?.name === EMPTY_TASK_LAYOUT && input.layouts) {
53
+ if (!validLayoutKey(r.slot))
54
+ throw new MemberAdditionRejected('Invalid member name');
55
+ const view = input.layouts.view(task);
56
+ if (!['active', 'review'].includes(task.state) || task.terminal_intent || task.deletion?.status === 'pending'
57
+ || view?.rooms.find(room => room.key === EMPTY_TASK_ROOM)?.state !== 'active' || view.uncertain || view.closing)
58
+ throw new MemberAdditionRejected('Adding an agent requires an active task room');
59
+ if (view.participants.some(p => p.key === r.slot))
60
+ throw new MemberAdditionRejected('This member slot already exists');
61
+ if (r.slot === (input.cfg.rooms?.owner.role ?? 'Owner'))
62
+ throw new MemberAdditionRejected('A member cannot use the Owner role');
63
+ const definition = (() => {
64
+ try {
65
+ const plan = prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
66
+ members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, brain: r.brain, ...(task.workspace ? { cwd: task.workspace.path } : {}) } });
67
+ return Object.values(plan.launchDefinitions)[0];
68
+ }
69
+ catch (error) {
70
+ throw new MemberAdditionRejected(error instanceof Error ? error.message : 'Invalid member configuration');
71
+ }
72
+ })();
73
+ const receipt = { taskId, requestId: r.requestId,
74
+ roleId: layoutOwnedMemberName(task.layout.run_id, r.slot), hash, state: 'running' };
75
+ const save = () => { replaceFileAtomically(path, JSON.stringify(receipt) + '\n', 0o600); publishFleetResourceCommit(path); };
76
+ save();
77
+ running.add(path);
78
+ void input.layouts.addMember(task, { key: r.slot, role: r.slot, requestId: r.requestId, hash, definition }).then(() => {
79
+ receipt.state = 'succeeded';
80
+ }).catch(() => { receipt.state = 'attention'; receipt.error = 'Member launch is not confirmed. Inspect the task layout and agent before recovery.'; })
81
+ .finally(() => { try {
82
+ save();
83
+ }
84
+ finally {
85
+ running.delete(path);
86
+ } }).catch(() => { running.delete(path); });
87
+ return memberAddition(taskId, r.requestId);
88
+ }
49
89
  if (!room || !['active', 'review'].includes(task.state) || task.terminal_intent || room.state !== 'active'
50
90
  || room.task_id !== taskId || room.room_identity_cid !== task.room_identity_cid)
51
91
  throw new MemberAdditionRejected('Adding an agent requires an active task room');
@@ -1334,12 +1334,12 @@ export function registerTaskCommands(parent, cOpt) {
1334
1334
  }
1335
1335
  });
1336
1336
  cOpt(taskCmd.command('_layout <id> <operation> [room]', { hidden: true }))
1337
- .description('internal: run an accepted layout room operation (open, close-room, close)')
1337
+ .description('internal: run an accepted layout operation (start, open, close-room, close)')
1338
1338
  .option('--json', 'JSON output')
1339
1339
  .option('--operation-id <id>', 'accepted operation ID; status updates apply only while it is current')
1340
1340
  .action(async (id, operation, room, opts) => {
1341
1341
  try {
1342
- if (!['open', 'close-room', 'close'].includes(operation))
1342
+ if (!['start', 'open', 'close-room', 'close'].includes(operation))
1343
1343
  throw new Error(`unknown layout operation: ${operation}`);
1344
1344
  const view = await taskRoomService(opts.configuration).runTaskLayoutOperation({
1345
1345
  actor: { kind: 'internal_worker', surface: 'cli' }, taskId: id,
@@ -0,0 +1,5 @@
1
+ import type { RoomLayoutDefinition } from './layout-config.js';
2
+ /** Reserved built-in source; task runs snapshot it just like a saved layout. */
3
+ export declare const EMPTY_TASK_LAYOUT = "builtin-empty";
4
+ export declare const EMPTY_TASK_ROOM = "mission";
5
+ export declare function emptyTaskLayout(): RoomLayoutDefinition;
@@ -0,0 +1,8 @@
1
+ /** Reserved built-in source; task runs snapshot it just like a saved layout. */
2
+ export const EMPTY_TASK_LAYOUT = 'builtin-empty';
3
+ export const EMPTY_TASK_ROOM = 'mission';
4
+ export function emptyTaskLayout() {
5
+ return { version: 1, participants: {}, rooms: {
6
+ [EMPTY_TASK_ROOM]: { goal: 'Work on the mission', members: [] },
7
+ } };
8
+ }
@@ -24,8 +24,8 @@ export function validateRoomLayout(raw, label = 'room layout') {
24
24
  text(doc.description, `${label}.description`);
25
25
  for (const section of ['participants', 'rooms']) {
26
26
  const value = doc[section];
27
- if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length)
28
- throw Error(`${label}.${section}: non-empty mapping required`);
27
+ if (!value || typeof value !== 'object' || Array.isArray(value) || (section === 'rooms' && !Object.keys(value).length))
28
+ throw Error(`${label}.${section}: ${section === 'rooms' ? 'non-empty ' : ''}mapping required`);
29
29
  for (const key of Object.keys(value))
30
30
  if (!validLayoutKey(key))
31
31
  throw Error(`${label}: invalid key ${key}`);
@@ -12,7 +12,7 @@ export declare class NativeLayoutSupervisor implements LayoutSupervisor {
12
12
  inspect(agent: string, temporary: boolean): Promise<LayoutInstance>;
13
13
  private call;
14
14
  verify(i: LayoutInstance): Promise<void>;
15
- spawn(key: string, template: string): Promise<LayoutInstance>;
15
+ spawn(key: string, template: string, snapshot?: AgentTemplateDefinition): Promise<LayoutInstance>;
16
16
  join(i: LayoutInstance, invite: string, roomCid: string, membership?: {
17
17
  roomId: string;
18
18
  role: string;
@@ -14,6 +14,7 @@ import { callRemoteLayoutBinding } from './layout-binding-client.js';
14
14
  import { readClientProfile } from '../client-profile.js';
15
15
  import { tempSupervisorLiveness } from '../temp-lifecycle.js';
16
16
  import { layoutOwnedMemberName, retireOwnedLayoutMember } from './layout-member-retirement.js';
17
+ import { EMPTY_TASK_LAYOUT, emptyTaskLayout } from './empty-layout.js';
17
18
  export class NativeLayoutSupervisor {
18
19
  configPath;
19
20
  runId;
@@ -47,8 +48,8 @@ export class NativeLayoutSupervisor {
47
48
  return this.request(i.agent, i.temporary, { ...request, instance: i });
48
49
  }
49
50
  async verify(i) { await this.call(i, { action: 'verify' }); }
50
- async spawn(key, template) {
51
- const definition = this.templates[template];
51
+ async spawn(key, template, snapshot) {
52
+ const definition = snapshot ?? this.templates[template];
52
53
  if (!definition)
53
54
  throw Error(`agent template not found in run snapshot: ${template}`);
54
55
  const name = layoutOwnedMemberName(this.runId, key);
@@ -145,6 +146,8 @@ export class RoomLayoutService {
145
146
  }
146
147
  /** Reads only the named file, so an unrelated broken layout cannot block this one. */
147
148
  definition(name) {
149
+ if (name === EMPTY_TASK_LAYOUT)
150
+ return emptyTaskLayout();
148
151
  if (!validLayoutKey(name))
149
152
  throw Error(`room layout not found: ${name}`);
150
153
  const root = join(splitRootFor(this.configPath ?? defaultConfigPath()), 'room_layouts');
@@ -53,7 +53,7 @@ export interface LayoutSupervisor {
53
53
  id: string;
54
54
  /** Must verify current launch, CID, live session and standalone ownership. */
55
55
  verify(instance: LayoutInstance): Promise<void>;
56
- spawn(key: string, template: string): Promise<LayoutInstance>;
56
+ spawn(key: string, template: string, definition?: import('../config.js').AgentTemplateDefinition): Promise<LayoutInstance>;
57
57
  join(instance: LayoutInstance, invite: string, roomCid: string, membership?: {
58
58
  roomId: string;
59
59
  role: string;
@@ -86,6 +86,14 @@ export interface RoomLayoutState {
86
86
  controller: string;
87
87
  participants: Record<string, ParticipantState>;
88
88
  rooms: Record<string, RoomState>;
89
+ /** Runtime additions leave the pinned source definition unchanged. */
90
+ additional_members?: Record<string, {
91
+ room: string;
92
+ role: string;
93
+ template: string;
94
+ request_id: string;
95
+ hash: string;
96
+ }>;
89
97
  order: string[];
90
98
  closed: boolean;
91
99
  closing?: boolean;
@@ -109,6 +117,15 @@ export declare class RoomLayout {
109
117
  private mutation;
110
118
  private activateRoom;
111
119
  activate(actor: string, key: string): Promise<void>;
120
+ /** Append an explicitly requested agent to an active owner-only room, with durable retry identity. */
121
+ addMember(actor: string, input: {
122
+ key: string;
123
+ room: string;
124
+ role: string;
125
+ requestId: string;
126
+ hash: string;
127
+ definition: import('../config.js').AgentTemplateDefinition;
128
+ }): Promise<void>;
112
129
  private assertRoomActive;
113
130
  private archiveRoom;
114
131
  closeRoom(actor: string, key: string): Promise<void>;
@@ -14,17 +14,20 @@ function need(ok, message) { if (!ok)
14
14
  throw Error(message); }
15
15
  /** Room/participant keys cannot contain ':', so scoped instance keys cannot collide. */
16
16
  function instanceKey(definition, participant, room) {
17
- return definition.participants[participant].instance_scope === 'room' ? `${room}:${participant}` : participant;
17
+ return definition.participants[participant]?.instance_scope === 'room' ? `${room}:${participant}` : participant;
18
18
  }
19
19
  export function validateLayout(def) {
20
- need(Object.keys(def.rooms).length && Object.keys(def.participants).length, 'rooms and participants required');
20
+ need(Object.keys(def.rooms).length, 'rooms required');
21
+ const empty = Object.keys(def.participants).length === 0;
22
+ if (empty)
23
+ need(Object.keys(def.rooms).length === 1, 'an empty layout requires exactly one room');
21
24
  for (const [key, participant] of Object.entries(def.participants)) {
22
25
  need(participant.instance_scope === undefined || ['layout', 'room'].includes(participant.instance_scope), `invalid instance_scope for ${key}`);
23
26
  if (participant.instance_scope === 'room')
24
27
  need(typeof participant.agent_template === 'string' && participant.agent_template.trim(), `room-scoped participant ${key} requires agent_template`);
25
28
  }
26
29
  for (const [key, room] of Object.entries(def.rooms)) {
27
- need(key.length && room.goal.trim() && room.members.length, 'room goal and members required');
30
+ need(key.length && room.goal.trim() && (empty || room.members.length), 'room goal and members required');
28
31
  need(new Set(room.members).size === room.members.length, 'duplicate participant in room');
29
32
  for (const member of room.members)
30
33
  need(own(def.participants, member), `unknown participant ${member}`);
@@ -87,6 +90,8 @@ export class RoomLayout {
87
90
  });
88
91
  }
89
92
  checkOwnerRoles(definition) {
93
+ if (!Object.keys(definition.participants).length)
94
+ need(this.owner, 'an empty layout requires an attached room owner');
90
95
  if (!this.owner)
91
96
  return;
92
97
  for (const room of Object.values(definition.rooms))
@@ -113,6 +118,9 @@ export class RoomLayout {
113
118
  this.save(s);
114
119
  }
115
120
  async activateRoom(s, key, spec) {
121
+ const additions = Object.entries(s.additional_members ?? {}).filter(([, member]) => member.room === key);
122
+ spec = { ...spec, members: [...spec.members, ...additions.map(([name]) => name)],
123
+ roles: { ...spec.roles, ...Object.fromEntries(additions.map(([name, member]) => [name, member.role])) } };
116
124
  if (s.owner)
117
125
  need(this.owner && canonicalJson(s.owner) === canonicalJson(ownerReference(this.owner)), 'room owner configuration changed');
118
126
  this.checkOwnerRoles(s.definition);
@@ -129,15 +137,18 @@ export class RoomLayout {
129
137
  return;
130
138
  }
131
139
  const room = s.rooms[key] ??= { spec, state: 'provisioning', ready: [] };
140
+ room.spec = spec;
132
141
  this.save(s);
133
142
  for (const member of spec.members) {
134
143
  const resolvedKey = instanceKey(s.definition, member, key), p = s.participants[resolvedKey];
135
144
  if (!p.instance) {
136
- const template = s.definition.participants[member]?.agent_template;
145
+ const template = s.definition.participants[member]?.agent_template ?? s.additional_members?.[member]?.template;
137
146
  need(template, `no factory for ${member}`);
138
147
  if (this.supervisor.taskSupervised)
139
148
  p.task_supervised = true;
140
- await this.mutation(s, `spawn:${resolvedKey}`, () => this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
149
+ await this.mutation(s, `spawn:${resolvedKey}`, () => s.additional_members?.[member]
150
+ ? this.supervisor.spawn(resolvedKey, template, s.agent_templates?.[template])
151
+ : this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
141
152
  await this.verify(p.instance);
142
153
  }
143
154
  }
@@ -147,7 +158,8 @@ export class RoomLayout {
147
158
  room_name: `${key.slice(0, 24)}-${createHash('sha256').update(this.file + '\0' + key).digest('hex').slice(0, 24)}`,
148
159
  goal: spec.goal, briefing: JSON.stringify({ goal: spec.goal, contract: spec.contract }),
149
160
  quiet_membership: spec.quiet_membership ?? true, anonymous: spec.anonymous ?? false,
150
- activation_requirements: [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
161
+ activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
162
+ : [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
151
163
  }), native => { room.native = native; s.order.push(key); });
152
164
  if (s.owner && !room.owner_ready)
153
165
  await this.mutation(s, `owner:${key}`, async () => {
@@ -188,6 +200,28 @@ export class RoomLayout {
188
200
  await this.activateRoom(s, key, s.definition.rooms[key]);
189
201
  });
190
202
  }
203
+ /** Append an explicitly requested agent to an active owner-only room, with durable retry identity. */
204
+ addMember(actor, input) {
205
+ return this.operation(actor, async (s) => {
206
+ need(!Object.keys(s.definition.participants).length, 'manual layout additions require an empty source layout');
207
+ need(/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(input.key) && !['constructor', 'prototype', '__proto__'].includes(input.key), 'invalid member key');
208
+ need(s.owner && input.role !== s.owner.role, 'participant cannot use the owner role');
209
+ const existing = s.additional_members?.[input.key];
210
+ if (existing)
211
+ need(existing.request_id === input.requestId && existing.hash === input.hash, 'this member slot already exists');
212
+ else {
213
+ need(s.rooms[input.room]?.state === 'active', 'adding an agent requires an active layout room');
214
+ await this.assertRoomActive(s, input.room);
215
+ const template = `added-${input.key}`;
216
+ (s.additional_members ??= {})[input.key] = { room: input.room, role: input.role, template, request_id: input.requestId, hash: input.hash };
217
+ (s.agent_templates ??= {})[template] = structuredClone(input.definition);
218
+ s.participants[input.key] = { owned: true };
219
+ s.rooms[input.room].state = 'provisioning';
220
+ this.save(s);
221
+ }
222
+ await this.activateRoom(s, input.room, s.definition.rooms[input.room]);
223
+ });
224
+ }
191
225
  async assertRoomActive(s, key) {
192
226
  const room = s.rooms[key], native = await this.cowork.getRoom(room.native.room_id);
193
227
  need(native?.identity_cid === room.native.identity_cid && native.state === 'active', 'native room missing, changed or inactive');
@@ -1,6 +1,7 @@
1
1
  import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
2
2
  import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
3
  import { stateRoot, defaultConfigPath } from '../paths.js';
4
+ import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
4
5
  import { conversationPreview } from '../application/conversation-history.js';
5
6
  import { registerConversationStream } from './conversation-stream.js';
6
7
  import { conversationHasAttachment } from '../application/conversation-history.js';
@@ -561,9 +562,9 @@ export async function buildWebServer(services, boundary, options = {}) {
561
562
  const body = request.body;
562
563
  if (typeof body?.title !== 'string' || !body.title)
563
564
  throw new FleetError('invalid_request', 'title is required');
564
- // Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
565
- if (typeof body.layout !== 'string' || !body.layout)
566
- throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
565
+ // Omission selects a real built-in layout, never a legacy no-room task.
566
+ if (body.layout !== undefined && (typeof body.layout !== 'string' || !body.layout))
567
+ throw new FleetError('invalid_request', 'layout must be a non-empty room layout name, or omitted for the empty layout');
567
568
  if (body.template !== undefined || body.noRoom !== undefined)
568
569
  throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
569
570
  const task = await taskApi(() => requireTaskRooms().createTask({
@@ -571,7 +572,8 @@ export async function buildWebServer(services, boundary, options = {}) {
571
572
  brief: typeof body.brief === 'string' ? body.brief : undefined,
572
573
  backlog: body.backlog === true,
573
574
  list: typeof body.list === 'string' ? body.list : undefined,
574
- layout: body.layout,
575
+ layout: body.layout === undefined ? EMPTY_TASK_LAYOUT : body.layout,
576
+ deferLayoutStart: true,
575
577
  idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
576
578
  ? request.headers['idempotency-key'] : undefined,
577
579
  origin: { type: 'web' },
@@ -748,7 +750,7 @@ export async function buildWebServer(services, boundary, options = {}) {
748
750
  // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
749
751
  if (!api.getTask(taskId).task.layout)
750
752
  throw new FleetError('conflict', 'choose a room layout for this task before starting it');
751
- const task = await api.startTask(input);
753
+ const task = await api.startTask({ ...input, deferLayoutStart: true });
752
754
  const provisioning = await api.launchTaskProvisioning(taskId);
753
755
  if (provisioning.kind !== 'ready')
754
756
  reply.code(202);
@@ -3,6 +3,40 @@
3
3
  Room Layout describes room goals, participants and their shared or independent
4
4
  sessions. Rooms and agents have independent lifecycles.
5
5
 
6
+ ## Empty mission layout
7
+
8
+ `POST /api/v1/tasks` may omit `layout`. Fleet then assigns the reserved built-in
9
+ `builtin-empty` layout: one `mission` room and no agent participants. It is a real
10
+ task layout with the usual pinned definition, private run snapshot, room ownership,
11
+ and finish/cancel/delete cleanup. The built-in source cannot be edited or deleted;
12
+ explicit saved-layout names select their configured rooms and participants.
13
+
14
+ Starting this default mission creates its native room and admits the configured
15
+ Owner CID before reporting Active. Starting a saved layout likewise initializes all
16
+ its rooms and required agents automatically, in definition order. Shared
17
+ participants are reused across their rooms. The HTTP request accepts startup
18
+ promptly and a detached worker completes it; the task remains Provisioning until
19
+ all rooms are ready. No agents are spawned. Owner attachment must be
20
+ enabled and an Owner invite configured; an empty ownerless room is rejected.
21
+ Saving with `backlog: true` creates no native room or agents until Start. Unknown
22
+ native mutation outcomes retain the existing reconciliation marker and are not
23
+ blindly replayed. Repeated requests with the same idempotency key reuse the task.
24
+
25
+ An active built-in empty mission accepts agents later through
26
+ `POST /api/v1/tasks/:id/members` and the existing durable member-addition receipt
27
+ API. These explicit additions retain their own launch snapshots alongside the
28
+ original empty definition. They use the mission workspace and existing agent
29
+ ownership, admission, assignment and retirement checks. A repeated request ID
30
+ observes the original receipt; an uncertain launch requires inspection, not a
31
+ second automatic launch. The retained room-opening endpoint remains available for recovery of older
32
+ active tasks whose rooms were declared but never initialized. New missions do not
33
+ require a separate room-opening action.
34
+
35
+ Layout YAML may also describe a single Owner room using `participants: {}` and
36
+ `members: []`. Other layouts continue to require valid participant references.
37
+
38
+ ## Saved layouts
39
+
6
40
  Place a layout at `fleet/room_layouts/<name>.yaml` beside the split configuration
7
41
  for `fleet.yaml`. Agent Templates remain in `fleet/agent_templates/`.
8
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",