@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.
- package/README.md +10 -1
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/runtime.d.ts +6 -0
- package/dist/agent-ours/runtime.js +36 -0
- package/dist/agent-ours/service.js +2 -1
- package/dist/application/conversation-history.d.ts +6 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +8 -0
- package/dist/application/session-control.js +21 -3
- package/dist/application/task-layouts.d.ts +89 -0
- package/dist/application/task-layouts.js +248 -0
- package/dist/application/task-room-service.d.ts +82 -0
- package/dist/application/task-room-service.js +197 -9
- package/dist/build-info.json +4 -4
- package/dist/cli.js +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/init-wizard.js +4 -2
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/cli.js +20 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -3
- package/dist/rooms-tasks/layout-cli.d.ts +2 -0
- package/dist/rooms-tasks/layout-cli.js +81 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +39 -0
- package/dist/rooms-tasks/layout-service.js +173 -0
- package/dist/rooms-tasks/layout.d.ts +102 -0
- package/dist/rooms-tasks/layout.js +227 -0
- package/dist/rooms-tasks/live-readiness.js +2 -1
- package/dist/rooms-tasks/provision.d.ts +3 -0
- package/dist/rooms-tasks/provision.js +44 -22
- package/dist/rooms-tasks/room-state.d.ts +1 -1
- package/dist/rooms-tasks/room-state.js +3 -1
- package/dist/rooms-tasks/task-state.d.ts +19 -0
- package/dist/rooms-tasks/task-state.js +58 -6
- package/dist/rooms-tasks/types.d.ts +14 -0
- package/dist/runner.d.ts +7 -2
- package/dist/runner.js +52 -4
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +7 -0
- package/dist/session/arbiter.d.ts +3 -0
- package/dist/session/arbiter.js +20 -2
- package/dist/session/codex-app-server.d.ts +3 -0
- package/dist/session/codex-app-server.js +25 -1
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +36 -2
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-types.d.ts +5 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +1 -1
- package/dist/temp-lifecycle.js +1 -1
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +4 -1
- package/dist/web/auth.js +23 -2
- package/dist/web/control.js +2 -1
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +20 -0
- package/dist/web/prefix-gateway.js +150 -0
- package/dist/web/runtime.d.ts +4 -0
- package/dist/web/runtime.js +19 -14
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.js +275 -24
- package/docs/room-layouts.md +92 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +14 -13
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- 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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
557
|
+
if (!input.append)
|
|
558
|
+
advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
|
|
540
559
|
return getRoomRecord(roomId);
|
|
541
560
|
}
|
|
542
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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:
|
|
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;
|