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