@ours.network/fleet 1.1.0-nightly.9 → 1.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 +231 -46
- package/dist/application/capabilities.js +18 -6
- package/dist/application/fleet-query-service.js +7 -4
- package/dist/application/role-command-service.js +9 -0
- package/dist/application/role-creation-service.d.ts +4 -4
- package/dist/application/role-creation-service.js +8 -1
- package/dist/application/role-repository.d.ts +4 -2
- package/dist/application/role-repository.js +6 -3
- package/dist/application/session-control.d.ts +3 -1
- package/dist/application/session-control.js +4 -2
- package/dist/application/task-room-service.d.ts +92 -39
- package/dist/application/task-room-service.js +562 -289
- package/dist/briefing.js +79 -22
- package/dist/build-info.json +5 -5
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +10 -0
- package/dist/cli.js +129 -47
- package/dist/config.d.ts +38 -3
- package/dist/config.js +193 -28
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +7 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +181 -41
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +40 -4
- package/dist/fleet-command-audit.d.ts +22 -1
- package/dist/fleet-command-audit.js +355 -50
- package/dist/fleet-proxy.d.ts +5 -2
- package/dist/harness/claude-code-session.js +2 -1
- package/dist/harness/codex-app-server-proxy.d.ts +40 -0
- package/dist/harness/codex-app-server-proxy.js +348 -14
- package/dist/harness/codex-session.d.ts +9 -2
- package/dist/harness/codex-session.js +18 -2
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +104 -10
- package/dist/harness/types.d.ts +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/init-guidance.d.ts +1 -1
- package/dist/init-guidance.js +1 -1
- package/dist/init-wizard.d.ts +98 -0
- package/dist/init-wizard.js +631 -0
- package/dist/lifecycle-summary.d.ts +134 -0
- package/dist/lifecycle-summary.js +179 -0
- package/dist/loops/config.d.ts +10 -1
- package/dist/loops/config.js +38 -5
- package/dist/model-recovery.d.ts +1 -1
- package/dist/ops.js +2 -17
- package/dist/owner-channel/channel.d.ts +19 -3
- package/dist/owner-channel/channel.js +301 -120
- package/dist/owner-channel/commands.d.ts +28 -6
- package/dist/owner-channel/commands.js +109 -27
- package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
- package/dist/owner-channel/lifecycle-outbox.js +87 -0
- package/dist/owner-channel/ours-client.d.ts +19 -0
- package/dist/owner-channel/ours-client.js +3 -0
- package/dist/permissions.js +1 -1
- package/dist/preset-migration.d.ts +42 -0
- package/dist/preset-migration.js +450 -0
- package/dist/reports/artifact.d.ts +10 -0
- package/dist/reports/artifact.js +32 -0
- package/dist/reports/index.d.ts +5 -0
- package/dist/reports/index.js +5 -0
- package/dist/reports/render.d.ts +6 -0
- package/dist/reports/render.js +240 -0
- package/dist/reports/service.d.ts +42 -0
- package/dist/reports/service.js +57 -0
- package/dist/reports/tasks.d.ts +52 -0
- package/dist/reports/tasks.js +177 -0
- package/dist/reports/types.d.ts +315 -0
- package/dist/reports/types.js +6 -0
- package/dist/resolved-plan.js +13 -1
- package/dist/rooms-tasks/cli.d.ts +2 -0
- package/dist/rooms-tasks/cli.js +361 -285
- package/dist/rooms-tasks/close.d.ts +9 -1
- package/dist/rooms-tasks/close.js +23 -7
- package/dist/rooms-tasks/config.js +16 -25
- package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
- package/dist/rooms-tasks/cowork-adapter.js +13 -0
- package/dist/rooms-tasks/deletion.d.ts +51 -0
- package/dist/rooms-tasks/deletion.js +217 -0
- package/dist/rooms-tasks/external-worker.d.ts +8 -0
- package/dist/rooms-tasks/external-worker.js +38 -0
- package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
- package/dist/rooms-tasks/launch-snapshot.js +181 -0
- package/dist/rooms-tasks/member-overrides.d.ts +30 -0
- package/dist/rooms-tasks/member-overrides.js +204 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +9 -2
- package/dist/rooms-tasks/provision.js +146 -83
- package/dist/rooms-tasks/room-state.d.ts +1 -0
- package/dist/rooms-tasks/room-state.js +12 -1
- package/dist/rooms-tasks/task-room-name.d.ts +12 -0
- package/dist/rooms-tasks/task-room-name.js +61 -0
- package/dist/rooms-tasks/task-state.d.ts +115 -3
- package/dist/rooms-tasks/task-state.js +361 -11
- package/dist/rooms-tasks/templates.d.ts +4 -1
- package/dist/rooms-tasks/templates.js +34 -3
- package/dist/rooms-tasks/terminal.d.ts +3 -0
- package/dist/rooms-tasks/terminal.js +10 -4
- package/dist/rooms-tasks/types.d.ts +92 -7
- package/dist/rooms-tasks/types.js +14 -1
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +91 -44
- package/dist/session/acp.d.ts +4 -1
- package/dist/session/acp.js +29 -13
- package/dist/session/arbiter.d.ts +2 -1
- package/dist/session/arbiter.js +2 -0
- package/dist/session/codex-app-server-transport.d.ts +53 -0
- package/dist/session/codex-app-server-transport.js +184 -0
- package/dist/session/codex-app-server.d.ts +122 -0
- package/dist/session/codex-app-server.js +1031 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/control.js +17 -1
- package/dist/session/conversation-types.d.ts +4 -4
- package/dist/session/types.d.ts +24 -6
- package/dist/session/types.js +28 -0
- package/dist/spawn.d.ts +9 -0
- package/dist/spawn.js +77 -9
- package/dist/web/fleet-config-service.d.ts +1 -0
- package/dist/web/fleet-config-service.js +25 -7
- package/dist/web/runtime.js +11 -6
- package/dist/web/server.js +28 -1
- package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
- package/dist/web-app/index.html +1 -1
- package/package.json +4 -4
- package/presets/brain-catalog.json +17 -0
- package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
- package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
- package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
- package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +37 -0
- package/presets/fleet/roles/Critic.yaml +23 -1
- package/presets/fleet/roles/Developer.yaml +27 -5
- package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
- package/presets/fleet/room_templates/pair.yaml +9 -7
- package/presets/fleet/room_templates/single.yaml +5 -4
- package/presets/fleet/room_templates/team.yaml +12 -8
- package/presets/manifest.json +2 -1
- package/presets/fleet/agents/Architect.yaml +0 -3
- package/presets/fleet/agents/Secretary.yaml +0 -3
- package/presets/fleet/agents/Tester.yaml +0 -3
- package/presets/fleet/roles/Agent.yaml +0 -6
- package/presets/fleet/roles/Architect.yaml +0 -6
- package/presets/fleet/roles/Secretary.yaml +0 -6
- package/presets/fleet/roles/Tester.yaml +0 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withFileLock } from '../atomic-file.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type TempLifecycleDeps } from '../temp-lifecycle.js';
|
|
3
|
+
import { type CoworkAdapter } from './cowork-adapter.js';
|
|
3
4
|
import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
|
|
4
5
|
export interface RoomCloseDeps {
|
|
5
6
|
inspectMember?(seat: RoomMemberSeat): Promise<{
|
|
@@ -11,6 +12,13 @@ export interface RoomCloseDeps {
|
|
|
11
12
|
removeIdentity?(seat: RoomMemberSeat): Promise<void>;
|
|
12
13
|
lock?: typeof withFileLock;
|
|
13
14
|
}
|
|
15
|
+
export declare function inspectMember(seat: RoomMemberSeat): Promise<{
|
|
16
|
+
launchId: string;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function waitForLivenessAbsent(role: string, launchId: string, lifecycleDeps?: TempLifecycleDeps): Promise<void>;
|
|
19
|
+
/** Report whether any daemon identity — under any name — carries this exact CID. */
|
|
20
|
+
export declare function identityCidPresent(cid: string): Promise<boolean>;
|
|
21
|
+
export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
|
|
14
22
|
/** One forward-only room close saga shared by every Fleet entry point. */
|
|
15
23
|
export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
|
|
16
24
|
export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
|
|
@@ -5,6 +5,7 @@ import { attachOursClient } from '@ours.network/sdk/client';
|
|
|
5
5
|
import { withFileLock } from '../atomic-file.js';
|
|
6
6
|
import { agentDir, stateRoot } from '../paths.js';
|
|
7
7
|
import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
|
|
8
|
+
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
8
9
|
import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
|
|
9
10
|
const CLOSE_LOCK_STALE_MS = 5 * 60_000;
|
|
10
11
|
const STOP_POLLS = 50;
|
|
@@ -26,7 +27,7 @@ function exactMemberIdentity(seat) {
|
|
|
26
27
|
throw new Error(`room member '${seat.role_name}' temp state binds identity '${identity}'; refusing mismatched retirement`);
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
async function inspectMember(seat) {
|
|
30
|
+
export async function inspectMember(seat) {
|
|
30
31
|
exactMemberIdentity(seat);
|
|
31
32
|
const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
|
|
32
33
|
if (!supervisor || supervisor.role !== seat.role_name) {
|
|
@@ -34,7 +35,7 @@ async function inspectMember(seat) {
|
|
|
34
35
|
}
|
|
35
36
|
return { launchId: supervisor.launchId };
|
|
36
37
|
}
|
|
37
|
-
async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
|
|
38
|
+
export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
|
|
38
39
|
const dir = agentDir(role, true);
|
|
39
40
|
const sleep = lifecycleDeps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
40
41
|
for (let attempt = 0; attempt < STOP_POLLS; attempt++) {
|
|
@@ -66,9 +67,16 @@ async function withIdentityClient(work) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
function listedIdentity(rows, name) {
|
|
69
|
-
return rows.find(row => row.name === name);
|
|
70
|
+
return rows.find((row) => row.name === name && 'cid' in row);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
/** Report whether any daemon identity — under any name — carries this exact CID. */
|
|
73
|
+
export async function identityCidPresent(cid) {
|
|
74
|
+
return withIdentityClient(async (client) => {
|
|
75
|
+
const rows = await client.listIdentities();
|
|
76
|
+
return rows.some(row => 'cid' in row && row.cid.toLowerCase() === cid.toLowerCase());
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export async function removeExactMemberIdentity(seat) {
|
|
72
80
|
await withIdentityClient(async (client) => {
|
|
73
81
|
const before = listedIdentity(await client.listIdentities(), seat.role_name);
|
|
74
82
|
if (!before)
|
|
@@ -163,7 +171,7 @@ export async function closeManagedRoom(input) {
|
|
|
163
171
|
return closeRoom(input.roomId);
|
|
164
172
|
}
|
|
165
173
|
catch (error) {
|
|
166
|
-
setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'
|
|
174
|
+
setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
|
|
167
175
|
throw error;
|
|
168
176
|
}
|
|
169
177
|
}, {}, CLOSE_LOCK_STALE_MS);
|
|
@@ -172,7 +180,15 @@ export async function closeManagedRoom(input) {
|
|
|
172
180
|
export async function deleteLegacyClosedRooms(input) {
|
|
173
181
|
const deleted = [];
|
|
174
182
|
for (const room of listRoomRecords({ state: 'closed' })) {
|
|
175
|
-
|
|
183
|
+
try {
|
|
184
|
+
await input.cowork.deleteRoom(room.room_id);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
// The retained Fleet record is the legacy state being migrated. If the
|
|
188
|
+
// exact Cowork room is already absent, the desired deletion is complete.
|
|
189
|
+
if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
176
192
|
deleteRoomRecord(room.room_id);
|
|
177
193
|
deleted.push(room.room_id);
|
|
178
194
|
}
|
|
@@ -185,7 +201,7 @@ export async function deleteManagedRoom(input) {
|
|
|
185
201
|
await input.cowork.deleteRoom(input.roomId);
|
|
186
202
|
}
|
|
187
203
|
catch (error) {
|
|
188
|
-
setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'
|
|
204
|
+
setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
|
|
189
205
|
throw error;
|
|
190
206
|
}
|
|
191
207
|
deleteRoomRecord(input.roomId);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
|
|
4
|
-
import { isSensitiveConfigKey } from '../sensitive-config.js';
|
|
3
|
+
import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
|
|
5
4
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
6
5
|
const CID_RE = /^[0-9a-fA-F]{64}$/;
|
|
7
6
|
export class RoomsTasksConfigError extends Error {
|
|
@@ -133,39 +132,31 @@ export function validateRoomTemplatesConfig(raw, path) {
|
|
|
133
132
|
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].role: required string`);
|
|
134
133
|
if (!Number.isInteger(m.count) || m.count < 1)
|
|
135
134
|
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].count: required positive integer`);
|
|
136
|
-
if (
|
|
137
|
-
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const isDefinition = agentKeys.includes('role') && agentKeys.includes('brain');
|
|
141
|
-
if (!isRef && !isDefinition)
|
|
142
|
-
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: must be {ref} or canonical {role, brain, ...}`);
|
|
143
|
-
const containsSensitive = (value) => Array.isArray(value)
|
|
144
|
-
? value.some(containsSensitive)
|
|
145
|
-
: Boolean(value && typeof value === 'object' && Object.entries(value)
|
|
146
|
-
.some(([key, child]) => isSensitiveConfigKey(key) || containsSensitive(child)));
|
|
147
|
-
if (isDefinition && containsSensitive(m.agent))
|
|
148
|
-
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: inline sensitive configuration cannot be persisted; use references`);
|
|
149
|
-
if (isDefinition) {
|
|
150
|
-
const definition = m.agent;
|
|
151
|
-
const brain = isPlainObject(definition.brain) && isPlainObject(definition.brain.inline)
|
|
152
|
-
? definition.brain.inline : undefined;
|
|
153
|
-
if (definition.env !== undefined || definition.owner_channel !== undefined
|
|
154
|
-
|| definition.auth_proxy !== undefined || brain?.harness_options !== undefined
|
|
155
|
-
|| brain?.session_options !== undefined)
|
|
156
|
-
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: secret-capable inline fields cannot be persisted; use references`);
|
|
157
|
-
}
|
|
135
|
+
if (m.agent !== undefined)
|
|
136
|
+
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: persistent Agent references are no longer allowed; use agent_template: <ID>`);
|
|
137
|
+
if (typeof m.agent_template !== 'string' || !m.agent_template.trim())
|
|
138
|
+
throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent_template: required non-blank Agent Template ID`);
|
|
158
139
|
return {
|
|
159
140
|
slot: m.slot,
|
|
160
141
|
role: m.role,
|
|
161
142
|
count: m.count,
|
|
162
|
-
|
|
143
|
+
agent_template: m.agent_template,
|
|
163
144
|
};
|
|
164
145
|
});
|
|
146
|
+
const slots = new Set();
|
|
147
|
+
for (const member of members) {
|
|
148
|
+
if (slots.has(member.slot))
|
|
149
|
+
throw new RoomsTasksConfigError(path, `room_templates.${name}.members: duplicate slot '${member.slot}'`);
|
|
150
|
+
slots.add(member.slot);
|
|
151
|
+
}
|
|
165
152
|
let room;
|
|
166
153
|
if (tplRaw.room !== undefined) {
|
|
167
154
|
if (!isPlainObject(tplRaw.room))
|
|
168
155
|
throw new RoomsTasksConfigError(path, `room_templates.${name}.room: must be a mapping`);
|
|
156
|
+
rejectUnknown(tplRaw.room, TRK, path, `room_templates.${name}.room`);
|
|
157
|
+
for (const key of TRK)
|
|
158
|
+
if (tplRaw.room[key] !== undefined && typeof tplRaw.room[key] !== 'boolean')
|
|
159
|
+
throw new RoomsTasksConfigError(path, `room_templates.${name}.room.${key}: must be a boolean`);
|
|
169
160
|
room = {
|
|
170
161
|
quiet_membership: tplRaw.room.quiet_membership,
|
|
171
162
|
anonymous: tplRaw.room.anonymous,
|
|
@@ -33,6 +33,7 @@ export interface CoworkRoomInfo {
|
|
|
33
33
|
identity_cid: string;
|
|
34
34
|
room_name: string;
|
|
35
35
|
state: 'provisioning' | 'active' | 'closing' | 'closed';
|
|
36
|
+
anonymous: boolean;
|
|
36
37
|
seats: CoworkSeatInfo[];
|
|
37
38
|
goal?: string;
|
|
38
39
|
briefing?: string;
|
|
@@ -72,6 +73,10 @@ export interface CoworkAdapter {
|
|
|
72
73
|
role: string;
|
|
73
74
|
text: string;
|
|
74
75
|
}): Promise<CoworkRoleBriefingInfo>;
|
|
76
|
+
setRoleCommands(roomId: string, opts: {
|
|
77
|
+
role: string;
|
|
78
|
+
commands: Array<'list-members' | 'remove-member'>;
|
|
79
|
+
}): Promise<void>;
|
|
75
80
|
getHistory(roomId: string, opts?: {
|
|
76
81
|
after?: number;
|
|
77
82
|
limit?: number;
|
|
@@ -47,6 +47,11 @@ function roomState(value, operation) {
|
|
|
47
47
|
throw new CoworkProtocolError(operation, 'room state is invalid');
|
|
48
48
|
return value;
|
|
49
49
|
}
|
|
50
|
+
function boolean(value, operation, label) {
|
|
51
|
+
if (typeof value !== 'boolean')
|
|
52
|
+
throw new CoworkProtocolError(operation, `${label} must be a boolean`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
50
55
|
function seatState(value, operation) {
|
|
51
56
|
if (value !== 'pending' && value !== 'active' && value !== 'removed')
|
|
52
57
|
throw new CoworkProtocolError(operation, 'seat state is invalid');
|
|
@@ -86,6 +91,7 @@ function projectRoom(value, operation) {
|
|
|
86
91
|
identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
|
|
87
92
|
room_name: string(room.room_name, operation, 'room.room_name'),
|
|
88
93
|
state: roomState(room.state, operation),
|
|
94
|
+
anonymous: room.anonymous === undefined ? false : boolean(room.anonymous, operation, 'room.anonymous'),
|
|
89
95
|
seats: room.seats.map((seat) => projectSeat(seat, operation)),
|
|
90
96
|
role_briefings: projectedBriefings,
|
|
91
97
|
...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
|
|
@@ -384,6 +390,13 @@ export function createCoworkAdapter(options = {}) {
|
|
|
384
390
|
throw new CoworkProtocolError('room.participants', 'result must be an array');
|
|
385
391
|
return result.map((seat) => projectSeat(seat, 'room.participants'));
|
|
386
392
|
},
|
|
393
|
+
async setRoleCommands(roomId, opts) {
|
|
394
|
+
const result = await call('room.command.role.set', {
|
|
395
|
+
room_id: roomId, role: opts.role, commands: opts.commands,
|
|
396
|
+
});
|
|
397
|
+
if (!Array.isArray(result))
|
|
398
|
+
throw new CoworkProtocolError('room.command.role.set', 'result must be an array');
|
|
399
|
+
},
|
|
387
400
|
async recoverRoom(roomId) {
|
|
388
401
|
// Cowork performs packet/state reconciliation during daemon recovery.
|
|
389
402
|
// Its `room.recover` RPC is specifically invite-receipt recovery, so a
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type RoomCloseDeps } from './close.js';
|
|
2
|
+
import { type CoworkAdapter } from './cowork-adapter.js';
|
|
3
|
+
import { type TaskDeletionAcceptance } from './task-state.js';
|
|
4
|
+
import type { TaskDeletionActor, TaskRecord } from './types.js';
|
|
5
|
+
export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
6
|
+
type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
|
|
7
|
+
export interface TaskDeletionSettleDeps {
|
|
8
|
+
roomClose?: RoomCloseDeps;
|
|
9
|
+
/** Test seam for the temp-state existence proof. */
|
|
10
|
+
hasTempState?(name: string): boolean;
|
|
11
|
+
/** Test seam for the CID-wide daemon identity scan. */
|
|
12
|
+
identityCidPresent?(cid: string): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
export interface TaskDeletionSettleResult {
|
|
15
|
+
task_id: string;
|
|
16
|
+
deleted: boolean;
|
|
17
|
+
/** Lifecycle state observed before unlink, for completion audit. */
|
|
18
|
+
previous_state?: TaskRecord['state'];
|
|
19
|
+
/** Title observed before unlink, for completion audit. */
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Accept a permanent task deletion under the common task-operation lock so it
|
|
24
|
+
* serializes against terminal settlement, recovery, and the room-publication
|
|
25
|
+
* window. Acceptance is durable before any side effect and never requires
|
|
26
|
+
* Cowork availability; settlement runs separately and converges through
|
|
27
|
+
* retries.
|
|
28
|
+
*/
|
|
29
|
+
export declare function acceptTaskDeletion(taskId: string, actor: TaskDeletionActor): Promise<TaskDeletionAcceptance>;
|
|
30
|
+
/** Persist a settlement failure under the same task-operation lock. */
|
|
31
|
+
export declare function recordTaskDeletionError(taskId: string, error: string, recoveryHint: string): Promise<TaskRecord>;
|
|
32
|
+
/**
|
|
33
|
+
* Converge an accepted deletion to physical absence in two lock-ordered
|
|
34
|
+
* stages. Cleanup stage (task-operation lock): per surviving room record,
|
|
35
|
+
* register late seats → close saga (retires members) → durable evidence
|
|
36
|
+
* checkpoint → tolerant remote delete → local record delete; then
|
|
37
|
+
* cursor-based retirement for members with no room record and tolerant remote
|
|
38
|
+
* close+delete of a recorded room whose local record is gone. Finalization
|
|
39
|
+
* stage (launch-snapshot lock → task-operation lock, matching the global
|
|
40
|
+
* provisioning order): re-verify quiescence, delete the owned launch snapshot
|
|
41
|
+
* while excluding only this deleting task from the reference scan, and unlink
|
|
42
|
+
* the task record LAST — every crash seam leaves the hidden intent
|
|
43
|
+
* recoverable. Failures are recorded on the intent; the task stays hidden and
|
|
44
|
+
* recoverable, never falsely settled.
|
|
45
|
+
*/
|
|
46
|
+
export declare function settleTaskDeletion(input: {
|
|
47
|
+
taskId: string;
|
|
48
|
+
/** Lazy: resolved only when room work exists, so no-room tasks settle without Cowork config. */
|
|
49
|
+
cowork: () => DeletionCowork;
|
|
50
|
+
deps?: TaskDeletionSettleDeps;
|
|
51
|
+
}): Promise<TaskDeletionSettleResult>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { withFileLock } from '../atomic-file.js';
|
|
3
|
+
import { agentDir } from '../paths.js';
|
|
4
|
+
import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
|
|
5
|
+
import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
|
|
6
|
+
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
7
|
+
import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
|
|
8
|
+
import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
|
|
9
|
+
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
10
|
+
import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
|
|
11
|
+
export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
12
|
+
import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
13
|
+
/**
|
|
14
|
+
* Accept a permanent task deletion under the common task-operation lock so it
|
|
15
|
+
* serializes against terminal settlement, recovery, and the room-publication
|
|
16
|
+
* window. Acceptance is durable before any side effect and never requires
|
|
17
|
+
* Cowork availability; settlement runs separately and converges through
|
|
18
|
+
* retries.
|
|
19
|
+
*/
|
|
20
|
+
export function acceptTaskDeletion(taskId, actor) {
|
|
21
|
+
return withFileLock(taskOperationLockPath(taskId), () => beginTaskDeletionIntent(taskId, actor), {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
22
|
+
}
|
|
23
|
+
/** Persist a settlement failure under the same task-operation lock. */
|
|
24
|
+
export function recordTaskDeletionError(taskId, error, recoveryHint) {
|
|
25
|
+
return withFileLock(taskOperationLockPath(taskId), () => setTaskDeletionError(taskId, error, recoveryHint), {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
26
|
+
}
|
|
27
|
+
function errorText(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
/** Already-missing remote rooms are a settled outcome, not a failure. */
|
|
31
|
+
function tolerantCowork(cowork) {
|
|
32
|
+
const tolerate = async (work) => {
|
|
33
|
+
try {
|
|
34
|
+
await work();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof CoworkProtocolError && error.code === 'not_found')
|
|
38
|
+
return;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
closeRoom: roomId => tolerate(() => cowork.closeRoom(roomId)),
|
|
44
|
+
deleteRoom: roomId => tolerate(() => cowork.deleteRoom(roomId)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function cursorSeat(cursor) {
|
|
48
|
+
return {
|
|
49
|
+
role_name: cursor.name, identity_cid: cursor.identity_cid,
|
|
50
|
+
slot: cursor.name, cowork_role: 'member', seat_state: 'active',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Retire one member from its durable deletion cursor when no room record
|
|
55
|
+
* carries the seat: the same evidence chain as close.ts, resumed at the
|
|
56
|
+
* recorded phase. A member with no temp state is verified absent through the
|
|
57
|
+
* CID-wide identity scan; a same-name stranger identity is never touched, and
|
|
58
|
+
* the recorded CID surviving under any name blocks settlement.
|
|
59
|
+
*/
|
|
60
|
+
async function retireCursorMember(taskId, cursor, deps) {
|
|
61
|
+
let phase = cursor.phase;
|
|
62
|
+
let launchId = cursor.launch_id;
|
|
63
|
+
if (phase === 'identity_absent')
|
|
64
|
+
return;
|
|
65
|
+
const seat = cursorSeat(cursor);
|
|
66
|
+
const roomClose = deps.roomClose ?? {};
|
|
67
|
+
if (phase === 'pending') {
|
|
68
|
+
const hasState = (deps.hasTempState ?? (name => existsSync(agentDir(name, true))))(cursor.name);
|
|
69
|
+
if (!hasState) {
|
|
70
|
+
const present = await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid);
|
|
71
|
+
if (present) {
|
|
72
|
+
// Remove only an exact name+CID match; a stranger under the same name
|
|
73
|
+
// stays untouched and removeExactMemberIdentity refuses the mismatch.
|
|
74
|
+
// The recorded CID surviving under a different name throws below on
|
|
75
|
+
// its post-removal verification and blocks settlement.
|
|
76
|
+
await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
|
|
77
|
+
if (await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid))
|
|
78
|
+
throw new TaskStateError(`deletion member '${cursor.name}' identity CID survives under another name; refusing to claim retirement`);
|
|
79
|
+
}
|
|
80
|
+
advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent', DELETION_MEMBER_ABSENT_VERIFIED);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const ownership = await (roomClose.inspectMember ?? inspectMember)(seat);
|
|
84
|
+
advanceTaskDeletionMember(taskId, cursor.name, 'stop_requested', ownership.launchId);
|
|
85
|
+
phase = 'stop_requested';
|
|
86
|
+
launchId = ownership.launchId;
|
|
87
|
+
}
|
|
88
|
+
if (phase === 'stop_requested') {
|
|
89
|
+
await (roomClose.requestStop
|
|
90
|
+
?? (async (role) => { await stopTempSupervisor(role); }))(cursor.name);
|
|
91
|
+
await (roomClose.waitForLivenessAbsent ?? waitForLivenessAbsent)(cursor.name, launchId);
|
|
92
|
+
advanceTaskDeletionMember(taskId, cursor.name, 'liveness_absent');
|
|
93
|
+
phase = 'liveness_absent';
|
|
94
|
+
}
|
|
95
|
+
if (phase === 'liveness_absent') {
|
|
96
|
+
const archivePath = await (roomClose.secureArchive ?? secureStoppedTempArchive)(cursor.name, launchId);
|
|
97
|
+
advanceTaskDeletionMember(taskId, cursor.name, 'archive_secured', undefined, archivePath);
|
|
98
|
+
phase = 'archive_secured';
|
|
99
|
+
}
|
|
100
|
+
if (phase === 'archive_secured') {
|
|
101
|
+
await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
|
|
102
|
+
advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Converge an accepted deletion to physical absence in two lock-ordered
|
|
107
|
+
* stages. Cleanup stage (task-operation lock): per surviving room record,
|
|
108
|
+
* register late seats → close saga (retires members) → durable evidence
|
|
109
|
+
* checkpoint → tolerant remote delete → local record delete; then
|
|
110
|
+
* cursor-based retirement for members with no room record and tolerant remote
|
|
111
|
+
* close+delete of a recorded room whose local record is gone. Finalization
|
|
112
|
+
* stage (launch-snapshot lock → task-operation lock, matching the global
|
|
113
|
+
* provisioning order): re-verify quiescence, delete the owned launch snapshot
|
|
114
|
+
* while excluding only this deleting task from the reference scan, and unlink
|
|
115
|
+
* the task record LAST — every crash seam leaves the hidden intent
|
|
116
|
+
* recoverable. Failures are recorded on the intent; the task stays hidden and
|
|
117
|
+
* recoverable, never falsely settled.
|
|
118
|
+
*/
|
|
119
|
+
export async function settleTaskDeletion(input) {
|
|
120
|
+
const { taskId } = input;
|
|
121
|
+
const deps = input.deps ?? {};
|
|
122
|
+
const recoveryHint = `Re-run 'ours-fleet task delete ${taskId} ${taskId}'.`;
|
|
123
|
+
const cleanup = await withFileLock(taskOperationLockPath(taskId), async () => {
|
|
124
|
+
let task;
|
|
125
|
+
try {
|
|
126
|
+
task = getDeletingTask(taskId);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error instanceof TaskStateError && /task not found/.test(error.message))
|
|
130
|
+
return { kind: 'already_absent' };
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
if (task.deletion?.status !== 'pending')
|
|
134
|
+
throw new TaskStateError(`task ${taskId} has no pending deletion`);
|
|
135
|
+
try {
|
|
136
|
+
// Audit evidence precedes every side effect; a receipt write failure
|
|
137
|
+
// aborts settlement (fail closed).
|
|
138
|
+
ensureTaskDeletionReceipt(taskId);
|
|
139
|
+
const records = listRoomRecords().filter(room => room.task_id === taskId);
|
|
140
|
+
const recordIds = new Set(records.map(room => room.room_id));
|
|
141
|
+
const recordedRoomId = task.deletion.room_id ?? task.room_id;
|
|
142
|
+
const needsCowork = records.length > 0 || recordedRoomId !== undefined;
|
|
143
|
+
const cowork = needsCowork ? tolerantCowork(input.cowork()) : undefined;
|
|
144
|
+
for (const record of records) {
|
|
145
|
+
upsertTaskDeletionMembersFromSeats(taskId, record.member_seats);
|
|
146
|
+
await closeManagedRoom({ roomId: record.room_id, cowork: cowork, deps: deps.roomClose });
|
|
147
|
+
const closed = getRoomRecord(record.room_id);
|
|
148
|
+
importTaskDeletionRetirementEvidence(taskId, closed?.member_seats ?? record.member_seats);
|
|
149
|
+
await cowork.deleteRoom(record.room_id);
|
|
150
|
+
deleteRoomRecord(record.room_id);
|
|
151
|
+
}
|
|
152
|
+
// Members whose room record is gone (crash after record deletion, or
|
|
153
|
+
// legacy state): resume from the durable cursors.
|
|
154
|
+
for (const cursor of getDeletingTask(taskId).deletion.members) {
|
|
155
|
+
await retireCursorMember(taskId, cursor, deps);
|
|
156
|
+
}
|
|
157
|
+
// A recorded room without a local record may still be live remotely.
|
|
158
|
+
if (recordedRoomId && !recordIds.has(recordedRoomId) && !getRoomRecord(recordedRoomId)) {
|
|
159
|
+
await cowork.closeRoom(recordedRoomId);
|
|
160
|
+
await cowork.deleteRoom(recordedRoomId);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
kind: 'cleaned',
|
|
164
|
+
snapshotHash: task.execution_plan?.snapshot.launch_snapshot_hash,
|
|
165
|
+
previousState: task.state,
|
|
166
|
+
title: task.title,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
setTaskDeletionError(taskId, errorText(error), recoveryHint);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
174
|
+
if (cleanup.kind === 'already_absent') {
|
|
175
|
+
// Heal a crash between unlink and receipt completion.
|
|
176
|
+
completeTaskDeletionReceipt(taskId);
|
|
177
|
+
return { task_id: taskId, deleted: false };
|
|
178
|
+
}
|
|
179
|
+
const finalize = () => withFileLock(taskOperationLockPath(taskId), () => {
|
|
180
|
+
try {
|
|
181
|
+
const task = getDeletingTask(taskId);
|
|
182
|
+
if (task.deletion?.status !== 'pending')
|
|
183
|
+
throw new TaskStateError(`task ${taskId} has no pending deletion`);
|
|
184
|
+
if (listRoomRecords().some(room => room.task_id === taskId))
|
|
185
|
+
throw new TaskStateError(`task ${taskId} room records reappeared during deletion finalization`);
|
|
186
|
+
if (task.deletion.members.some(member => member.phase !== 'identity_absent'))
|
|
187
|
+
throw new TaskStateError(`task ${taskId} has unretired members at deletion finalization`);
|
|
188
|
+
if (cleanup.snapshotHash)
|
|
189
|
+
releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
|
|
190
|
+
unlinkDeletedTask(taskId);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
setTaskDeletionError(taskId, errorText(error), recoveryHint);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
// After unlink the task can no longer carry errors; receipt completion is
|
|
197
|
+
// idempotent and the already-absent retry path heals a crash here.
|
|
198
|
+
completeTaskDeletionReceipt(taskId);
|
|
199
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
200
|
+
if (cleanup.snapshotHash) {
|
|
201
|
+
// Global lock order: launch-snapshot lock → task-operation lock.
|
|
202
|
+
const releaseSnapshotLock = acquireLaunchSnapshotLock();
|
|
203
|
+
try {
|
|
204
|
+
await finalize();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
releaseSnapshotLock();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
await finalize();
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
task_id: taskId, deleted: true,
|
|
215
|
+
previous_state: cleanup.previousState, title: cleanup.title,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { FleetAuditPresentation } from '../fleet-command-audit.js';
|
|
2
|
+
/** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
|
|
3
|
+
export declare const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = "OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR";
|
|
1
4
|
export declare function fleetWorkerEnv(source?: NodeJS.ProcessEnv): Record<string, string>;
|
|
5
|
+
/** Present a detached worker's durable outcome through the authenticated Owner sink. */
|
|
6
|
+
export declare function presentFleetWorkerLifecycle(presentations: FleetAuditPresentation[], deps?: {
|
|
7
|
+
now(): number;
|
|
8
|
+
sleep(ms: number): Promise<void>;
|
|
9
|
+
}): Promise<void>;
|
|
2
10
|
/** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
|
|
3
11
|
export declare function launchFleetWorker(args: string[], operation: string, configPath?: string): Promise<void>;
|
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV } from '../fleet-proxy.js';
|
|
4
|
+
import { controlRequest } from '../session/control.js';
|
|
5
|
+
import { SessionControlError } from '../session/types.js';
|
|
6
|
+
/** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
|
|
7
|
+
export const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = 'OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR';
|
|
8
|
+
const LIFECYCLE_ACCEPT_TIMEOUT_MS = 10 * 60_000;
|
|
9
|
+
const LIFECYCLE_RETRY_MS = 250;
|
|
4
10
|
export function fleetWorkerEnv(source = process.env) {
|
|
5
11
|
const inheritedKeys = [
|
|
6
12
|
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_CONFIG',
|
|
7
13
|
'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_COWORK_CONFIG',
|
|
8
14
|
];
|
|
9
15
|
const env = Object.fromEntries(inheritedKeys.flatMap(key => source[key] === undefined ? [] : [[key, source[key]]]));
|
|
16
|
+
const lifecycleStateDir = source[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV]
|
|
17
|
+
?? source[FLEET_PROXY_STATE_DIR_ENV];
|
|
18
|
+
if (lifecycleStateDir)
|
|
19
|
+
env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV] = lifecycleStateDir;
|
|
10
20
|
// Explicit defense: trusted settle/recover/delete/close workers are effects
|
|
11
21
|
// nested inside the outer audited command, never fresh agent attempts.
|
|
12
22
|
delete env[FLEET_PROXY_STATE_DIR_ENV];
|
|
13
23
|
delete env[FLEET_PROXY_CALLER_ENV];
|
|
14
24
|
return env;
|
|
15
25
|
}
|
|
26
|
+
/** Present a detached worker's durable outcome through the authenticated Owner sink. */
|
|
27
|
+
export async function presentFleetWorkerLifecycle(presentations, deps = {
|
|
28
|
+
now: Date.now, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
29
|
+
}) {
|
|
30
|
+
const stateDir = process.env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV];
|
|
31
|
+
if (!stateDir || !presentations.length)
|
|
32
|
+
return;
|
|
33
|
+
const deadline = deps.now() + LIFECYCLE_ACCEPT_TIMEOUT_MS;
|
|
34
|
+
for (;;) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await controlRequest(stateDir, {
|
|
37
|
+
command: 'fleet_audit_present', audit: { presentations },
|
|
38
|
+
}, 15_000);
|
|
39
|
+
if (!response.ok)
|
|
40
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'detached Fleet lifecycle delivery failed');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// Missing token/socket and refused connect are classified before any
|
|
45
|
+
// request can cross the server boundary, so only this class is safe to
|
|
46
|
+
// retry. Timeout/backend/response failures may be uncertain and stop.
|
|
47
|
+
if (!(error instanceof SessionControlError)
|
|
48
|
+
|| error.kind !== 'control-unavailable' || deps.now() >= deadline)
|
|
49
|
+
throw error;
|
|
50
|
+
await deps.sleep(LIFECYCLE_RETRY_MS);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
16
54
|
/** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
|
|
17
55
|
export function launchFleetWorker(args, operation, configPath) {
|
|
18
56
|
const cli = [process.argv[1], ...args, ...(configPath ? ['-c', configPath] : [])];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AgentTemplateDefinition } from '../config.js';
|
|
2
|
+
/** Cross-process exclusion used from seal through durable Room publication. */
|
|
3
|
+
export declare function acquireLaunchSnapshotLock(options?: {
|
|
4
|
+
beforePublish?: (claimPath: string, lockPath: string) => void;
|
|
5
|
+
}): () => void;
|
|
6
|
+
export declare function redactLaunchDefinition(value: unknown, key?: string): unknown;
|
|
7
|
+
export declare function sealLaunchSnapshot(definitions: Record<string, AgentTemplateDefinition>): string;
|
|
8
|
+
export declare function readLaunchSnapshot(hash: string): Record<string, AgentTemplateDefinition>;
|
|
9
|
+
/** Delete a sealed snapshot only after no retained Task/Room record references it. */
|
|
10
|
+
export declare function releaseLaunchSnapshot(hash: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Deletion-finalization variant: the caller already holds the global
|
|
13
|
+
* launch-snapshot lock and is about to unlink the deletion-pending task
|
|
14
|
+
* `taskId`. The reference scan excludes exactly that task file — every other
|
|
15
|
+
* retained Task/Room reference still protects the snapshot. Deleting the
|
|
16
|
+
* snapshot BEFORE the task unlink keeps every crash seam recoverable: the
|
|
17
|
+
* hidden intent survives, and a retry sees the snapshot already absent.
|
|
18
|
+
*/
|
|
19
|
+
export declare function releaseLaunchSnapshotForDeletingTask(hash: string, taskId: string): void;
|