@ours.network/fleet 2.0.3 → 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.
- package/README.md +10 -1
- package/dist/application/fleet-query-service.js +2 -0
- package/dist/application/room-layout-definitions.js +5 -0
- package/dist/application/task-layouts.d.ts +10 -1
- package/dist/application/task-layouts.js +53 -5
- package/dist/application/task-room-service.d.ts +5 -1
- package/dist/application/task-room-service.js +15 -6
- package/dist/application/types.d.ts +2 -0
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +0 -2
- package/dist/config.js +4 -6
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +5 -1
- package/dist/owner-channel/channel.d.ts +2 -3
- package/dist/owner-channel/channel.js +6 -78
- package/dist/owner-channel/notices.d.ts +0 -2
- package/dist/owner-channel/notices.js +0 -25
- package/dist/owner-channel/ours-client.d.ts +4 -0
- package/dist/owner-channel/ours-client.js +7 -1
- package/dist/rooms-tasks/add-member.d.ts +3 -1
- package/dist/rooms-tasks/add-member.js +40 -0
- package/dist/rooms-tasks/cli.js +2 -2
- package/dist/rooms-tasks/empty-layout.d.ts +5 -0
- package/dist/rooms-tasks/empty-layout.js +8 -0
- package/dist/rooms-tasks/layout-config.js +2 -2
- package/dist/rooms-tasks/layout-service.d.ts +1 -1
- package/dist/rooms-tasks/layout-service.js +5 -2
- package/dist/rooms-tasks/layout.d.ts +18 -1
- package/dist/rooms-tasks/layout.js +40 -6
- package/dist/session/control.js +1 -0
- package/dist/web/server.js +7 -5
- package/docs/room-layouts.md +34 -0
- package/package.json +1 -1
|
@@ -17,7 +17,6 @@ export interface OwnerCommentsState {
|
|
|
17
17
|
/** False when this session's backend never emits ACP commentary at all. */
|
|
18
18
|
supported: boolean;
|
|
19
19
|
}
|
|
20
|
-
export type OwnerProgressPhase = 'starting request' | 'waiting behind earlier requests' | 'working on request' | 'planning next step' | 'using tools' | 'reviewing tool results' | 'drafting response' | 'waiting for approval' | 'resuming after permission decision' | 'recovering from session error';
|
|
21
20
|
export declare const ownerNotices: {
|
|
22
21
|
receivedStarted: () => string;
|
|
23
22
|
receivedQueued: (queuedBehind: number) => string;
|
|
@@ -43,7 +42,6 @@ export declare const ownerNotices: {
|
|
|
43
42
|
attachmentRejected: (reason: string) => string;
|
|
44
43
|
attachmentFailed: () => string;
|
|
45
44
|
deliveryFailed: (role: string) => string;
|
|
46
|
-
progress: (elapsedMs: number, phase: OwnerProgressPhase, started: number, completed: number, activityUpdates?: number) => string;
|
|
47
45
|
/** The labeled form of one relayed ACP live comment. */
|
|
48
46
|
comment: (message: string) => string;
|
|
49
47
|
comments: (state: OwnerCommentsState) => string;
|
|
@@ -5,18 +5,6 @@
|
|
|
5
5
|
* never be spoofed away by model content.
|
|
6
6
|
*/
|
|
7
7
|
export const OWNER_COMMENT_LABEL = '🟡 Live update:';
|
|
8
|
-
const duration = (elapsedMs) => {
|
|
9
|
-
const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
|
10
|
-
const hours = Math.floor(seconds / 3_600);
|
|
11
|
-
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
12
|
-
const remainder = seconds % 60;
|
|
13
|
-
return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', remainder ? `${remainder}s` : '']
|
|
14
|
-
.filter(Boolean).join(' ') || '0s';
|
|
15
|
-
};
|
|
16
|
-
const count = (value, singular, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`;
|
|
17
|
-
const joinCounts = (parts) => parts.length < 2
|
|
18
|
-
? parts[0] ?? ''
|
|
19
|
-
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`;
|
|
20
8
|
export const ownerNotices = {
|
|
21
9
|
receivedStarted: () => 'ℹ️ Message received. The agent has started working on this request now. '
|
|
22
10
|
+ 'The response will arrive in this channel when ready.',
|
|
@@ -64,19 +52,6 @@ export const ownerNotices = {
|
|
|
64
52
|
attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
|
|
65
53
|
attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
|
|
66
54
|
deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,
|
|
67
|
-
progress: (elapsedMs, phase, started, completed, activityUpdates = 0) => {
|
|
68
|
-
const counts = [];
|
|
69
|
-
if (started)
|
|
70
|
-
counts.push(`${count(started, 'tool action')} started`);
|
|
71
|
-
if (completed)
|
|
72
|
-
counts.push(`${count(completed, 'tool action')} completed`);
|
|
73
|
-
if (activityUpdates)
|
|
74
|
-
counts.push(`${count(activityUpdates, counts.length ? 'additional activity update' : 'activity update')} observed`);
|
|
75
|
-
const activity = counts.length
|
|
76
|
-
? `${joinCounts(counts)} since the last update.`
|
|
77
|
-
: 'no new reportable activity since the last update.';
|
|
78
|
-
return `⏳ Working for ${duration(elapsedMs)} · ${phase} · ${activity}`;
|
|
79
|
-
},
|
|
80
55
|
/** The labeled form of one relayed ACP live comment. */
|
|
81
56
|
comment: (message) => `${OWNER_COMMENT_LABEL} ${message}`,
|
|
82
57
|
comments: (state) => `🟡 Live updates are ${state.enabled ? 'ON' : 'OFF'} for this session `
|
|
@@ -57,6 +57,8 @@ export interface OursOps {
|
|
|
57
57
|
start(): Promise<void>;
|
|
58
58
|
/** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
|
|
59
59
|
bindIdentity(name: string): Promise<void>;
|
|
60
|
+
/** CID returned by the current successful mailbox binding; absent while unbound. */
|
|
61
|
+
boundIdentityCid?(): string | undefined;
|
|
60
62
|
/** Replace the bound identity's advertised typed-command catalog and handlers. */
|
|
61
63
|
registerCommands(commands: OursRegisteredCommand[]): Promise<void>;
|
|
62
64
|
listContacts(): Promise<OursContactsView>;
|
|
@@ -132,11 +134,13 @@ export declare class OursSdkClient implements OursOps {
|
|
|
132
134
|
private readonly log;
|
|
133
135
|
private readonly deps;
|
|
134
136
|
private client?;
|
|
137
|
+
private boundCid?;
|
|
135
138
|
private terminalReleasePending;
|
|
136
139
|
private readonly leaseToken;
|
|
137
140
|
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
138
141
|
start(): Promise<void>;
|
|
139
142
|
bindIdentity(name: string): Promise<void>;
|
|
143
|
+
boundIdentityCid(): string | undefined;
|
|
140
144
|
registerCommands(commands: OursRegisteredCommand[]): Promise<void>;
|
|
141
145
|
listContacts(): Promise<OursContactsView>;
|
|
142
146
|
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
@@ -117,6 +117,7 @@ export class OursSdkClient {
|
|
|
117
117
|
log;
|
|
118
118
|
deps;
|
|
119
119
|
client;
|
|
120
|
+
boundCid;
|
|
120
121
|
// Disposing a transport does not discharge this owner's terminal cleanup.
|
|
121
122
|
terminalReleasePending = false;
|
|
122
123
|
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
@@ -141,8 +142,12 @@ export class OursSdkClient {
|
|
|
141
142
|
async bindIdentity(name) {
|
|
142
143
|
// force is pinned off: the owner channel never evicts another live session
|
|
143
144
|
// from an identity, it waits for the bounded handoff window and then fails.
|
|
144
|
-
|
|
145
|
+
this.boundCid = undefined;
|
|
146
|
+
const result = await this.ops().chooseIdentity({ name, force: false });
|
|
147
|
+
if (typeof result.cid === 'string' && /^[a-f0-9]{64}$/i.test(result.cid))
|
|
148
|
+
this.boundCid = result.cid.toLowerCase();
|
|
145
149
|
}
|
|
150
|
+
boundIdentityCid() { return this.boundCid; }
|
|
146
151
|
async registerCommands(commands) {
|
|
147
152
|
await this.ops().registerCommands(commands);
|
|
148
153
|
}
|
|
@@ -218,6 +223,7 @@ export class OursSdkClient {
|
|
|
218
223
|
+ 'session must be re-established after an upgrade; files are not queued');
|
|
219
224
|
}
|
|
220
225
|
async close(options = {}) {
|
|
226
|
+
this.boundCid = undefined;
|
|
221
227
|
const terminal = options.releaseLease !== false;
|
|
222
228
|
// A failed recovery may have disposed its client before normal shutdown.
|
|
223
229
|
// Reattach the same owner through ordinary verified selection; no rebind is
|
|
@@ -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
|
|
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');
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -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
|
|
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]
|
|
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
|
|
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}`, () =>
|
|
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:
|
|
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');
|
package/dist/session/control.js
CHANGED
|
@@ -246,6 +246,7 @@ export class RoleControlServer {
|
|
|
246
246
|
version: 1, id: request.id, ok: true,
|
|
247
247
|
result: {
|
|
248
248
|
...this.session.snapshot(),
|
|
249
|
+
ownerChannelCid: this.ownerChannel?.ownerChannelCid?.(),
|
|
249
250
|
protocolVersion: this.session.conversationPage ? 3 : 2,
|
|
250
251
|
features: [
|
|
251
252
|
'events_since', 'observer_follow', 'retained_range',
|
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
//
|
|
565
|
-
if (typeof body.layout !== 'string' || !body.layout)
|
|
566
|
-
throw new FleetError('invalid_request', 'layout
|
|
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);
|
package/docs/room-layouts.md
CHANGED
|
@@ -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
|
|
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",
|