@ours.network/fleet 1.2.0-nightly.19 → 1.2.0-nightly.20

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.
Files changed (99) hide show
  1. package/README.md +10 -1
  2. package/dist/agent-ours/correspondence.d.ts +10 -0
  3. package/dist/agent-ours/correspondence.js +1 -0
  4. package/dist/agent-ours/runtime.d.ts +6 -0
  5. package/dist/agent-ours/runtime.js +36 -0
  6. package/dist/agent-ours/service.js +2 -1
  7. package/dist/application/conversation-history.d.ts +6 -0
  8. package/dist/application/conversation-history.js +65 -0
  9. package/dist/application/fleet-query-service.d.ts +1 -1
  10. package/dist/application/fleet-query-service.js +2 -2
  11. package/dist/application/preset-provenance.d.ts +19 -0
  12. package/dist/application/preset-provenance.js +59 -0
  13. package/dist/application/room-layout-definitions.d.ts +45 -0
  14. package/dist/application/room-layout-definitions.js +130 -0
  15. package/dist/application/session-control.d.ts +8 -0
  16. package/dist/application/session-control.js +21 -3
  17. package/dist/application/task-layouts.d.ts +89 -0
  18. package/dist/application/task-layouts.js +248 -0
  19. package/dist/application/task-room-service.d.ts +82 -0
  20. package/dist/application/task-room-service.js +197 -9
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli.js +2 -0
  23. package/dist/config.d.ts +3 -0
  24. package/dist/config.js +7 -0
  25. package/dist/init-wizard.js +4 -2
  26. package/dist/rooms-tasks/add-member.d.ts +48 -0
  27. package/dist/rooms-tasks/add-member.js +83 -0
  28. package/dist/rooms-tasks/cli.js +20 -0
  29. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  30. package/dist/rooms-tasks/external-worker.js +3 -3
  31. package/dist/rooms-tasks/layout-cli.d.ts +2 -0
  32. package/dist/rooms-tasks/layout-cli.js +81 -0
  33. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  34. package/dist/rooms-tasks/layout-config.js +89 -0
  35. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  36. package/dist/rooms-tasks/layout-control.js +52 -0
  37. package/dist/rooms-tasks/layout-service.d.ts +39 -0
  38. package/dist/rooms-tasks/layout-service.js +173 -0
  39. package/dist/rooms-tasks/layout.d.ts +102 -0
  40. package/dist/rooms-tasks/layout.js +227 -0
  41. package/dist/rooms-tasks/live-readiness.js +2 -1
  42. package/dist/rooms-tasks/provision.d.ts +3 -0
  43. package/dist/rooms-tasks/provision.js +44 -22
  44. package/dist/rooms-tasks/room-state.d.ts +1 -1
  45. package/dist/rooms-tasks/room-state.js +3 -1
  46. package/dist/rooms-tasks/task-state.d.ts +19 -0
  47. package/dist/rooms-tasks/task-state.js +58 -6
  48. package/dist/rooms-tasks/types.d.ts +14 -0
  49. package/dist/runner.d.ts +7 -2
  50. package/dist/runner.js +52 -4
  51. package/dist/session/acp.d.ts +3 -0
  52. package/dist/session/acp.js +7 -0
  53. package/dist/session/arbiter.d.ts +3 -0
  54. package/dist/session/arbiter.js +20 -2
  55. package/dist/session/codex-app-server.d.ts +3 -0
  56. package/dist/session/codex-app-server.js +25 -1
  57. package/dist/session/control.d.ts +10 -1
  58. package/dist/session/control.js +36 -2
  59. package/dist/session/conversation-normalizer.d.ts +2 -1
  60. package/dist/session/conversation-normalizer.js +1 -1
  61. package/dist/session/conversation-types.d.ts +5 -3
  62. package/dist/session/task-notice.d.ts +14 -0
  63. package/dist/session/task-notice.js +13 -0
  64. package/dist/session/types.d.ts +3 -0
  65. package/dist/socket-path.d.ts +2 -0
  66. package/dist/socket-path.js +22 -0
  67. package/dist/temp-idle.d.ts +17 -0
  68. package/dist/temp-idle.js +42 -0
  69. package/dist/temp-lifecycle.d.ts +1 -1
  70. package/dist/temp-lifecycle.js +1 -1
  71. package/dist/web/agent-attachments.d.ts +29 -0
  72. package/dist/web/agent-attachments.js +162 -0
  73. package/dist/web/auth.d.ts +4 -1
  74. package/dist/web/auth.js +23 -2
  75. package/dist/web/control.js +2 -1
  76. package/dist/web/fleet-config-service.d.ts +4 -1
  77. package/dist/web/fleet-config-service.js +46 -15
  78. package/dist/web/prefix-gateway.d.ts +20 -0
  79. package/dist/web/prefix-gateway.js +150 -0
  80. package/dist/web/runtime.d.ts +4 -0
  81. package/dist/web/runtime.js +19 -14
  82. package/dist/web/server.d.ts +5 -0
  83. package/dist/web/server.js +275 -24
  84. package/docs/room-layouts.md +92 -0
  85. package/examples/room-layouts/fresh.yaml +27 -0
  86. package/examples/room-layouts/shared.yaml +19 -0
  87. package/package.json +14 -13
  88. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  89. package/presets/fleet/room_layouts/pair.yaml +24 -0
  90. package/presets/fleet/room_layouts/single.yaml +17 -0
  91. package/presets/fleet/room_layouts/team.yaml +30 -0
  92. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  93. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  94. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  95. package/dist/web-app/icons/ours-fleet.svg +0 -4
  96. package/dist/web-app/index.html +0 -17
  97. package/dist/web-app/manifest.webmanifest +0 -15
  98. package/dist/web-app/offline.html +0 -18
  99. package/dist/web-app/sw.js +0 -51
@@ -0,0 +1,227 @@
1
+ /** Durable local room-layout controller.
2
+ * Uses native Cowork operations and a supervising Fleet's exact-instance port.
3
+ * Unknown mutation outcomes stop for reconciliation; they are never replayed.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { canonicalJson } from '../canonical-json.js';
8
+ import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
9
+ const ownerReference = (owner) => ({ cid: owner.cid, role: owner.role, fingerprint: createHash('sha256').update(owner.invite).digest('hex') });
10
+ const own = (o, key) => Object.hasOwn(o, key);
11
+ function need(ok, message) { if (!ok)
12
+ throw Error(message); }
13
+ export function validateLayout(def) {
14
+ need(Object.keys(def.rooms).length && Object.keys(def.participants).length, 'rooms and participants required');
15
+ for (const [key, room] of Object.entries(def.rooms)) {
16
+ need(key.length && room.goal.trim() && room.members.length, 'room goal and members required');
17
+ need(new Set(room.members).size === room.members.length, 'duplicate participant in room');
18
+ for (const member of room.members)
19
+ need(own(def.participants, member), `unknown participant ${member}`);
20
+ }
21
+ for (const key of [...Object.keys(def.rooms), ...Object.keys(def.participants)])
22
+ need(/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key) && !['constructor', 'prototype', '__proto__'].includes(key), 'invalid layout key');
23
+ }
24
+ export class RoomLayout {
25
+ file;
26
+ supervisor;
27
+ owner;
28
+ coworkAdapter;
29
+ coworkFactory;
30
+ constructor(file, cowork, supervisor, owner) {
31
+ this.file = file;
32
+ this.supervisor = supervisor;
33
+ this.owner = owner;
34
+ this.coworkFactory = typeof cowork === 'function' ? cowork : () => cowork;
35
+ }
36
+ get cowork() { return this.coworkAdapter ??= this.coworkFactory(); }
37
+ snapshot() { return JSON.parse(readFileSync(this.file, 'utf8')); }
38
+ save(s) { replaceFileAtomically(this.file, JSON.stringify(s, null, 2)); }
39
+ async verify(i) {
40
+ need(i.supervisor === this.supervisor.id, 'remote binding unsupported');
41
+ need(i.launch && i.cid && i.session, 'incomplete instance reference');
42
+ await this.supervisor.verify(i);
43
+ }
44
+ async create(definition, controller, bindings = {}, agentTemplates) {
45
+ validateLayout(definition);
46
+ need(controller, 'controller required');
47
+ this.checkOwnerRoles(definition);
48
+ await withFileLock(this.file + '.lock', async () => {
49
+ need(!existsSync(this.file), 'layout instance already exists');
50
+ for (const key of Object.keys(bindings))
51
+ need(own(definition.participants, key), 'unknown binding');
52
+ // Validate every supplied binding before any external mutation.
53
+ for (const [key, p] of Object.entries(definition.participants)) {
54
+ need(bindings[key] || p.agent_template, `binding required for ${key}`);
55
+ if (bindings[key])
56
+ await this.verify(bindings[key]);
57
+ }
58
+ for (const room of Object.values(definition.rooms)) {
59
+ const cids = room.members.flatMap(key => bindings[key] ? [bindings[key].cid] : []);
60
+ need(new Set(cids).size === cids.length, 'same instance cannot occupy two seats in one room');
61
+ }
62
+ this.save({ version: 1, definition, controller, ...(this.owner ? { owner: ownerReference(this.owner) } : {}), agent_templates: agentTemplates, participants: Object.fromEntries(Object.keys(definition.participants).map(key => [key, { owned: !bindings[key], ...(bindings[key] ? { instance: bindings[key] } : {}) }])), rooms: {}, order: [], closed: false });
63
+ });
64
+ }
65
+ checkOwnerRoles(definition) {
66
+ if (!this.owner)
67
+ return;
68
+ for (const room of Object.values(definition.rooms))
69
+ need(room.members.every(member => (room.roles?.[member] ?? member) !== this.owner.role), 'participant cannot use the owner role');
70
+ }
71
+ async operation(actor, work, cleanup = false) {
72
+ return withFileLock(this.file + '.lock', async () => {
73
+ const s = this.snapshot();
74
+ need(actor === s.controller, 'controller authorization required');
75
+ if (!cleanup) {
76
+ need(!s.closed && !s.closing, 'layout instance closing or closed');
77
+ need(!s.uncertain, `reconciliation required: ${s.uncertain}`);
78
+ }
79
+ return work(s);
80
+ });
81
+ }
82
+ async mutation(s, id, work, commit) {
83
+ s.uncertain = id;
84
+ this.save(s);
85
+ // On any error keep the cursor, even if the operation may have succeeded.
86
+ const result = await work();
87
+ commit(result);
88
+ delete s.uncertain;
89
+ this.save(s);
90
+ }
91
+ async activateRoom(s, key, spec) {
92
+ if (s.owner)
93
+ need(this.owner && canonicalJson(s.owner) === canonicalJson(ownerReference(this.owner)), 'room owner configuration changed');
94
+ this.checkOwnerRoles(s.definition);
95
+ need(s.rooms[key]?.state !== 'closed', 'room closed');
96
+ for (const member of spec.members) {
97
+ const i = s.participants[member]?.instance;
98
+ if (i)
99
+ await this.verify(i);
100
+ need(!s.participants[member]?.retired, 'participant retired');
101
+ }
102
+ if (s.rooms[key]?.state === 'active') {
103
+ await this.assertRoomActive(s, key);
104
+ return;
105
+ }
106
+ const room = s.rooms[key] ??= { spec, state: 'provisioning', ready: [] };
107
+ this.save(s);
108
+ for (const member of spec.members) {
109
+ const p = s.participants[member];
110
+ if (!p.instance) {
111
+ const template = s.definition.participants[member]?.agent_template;
112
+ need(template, `no factory for ${member}`);
113
+ await this.mutation(s, `spawn:${member}`, () => this.supervisor.spawn(member, template), i => { p.instance = i; });
114
+ await this.verify(p.instance);
115
+ }
116
+ }
117
+ const roleFor = (member) => spec.roles?.[member] ?? member;
118
+ if (!room.native)
119
+ await this.mutation(s, `create:${key}`, () => this.cowork.createRoom({
120
+ room_name: `${key.slice(0, 24)}-${createHash('sha256').update(this.file + '\0' + key).digest('hex').slice(0, 24)}`,
121
+ goal: spec.goal, briefing: JSON.stringify({ goal: spec.goal, contract: spec.contract }),
122
+ quiet_membership: spec.quiet_membership ?? true, anonymous: spec.anonymous ?? false,
123
+ activation_requirements: [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
124
+ }), native => { room.native = native; s.order.push(key); });
125
+ if (s.owner && !room.owner_ready)
126
+ await this.mutation(s, `owner:${key}`, async () => {
127
+ await this.cowork.acceptInvite(room.native.room_id, this.owner.invite, { role: s.owner.role, expected_cid: s.owner.cid });
128
+ await this.cowork.setRoleCommands(room.native.room_id, { role: s.owner.role, commands: ['*'] });
129
+ }, () => { room.owner_ready = true; });
130
+ for (const member of spec.members) {
131
+ if (room.ready.includes(member))
132
+ continue;
133
+ const i = s.participants[member].instance;
134
+ await this.verify(i);
135
+ const seats = await this.cowork.getSeats(room.native.room_id);
136
+ if (!seats.some(seat => seat.identity_cid === i.cid && seat.role === roleFor(member) && seat.seat_state !== 'removed')) {
137
+ await this.mutation(s, `admit:${key}:${member}`, async () => {
138
+ const invitation = await this.cowork.issueInvite(room.native.room_id, { mode: 'one_time', role: roleFor(member), min_accepts: 1 });
139
+ await this.supervisor.join(i, invitation.invite, room.native.identity_cid);
140
+ }, () => { });
141
+ }
142
+ }
143
+ await this.assertRoomActive(s, key);
144
+ // Admission of all required seats precedes any participant work.
145
+ for (const member of spec.members) {
146
+ if (room.ready.includes(member))
147
+ continue;
148
+ const i = s.participants[member].instance;
149
+ await this.verify(i);
150
+ await this.mutation(s, `assignment:${key}:${member}`, () => this.supervisor.assign(i, {
151
+ id: `${key}:${member}`, room_id: room.native.room_id, room_cid: room.native.identity_cid,
152
+ participant: member, room_role: roleFor(member), goal: spec.goal, contract: spec.contract,
153
+ }), () => { room.ready.push(member); });
154
+ }
155
+ room.state = 'active';
156
+ this.save(s);
157
+ }
158
+ activate(actor, key) {
159
+ return this.operation(actor, async (s) => {
160
+ need(own(s.definition.rooms, key), 'unknown room');
161
+ await this.activateRoom(s, key, s.definition.rooms[key]);
162
+ });
163
+ }
164
+ async assertRoomActive(s, key) {
165
+ const room = s.rooms[key], native = await this.cowork.getRoom(room.native.room_id);
166
+ need(native?.identity_cid === room.native.identity_cid && native.state === 'active', 'native room missing, changed or inactive');
167
+ if (s.owner)
168
+ need(native.seats.some(seat => seat.identity_cid === s.owner.cid
169
+ && seat.role === s.owner.role && seat.seat_state === 'active'), 'owner membership not active');
170
+ need(room.spec.members.every(member => native.seats.some(seat => seat.identity_cid === s.participants[member].instance.cid
171
+ && seat.role === (room.spec.roles?.[member] ?? member) && seat.seat_state === 'active')), 'membership not active');
172
+ }
173
+ async archiveRoom(s, key) {
174
+ const room = s.rooms[key];
175
+ need(room?.native, 'room not created');
176
+ if (room.state === 'closed')
177
+ return;
178
+ const native = await this.cowork.getRoom(room.native.room_id);
179
+ if (native) {
180
+ need(native.identity_cid === room.native.identity_cid, 'native room identity changed');
181
+ if (native.state !== 'closed')
182
+ await this.cowork.closeRoom(room.native.room_id);
183
+ }
184
+ room.state = 'closed';
185
+ this.save(s);
186
+ }
187
+ closeRoom(actor, key) {
188
+ return this.operation(actor, s => this.archiveRoom(s, key), true);
189
+ }
190
+ close(actor) {
191
+ return this.operation(actor, async (s) => {
192
+ if (s.closed)
193
+ return;
194
+ s.closing = true;
195
+ this.save(s);
196
+ const errors = [];
197
+ for (const key of [...s.order].reverse()) {
198
+ try {
199
+ await this.archiveRoom(s, key);
200
+ }
201
+ catch (error) {
202
+ errors.push(`room ${key}: ${String(error)}`);
203
+ }
204
+ }
205
+ for (const [key, p] of Object.entries(s.participants))
206
+ if (p.owned && p.instance && !p.retired) {
207
+ try {
208
+ need(p.instance.supervisor === this.supervisor.id, 'remote binding unsupported');
209
+ await this.supervisor.retire(p.instance);
210
+ p.retired = true;
211
+ this.save(s);
212
+ }
213
+ catch (error) {
214
+ errors.push(`participant ${key}: ${String(error)}`);
215
+ }
216
+ }
217
+ // Clean known resources without erasing evidence of an unknown creation outcome.
218
+ if (s.uncertain)
219
+ errors.push(`known resources cleaned where possible; inspection still required: ${s.uncertain}`);
220
+ if (errors.length)
221
+ throw Error(`layout cleanup incomplete: ${errors.join('; ')}`);
222
+ s.closed = true;
223
+ delete s.closing;
224
+ this.save(s);
225
+ }, true);
226
+ }
227
+ }
@@ -11,7 +11,8 @@ export async function taskLiveReadiness(task, room, cowork, deps = {}) {
11
11
  || room.task_id !== task.task_id || !room.room_identity_cid
12
12
  || task.room_identity_cid !== room.room_identity_cid)
13
13
  return degraded('room_record_mismatch');
14
- const expected = room.template_snapshot?.members.reduce((n, member) => n + member.count, 0);
14
+ const baseExpected = room.template_snapshot?.members.reduce((n, member) => n + member.count, 0);
15
+ const expected = baseExpected === undefined ? undefined : baseExpected + (room.additional_member_templates ?? []).reduce((n, t) => n + t.members.reduce((m, s) => m + s.count, 0), 0);
15
16
  if (expected === undefined || room.member_seats.length !== expected
16
17
  || new Set(room.member_seats.map(seat => seat.role_name)).size !== expected
17
18
  || new Set(room.member_seats.map(seat => seat.identity_cid)).size !== expected)
@@ -3,6 +3,7 @@ import type { RoomOrchestrationRecord, TemplateSnapshot } from './types.js';
3
3
  import { type FleetConfig } from '../config.js';
4
4
  export declare function getBinPath(): string;
5
5
  export interface ProvisionMembersInput {
6
+ configPath?: string;
6
7
  cfg: FleetConfig;
7
8
  cowork: CoworkAdapter;
8
9
  roomId: string;
@@ -12,6 +13,8 @@ export interface ProvisionMembersInput {
12
13
  brief?: string;
13
14
  goal?: string;
14
15
  startupWait?: Partial<StartupWaitPolicy>;
16
+ /** Append only the supplied new slots to an already active room. */
17
+ append?: boolean;
15
18
  }
16
19
  export interface StartupWaitPolicy {
17
20
  timeoutMs: number;
@@ -131,7 +131,7 @@ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCi
131
131
  cowork_role: member.coworkRole,
132
132
  persona: settings.persona,
133
133
  },
134
- roster: members.map(candidate => ({
134
+ roster: [...(input.append ? (getRoomRecord(input.roomId)?.member_seats ?? []).filter(seat => !members.some(m => m.name === seat.role_name)).map(seat => ({ name: seat.role_name, coworkRole: seat.cowork_role })) : []), ...members].map(candidate => ({
135
135
  role_name: candidate.name,
136
136
  cowork_role: candidate.coworkRole,
137
137
  })),
@@ -264,6 +264,7 @@ async function launchMember(input) {
264
264
  try {
265
265
  const launched = await spawnRoomMember({
266
266
  name: member.name,
267
+ configPath: provision.configPath,
267
268
  temp: true,
268
269
  identity: member.name,
269
270
  agentDefinition: settings.definition,
@@ -335,6 +336,8 @@ function reconcileMemberSeats(roomId, members, observed, ownerSeatCid) {
335
336
  && seat.seat_state === 'active');
336
337
  const seats = current.member_seats.map(seat => {
337
338
  const member = members.find(candidate => candidate.name === seat.role_name);
339
+ if (!member)
340
+ return seat;
338
341
  const readiness = readRoomReadiness(current.room_identity_cid, member.name);
339
342
  if (!readiness || readiness.room !== roomId || readiness.invite !== seat.invite_id) {
340
343
  complete = false;
@@ -373,6 +376,18 @@ export async function provisionMembers(input) {
373
376
  // and standalone retirement as well as member publication. Seat waits are bounded.
374
377
  const run = () => withFileLock(roomCloseLockPath(input.roomId), () => {
375
378
  assertProvisioningOpen(input);
379
+ if (input.append) {
380
+ const task = input.taskId ? getTask(input.taskId) : undefined;
381
+ const room = getRoomRecord(input.roomId);
382
+ if (!task || !['active', 'review'].includes(task.state) || task.blocked || room?.state !== 'active'
383
+ || room.task_id !== task.task_id || room.room_identity_cid !== task.room_identity_cid)
384
+ throw new Error('Adding an agent requires the exact active, unblocked task room');
385
+ const names = expandMembers(input.template, shortId(task.task_id)).map(m => m.name);
386
+ if (room.member_seats.some(seat => names.includes(seat.role_name)))
387
+ throw new Error('Member slot is already recorded; inspect its existing launch');
388
+ if (room.member_seats.some(seat => seat.seat_state !== 'active'))
389
+ throw new Error('Existing member admission must finish before adding an agent');
390
+ }
376
391
  if (!getRoomRecord(input.roomId))
377
392
  throw new Error(`room ${input.roomId} is missing; refusing provisioning`);
378
393
  return provisionMembersUnlocked(input);
@@ -408,22 +423,23 @@ async function provisionMembersUnlocked(input) {
408
423
  const resuming = members.length > 0
409
424
  && members.every(member => persistedNames.has(member.name));
410
425
  if (!resuming) {
411
- advanceSaga(roomId, 'create_members', 3);
412
- updateMemberSeats(roomId, members.map(member => {
413
- const memberSettings = settings.get(member.name);
414
- const evidence = launchDefinition(memberSettings.definition);
415
- return ({
416
- role_name: member.name,
417
- slot: member.slot,
418
- cowork_role: member.coworkRole,
419
- seat_state: 'pending',
420
- launch: { state: 'pending', attempt: 0,
421
- agent_definition: evidence.projection, agent_fingerprint: evidence.fingerprint,
422
- agent_template: memberSettings.template,
423
- agent_template_hash: memberSettings.templateHash,
424
- updated_at: new Date().toISOString() },
425
- });
426
- }));
426
+ if (!input.append)
427
+ advanceSaga(roomId, 'create_members', 3);
428
+ updateMemberSeats(roomId, [...(input.append ? existing.member_seats : []), ...members.map(member => {
429
+ const memberSettings = settings.get(member.name);
430
+ const evidence = launchDefinition(memberSettings.definition);
431
+ return ({
432
+ role_name: member.name,
433
+ slot: member.slot,
434
+ cowork_role: member.coworkRole,
435
+ seat_state: 'pending',
436
+ launch: { state: 'pending', attempt: 0,
437
+ agent_definition: evidence.projection, agent_fingerprint: evidence.fingerprint,
438
+ agent_template: memberSettings.template,
439
+ agent_template_hash: memberSettings.templateHash,
440
+ updated_at: new Date().toISOString() },
441
+ });
442
+ })], input.append ? template : undefined);
427
443
  }
428
444
  else {
429
445
  for (const member of members) {
@@ -441,7 +457,8 @@ async function provisionMembersUnlocked(input) {
441
457
  now: input.startupWait?.now ?? Date.now,
442
458
  sleep: input.startupWait?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
443
459
  };
444
- advanceSaga(roomId, 'join_role_groups', 4);
460
+ if (!input.append)
461
+ advanceSaga(roomId, 'join_role_groups', 4);
445
462
  try {
446
463
  const initialRoom = await cowork.recoverRoom(roomId);
447
464
  assertCoworkRoomPolicy(initialRoom, roomPolicy.anonymous);
@@ -526,7 +543,8 @@ async function provisionMembersUnlocked(input) {
526
543
  }
527
544
  throw error;
528
545
  }
529
- advanceSaga(roomId, 'wait_seats', 5);
546
+ if (!input.append)
547
+ advanceSaga(roomId, 'wait_seats', 5);
530
548
  const deadline = policy.now() + policy.timeoutMs;
531
549
  let delay = policy.initialDelayMs;
532
550
  for (;;) {
@@ -536,10 +554,12 @@ async function provisionMembersUnlocked(input) {
536
554
  if (reconciled.complete && remote.state === 'active')
537
555
  break;
538
556
  if (policy.now() >= deadline) {
539
- advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
557
+ if (!input.append)
558
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
540
559
  return getRoomRecord(roomId);
541
560
  }
542
- advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
561
+ if (!input.append)
562
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
543
563
  await policy.sleep(delay);
544
564
  delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
545
565
  }
@@ -564,9 +584,11 @@ async function provisionMembersUnlocked(input) {
564
584
  };
565
585
  });
566
586
  updateTaskMembers(taskId, taskMembers);
567
- if (getTask(taskId).blocked)
587
+ if (!input.append && getTask(taskId).blocked)
568
588
  unblockTask(taskId);
569
589
  }
590
+ if (input.append)
591
+ return getRoomRecord(roomId);
570
592
  advanceSaga(roomId, 'activate', 6);
571
593
  const record = activateRoom(roomId);
572
594
  if (taskId)
@@ -22,7 +22,7 @@ export declare function advanceSaga(id: string, phase: SagaPhase, stepIndex?: nu
22
22
  export declare function setSagaError(id: string, error: string, hint?: string, detail?: ProvisioningDetail): RoomOrchestrationRecord;
23
23
  export declare function setRoomIdentity(id: string, roomIdentityCid: string): RoomOrchestrationRecord;
24
24
  export declare function setOwnerSeat(id: string, ownerSeatCid: string, inviteFingerprint: string): RoomOrchestrationRecord;
25
- export declare function updateMemberSeats(id: string, seats: RoomMemberSeat[]): RoomOrchestrationRecord;
25
+ export declare function updateMemberSeats(id: string, seats: RoomMemberSeat[], additionalTemplate?: import('./types.js').TemplateSnapshot): RoomOrchestrationRecord;
26
26
  export declare function updateRoomRoleBriefing(id: string, role: string, definition: RoomRoleBriefingDefinition): RoomOrchestrationRecord;
27
27
  export declare function updateRoomHistoryCursor(id: string, cursor: number): RoomOrchestrationRecord;
28
28
  export declare function updateMemberStartup(id: string, roleName: string, update: {
@@ -128,11 +128,13 @@ export function setOwnerSeat(id, ownerSeatCid, inviteFingerprint) {
128
128
  writeRoom(r);
129
129
  return r;
130
130
  }
131
- export function updateMemberSeats(id, seats) {
131
+ export function updateMemberSeats(id, seats, additionalTemplate) {
132
132
  const r = readRoom(id);
133
133
  if (r.state === 'closing' || r.state === 'closed')
134
134
  throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
135
135
  r.member_seats = seats;
136
+ if (additionalTemplate)
137
+ r.additional_member_templates = [...(r.additional_member_templates ?? []), additionalTemplate];
136
138
  writeRoom(r);
137
139
  return r;
138
140
  }
@@ -18,6 +18,10 @@ export interface CreateTaskInput {
18
18
  idempotency_key?: string;
19
19
  start?: boolean;
20
20
  no_room?: boolean;
21
+ layout?: {
22
+ name: string;
23
+ definition_hash: string;
24
+ };
21
25
  room_id?: string;
22
26
  listId?: string;
23
27
  }
@@ -172,4 +176,19 @@ export declare function settleAbsentTaskDeletionMember(id: string, name: string)
172
176
  export declare function detachDeletedRoom(taskId: string, roomId: string): void;
173
177
  /** A completed room may consume its archives while other task members still retire. */
174
178
  export declare function beginTaskArchiveCleanup(id: string): void;
179
+ /** Metadata-only edit; compare under the task lock to prevent lost updates. */
180
+ /**
181
+ * Choose, change or clear a Backlog task's Room Layout before it starts.
182
+ * `expected` is the layout name the caller saw (null = none); a different
183
+ * current choice is a stale edit. Choosing a layout drops the sealed room
184
+ * template plan; the caller releases the returned launch snapshot hash.
185
+ */
186
+ export declare function updateTaskLayout(id: string, expected: string | null, next: {
187
+ name: string;
188
+ definition_hash: string;
189
+ } | null): {
190
+ task: TaskRecord;
191
+ releasedSnapshot?: string;
192
+ };
193
+ export declare function updateTaskBrief(id: string, brief: string, expectedBrief: string): TaskRecord;
175
194
  export {};
@@ -126,15 +126,17 @@ export function assertNoPendingDeletion(task) {
126
126
  throw new TaskStateError(`task ${task.task_id} is pending deletion; run 'ours-fleet task delete ${task.task_id} ${task.task_id}' to retry cleanup`);
127
127
  }
128
128
  }
129
+ function creationPlan(plan, layout) {
130
+ return JSON.stringify([plan?.plan_hash ?? null, storedRoomLaunchPolicy(plan?.room_policy) ?? null,
131
+ layout ? `${layout.name}@${layout.definition_hash}` : null]);
132
+ }
129
133
  export function createTask(input) {
130
134
  const key = input.idempotency_key ?? randomUUID();
131
135
  const existing = listTasks({ includeDeleting: true }).find(task => task.idempotency_key === key);
136
+ const requestedPlan = creationPlan(input.execution_plan, input.layout);
132
137
  if (existing) {
133
- const existingPlan = existing.execution_plan?.plan_hash;
134
- const requestedPlan = input.execution_plan?.plan_hash;
135
- const existingPolicy = storedRoomLaunchPolicy(existing.execution_plan?.room_policy);
136
- const requestedPolicy = storedRoomLaunchPolicy(input.execution_plan?.room_policy);
137
- if (existingPlan !== requestedPlan || JSON.stringify(existingPolicy) !== JSON.stringify(requestedPolicy))
138
+ // After a Backlog plan edit the current plan differs from the one created; retries compare the original request.
139
+ if ((existing.creation_plan ?? creationPlan(existing.execution_plan, existing.layout)) !== requestedPlan)
138
140
  throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
139
141
  return withTaskLock(existing.task_id, () => {
140
142
  const fresh = readTask(existing.task_id);
@@ -144,9 +146,12 @@ export function createTask(input) {
144
146
  return fresh;
145
147
  });
146
148
  }
149
+ if (input.layout && (input.execution_plan || input.no_room || input.room_id))
150
+ throw new TaskStateError('a layout task cannot also use a room template, no-room mode or a room');
147
151
  const state = input.start === false ? 'backlog' : 'provisioning';
152
+ const taskId = generateTaskId();
148
153
  const record = {
149
- task_id: generateTaskId(),
154
+ task_id: taskId,
150
155
  list_id: input.listId ?? DEFAULT_TASK_LIST_ID,
151
156
  title: input.title,
152
157
  brief: input.brief,
@@ -155,10 +160,12 @@ export function createTask(input) {
155
160
  template: input.template,
156
161
  execution_plan: input.execution_plan,
157
162
  no_room: input.no_room || undefined,
163
+ ...(input.layout ? { layout: { name: input.layout.name, run_id: `task-${taskId}`, definition_hash: input.layout.definition_hash } } : {}),
158
164
  room_id: input.room_id,
159
165
  member_roles: [],
160
166
  origin: input.origin,
161
167
  idempotency_key: key,
168
+ creation_plan: requestedPlan,
162
169
  created_at: new Date().toISOString(),
163
170
  started_at: state === 'provisioning' ? new Date().toISOString() : undefined,
164
171
  };
@@ -840,3 +847,48 @@ export function beginTaskArchiveCleanup(id) {
840
847
  writeTask(stored);
841
848
  });
842
849
  }
850
+ /** Metadata-only edit; compare under the task lock to prevent lost updates. */
851
+ /**
852
+ * Choose, change or clear a Backlog task's Room Layout before it starts.
853
+ * `expected` is the layout name the caller saw (null = none); a different
854
+ * current choice is a stale edit. Choosing a layout drops the sealed room
855
+ * template plan; the caller releases the returned launch snapshot hash.
856
+ */
857
+ export function updateTaskLayout(id, expected, next) {
858
+ return withTaskLock(id, () => {
859
+ const task = readTask(id);
860
+ assertNoPendingDeletion(task);
861
+ assertNoPendingTerminalIntent(task);
862
+ if (task.state !== 'backlog' || task.room_id)
863
+ throw new TaskStateError(`task ${id} has started; its room layout is fixed (state '${task.state}')`);
864
+ const current = task.layout?.name ?? null;
865
+ // A replay of the same choice is idempotent (and refreshes the pinned source hash).
866
+ if (current !== expected && current !== (next?.name ?? null))
867
+ throw new TaskStateError(`task ${id} room layout changed to '${current ?? 'one room'}'; reload before changing it`);
868
+ const releasedSnapshot = next ? task.execution_plan?.snapshot.launch_snapshot_hash : undefined;
869
+ if (next) {
870
+ task.layout = { name: next.name, run_id: `task-${task.task_id}`, definition_hash: next.definition_hash };
871
+ delete task.execution_plan;
872
+ delete task.template;
873
+ delete task.no_room;
874
+ }
875
+ else
876
+ delete task.layout;
877
+ task.room_plan_changed_at = new Date().toISOString();
878
+ writeTask(task);
879
+ return { task: readTask(id), ...(releasedSnapshot ? { releasedSnapshot } : {}) };
880
+ });
881
+ }
882
+ export function updateTaskBrief(id, brief, expectedBrief) {
883
+ assertCanonicalTaskId(id);
884
+ return withTaskLock(id, () => {
885
+ const task = readTask(id);
886
+ assertNoPendingDeletion(task);
887
+ assertNoPendingTerminalIntent(task);
888
+ if ((task.brief ?? '') !== expectedBrief && task.brief !== brief)
889
+ throw new TaskStateError('Description changed elsewhere. Reload the task before saving.');
890
+ task.brief = brief;
891
+ writeTask(task);
892
+ return task;
893
+ });
894
+ }
@@ -97,6 +97,13 @@ export interface TaskDeletionIntent {
97
97
  first_failure?: string;
98
98
  first_recovery_hint?: string;
99
99
  }
100
+ export interface TaskLayoutLink {
101
+ name: string;
102
+ /** Deterministic `task-<task_id>` Room Layout run ID. */
103
+ run_id: string;
104
+ /** sha256 of the canonical source layout definition the run must snapshot. */
105
+ definition_hash: string;
106
+ }
100
107
  export interface TaskRecord {
101
108
  workspace?: import('./workspace.js').OwnedWorkspace;
102
109
  task_id: string;
@@ -119,6 +126,12 @@ export interface TaskRecord {
119
126
  plan_hash: string;
120
127
  };
121
128
  no_room?: boolean;
129
+ /** Multi-room task: rooms and participants come from a Room Layout run. */
130
+ layout?: TaskLayoutLink;
131
+ /** Set when the room plan (layout or one-room template) was changed after creation, before start. */
132
+ room_plan_changed_at?: string;
133
+ /** Fingerprint of the room plan requested at creation; idempotent retries compare against it after later plan edits. */
134
+ creation_plan?: string;
122
135
  room_id?: string;
123
136
  room_identity_cid?: string;
124
137
  member_roles: TaskMemberRole[];
@@ -247,6 +260,7 @@ export interface RoomOrchestrationRecord {
247
260
  goal?: string;
248
261
  task_id?: string;
249
262
  template_snapshot?: TemplateSnapshot;
263
+ additional_member_templates?: TemplateSnapshot[];
250
264
  /** Resolved once at launch; legacy absence means non-anonymous. */
251
265
  room_policy?: RoomLaunchPolicy;
252
266
  saga: SagaCursor;
package/dist/runner.d.ts CHANGED
@@ -30,7 +30,7 @@ export interface RunnerDeps {
30
30
  /** Neutral construction seam for deterministic runner lifecycle tests. */
31
31
  startAgentSession(adapter: AgentSessionAdapter, options: AgentSessionStartOptions): Promise<AgentSession>;
32
32
  /** Construct the authenticated role control route (injectable where sockets are unavailable). */
33
- createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setFleetAuditor' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
33
+ createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setFleetAuditor' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'> & Partial<Pick<RoleControlServer, 'setLayoutControl' | 'setCorrespondence'>>;
34
34
  /** Construct scheduled-loop execution (injectable for fail-closed startup tests). */
35
35
  createLoopManager(...args: ConstructorParameters<typeof ScheduledLoopManager>): ScheduledLoopManagerHandle;
36
36
  /** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
@@ -45,6 +45,11 @@ export declare class SupervisorRecycleRequiredError extends Error {
45
45
  readonly code = "OWNER_CHANNEL_SUPERVISOR_RECYCLE_REQUIRED";
46
46
  constructor();
47
47
  }
48
+ export declare function localFleetAuditor(stateDir: string, caller: string, log: (line: string) => void, session: AgentSession): {
49
+ begin(requestId: string, argv: string[]): Promise<import("./fleet-command-audit.js").FleetAuditAttempt>;
50
+ finish(input: Parameters<NonNullable<OwnerChannelHandle["finishFleetCommandAudit"]>>[0]): Promise<import("./fleet-command-audit.js").FleetAuditAttempt>;
51
+ present(): Promise<void>;
52
+ };
48
53
  /** Environment injected only into the managed harness process. */
49
54
  export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
50
55
  /**
@@ -169,7 +174,7 @@ export interface AttemptResult {
169
174
  mode: 'fresh' | 'resume';
170
175
  modelRecovery?: 'advance' | 'hold';
171
176
  /** Present only when a temporary-role lifecycle signal ended the session. */
172
- retirementReason?: 'identity-closed' | 'operator-stop' | 'supervisor-signal';
177
+ retirementReason?: 'identity-closed' | 'operator-stop' | 'supervisor-signal' | 'idle-timeout';
173
178
  }
174
179
  /** Continuous authoritative absence required after an identity was observed. */
175
180
  export declare const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5000;