@ours.network/fleet 1.1.0-nightly.11 → 1.1.0-nightly.13
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/dist/application/role-creation-service.js +8 -1
- package/dist/application/task-room-service.d.ts +44 -4
- package/dist/application/task-room-service.js +137 -20
- package/dist/build-info.json +4 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +18 -5
- package/dist/fleet-command-audit.d.ts +4 -0
- package/dist/fleet-command-audit.js +126 -22
- package/dist/fleet-proxy.d.ts +3 -0
- package/dist/lifecycle-summary.d.ts +118 -0
- package/dist/lifecycle-summary.js +161 -0
- package/dist/owner-channel/channel.d.ts +2 -0
- package/dist/owner-channel/channel.js +45 -9
- package/dist/owner-channel/commands.d.ts +4 -1
- package/dist/owner-channel/commands.js +3 -6
- package/dist/rooms-tasks/cli.js +151 -19
- package/dist/rooms-tasks/close.d.ts +9 -1
- package/dist/rooms-tasks/close.js +20 -4
- package/dist/rooms-tasks/deletion.d.ts +51 -0
- package/dist/rooms-tasks/deletion.js +217 -0
- package/dist/rooms-tasks/launch-snapshot.d.ts +9 -0
- package/dist/rooms-tasks/launch-snapshot.js +18 -1
- package/dist/rooms-tasks/provision.js +84 -10
- package/dist/rooms-tasks/task-state.d.ts +113 -3
- package/dist/rooms-tasks/task-state.js +333 -9
- package/dist/rooms-tasks/terminal.d.ts +3 -0
- package/dist/rooms-tasks/terminal.js +7 -3
- package/dist/rooms-tasks/types.d.ts +48 -0
- package/dist/web/server.js +28 -1
- package/package.json +1 -1
|
@@ -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}' or 'ours-fleet task recover ${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
|
+
}
|
|
@@ -8,3 +8,12 @@ export declare function sealLaunchSnapshot(definitions: Record<string, AgentTemp
|
|
|
8
8
|
export declare function readLaunchSnapshot(hash: string): Record<string, AgentTemplateDefinition>;
|
|
9
9
|
/** Delete a sealed snapshot only after no retained Task/Room record references it. */
|
|
10
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;
|
|
@@ -68,12 +68,14 @@ export function acquireLaunchSnapshotLock(options = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
function referencedByRetainedState(hash) {
|
|
71
|
+
function referencedByRetainedState(hash, excludeTaskFile) {
|
|
72
72
|
for (const directory of ['tasks', 'rooms']) {
|
|
73
73
|
const root = join(stateRoot(), directory);
|
|
74
74
|
if (!existsSync(root))
|
|
75
75
|
continue;
|
|
76
76
|
for (const name of readdirSync(root).filter(entry => entry.endsWith('.json'))) {
|
|
77
|
+
if (directory === 'tasks' && excludeTaskFile !== undefined && name === excludeTaskFile)
|
|
78
|
+
continue;
|
|
77
79
|
try {
|
|
78
80
|
if (readFileSync(join(root, name), 'utf8').includes(hash))
|
|
79
81
|
return true;
|
|
@@ -157,3 +159,18 @@ export function releaseLaunchSnapshot(hash) {
|
|
|
157
159
|
release();
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Deletion-finalization variant: the caller already holds the global
|
|
164
|
+
* launch-snapshot lock and is about to unlink the deletion-pending task
|
|
165
|
+
* `taskId`. The reference scan excludes exactly that task file — every other
|
|
166
|
+
* retained Task/Room reference still protects the snapshot. Deleting the
|
|
167
|
+
* snapshot BEFORE the task unlink keeps every crash seam recoverable: the
|
|
168
|
+
* hidden intent survives, and a retry sees the snapshot already absent.
|
|
169
|
+
*/
|
|
170
|
+
export function releaseLaunchSnapshotForDeletingTask(hash, taskId) {
|
|
171
|
+
if (referencedByRetainedState(hash, `${taskId}.json`))
|
|
172
|
+
return;
|
|
173
|
+
const path = launchSnapshotPath(hash);
|
|
174
|
+
if (existsSync(path))
|
|
175
|
+
unlinkSync(path);
|
|
176
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { parse } from 'yaml';
|
|
4
5
|
import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
|
|
5
6
|
import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
|
|
6
7
|
import { spawnTemp } from '../spawn.js';
|
|
8
|
+
import { effectivePermissionMode } from '../permissions.js';
|
|
9
|
+
import { selectionOrigin, summarizeResolvedLaunch, } from '../lifecycle-summary.js';
|
|
7
10
|
import { canonicalJson } from '../canonical-json.js';
|
|
8
11
|
import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js';
|
|
9
12
|
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
|
|
10
13
|
import { controlRequest } from '../session/control.js';
|
|
11
14
|
import { SessionControlError } from '../session/types.js';
|
|
12
15
|
import { closeManagedRoom } from './close.js';
|
|
16
|
+
import { withFileLock } from '../atomic-file.js';
|
|
17
|
+
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
18
|
+
import { TaskStateError, taskDeletionState } from './task-state.js';
|
|
13
19
|
import { buildRoomMemberTask, sha256Text } from './member-startup.js';
|
|
14
20
|
import { agentDir } from '../paths.js';
|
|
15
21
|
import { readProvenance } from '../creation.js';
|
|
@@ -60,8 +66,27 @@ async function spawnRoomMember(options, binPath) {
|
|
|
60
66
|
statePath: result.statePath,
|
|
61
67
|
creationActionId: result.creationActionId,
|
|
62
68
|
callerRole: result.caller,
|
|
69
|
+
...(result.configuration ? { configuration: result.configuration } : {}),
|
|
63
70
|
};
|
|
64
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Launch-boundary capture: the exact ResolvedRole the spawn persisted,
|
|
74
|
+
* whitelisted into the operator-facing presentation. Capture is required for
|
|
75
|
+
* every launched room member — launchMatches has already proved role.yaml
|
|
76
|
+
* readable, and effectivePermissionMode is total over registered harnesses —
|
|
77
|
+
* so a failure here is a capture defect and must surface, not degrade to the
|
|
78
|
+
* legacy rendering.
|
|
79
|
+
*/
|
|
80
|
+
function presentationFromStatePath(statePath, settings, coworkRole) {
|
|
81
|
+
const resolved = parse(readFileSync(join(statePath, 'role.yaml'), 'utf8'));
|
|
82
|
+
return summarizeResolvedLaunch(resolved, {
|
|
83
|
+
role: selectionOrigin(settings.definition.role),
|
|
84
|
+
brain: selectionOrigin(settings.definition.brain),
|
|
85
|
+
template: settings.template,
|
|
86
|
+
permissionMode: effectivePermissionMode(resolved),
|
|
87
|
+
missionFallback: coworkRole,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
65
90
|
function shortId(id) { return id.slice(0, 8); }
|
|
66
91
|
function expandMembers(template, prefix) {
|
|
67
92
|
const result = [];
|
|
@@ -72,6 +97,7 @@ function expandMembers(template, prefix) {
|
|
|
72
97
|
slot: slot.slot,
|
|
73
98
|
coworkRole: slot.role,
|
|
74
99
|
agentTemplate: slot.agent_template,
|
|
100
|
+
launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
|
|
75
101
|
agentTemplateHash: slot.agent_template_hash,
|
|
76
102
|
});
|
|
77
103
|
}
|
|
@@ -79,9 +105,13 @@ function expandMembers(template, prefix) {
|
|
|
79
105
|
return result;
|
|
80
106
|
}
|
|
81
107
|
function settingsFor(member, cfg, sealed) {
|
|
82
|
-
const definition = sealed
|
|
108
|
+
const definition = sealed
|
|
109
|
+
? sealed[member.launchDefinitionId]
|
|
110
|
+
: cfg.agentTemplates?.[member.agentTemplate];
|
|
83
111
|
if (!definition)
|
|
84
|
-
throw new Error(
|
|
112
|
+
throw new Error(sealed
|
|
113
|
+
? `Sealed Agent definition '${member.launchDefinitionId}' not found`
|
|
114
|
+
: `Agent Template '${member.agentTemplate}' not found`);
|
|
85
115
|
const role = definition.role;
|
|
86
116
|
return {
|
|
87
117
|
definition: structuredClone(definition),
|
|
@@ -134,7 +164,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
|
|
|
134
164
|
}
|
|
135
165
|
}
|
|
136
166
|
async function retainRunningLaunch(input) {
|
|
137
|
-
const { provision, member, task, roomIdentityCid } = input;
|
|
167
|
+
const { provision, member, settings, task, roomIdentityCid } = input;
|
|
138
168
|
let seat = getRoomRecord(provision.roomId).member_seats
|
|
139
169
|
.find(candidate => candidate.role_name === member.name);
|
|
140
170
|
const dir = agentDir(member.name, true);
|
|
@@ -166,9 +196,14 @@ async function retainRunningLaunch(input) {
|
|
|
166
196
|
throw new Error(`existing launch for ${member.name} has unknown liveness`);
|
|
167
197
|
const retainedLaunch = seat.launch;
|
|
168
198
|
if (live === 'running') {
|
|
199
|
+
// A crash between spawn and the seat update (or a pre-upgrade launch)
|
|
200
|
+
// can retain a running member without a captured presentation; backfill
|
|
201
|
+
// it from the same persisted resolved role the match was proved against.
|
|
202
|
+
const presentation = retainedLaunch.presentation
|
|
203
|
+
?? presentationFromStatePath(dir, settings, member.coworkRole);
|
|
169
204
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
170
205
|
...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
|
|
171
|
-
updated_at: new Date().toISOString(),
|
|
206
|
+
presentation, updated_at: new Date().toISOString(),
|
|
172
207
|
} });
|
|
173
208
|
return true;
|
|
174
209
|
}
|
|
@@ -205,7 +240,25 @@ async function retainRunningLaunch(input) {
|
|
|
205
240
|
}
|
|
206
241
|
return false;
|
|
207
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Member-launch publication window: for a task-bound room, the durable launch
|
|
245
|
+
* intent, spawn, and launch record publish under the common task-operation
|
|
246
|
+
* lock so an accepted deletion linearizes strictly before (bounded abort — no
|
|
247
|
+
* spawn happens) or strictly after (the seat's launch record is visible to
|
|
248
|
+
* deletion retirement). The lock is held per member only, never across
|
|
249
|
+
* seat-wait loops.
|
|
250
|
+
*/
|
|
208
251
|
async function launchMember(input) {
|
|
252
|
+
const taskId = input.provision.taskId;
|
|
253
|
+
if (!taskId)
|
|
254
|
+
return launchMemberUnlocked(input);
|
|
255
|
+
return withFileLock(taskOperationLockPath(taskId), () => {
|
|
256
|
+
if (taskDeletionState(taskId) !== 'none')
|
|
257
|
+
throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
|
|
258
|
+
return launchMemberUnlocked(input);
|
|
259
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
260
|
+
}
|
|
261
|
+
async function launchMemberUnlocked(input) {
|
|
209
262
|
const { provision, member, settings, startup } = input;
|
|
210
263
|
const seat = getRoomRecord(provision.roomId).member_seats
|
|
211
264
|
.find(candidate => candidate.role_name === member.name);
|
|
@@ -250,18 +303,25 @@ async function launchMember(input) {
|
|
|
250
303
|
if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
|
|
251
304
|
throw new Error(`new launch for ${member.name} did not persist matching provenance`);
|
|
252
305
|
}
|
|
306
|
+
const presentation = launched.configuration
|
|
307
|
+
? { ...launched.configuration, template: settings.template,
|
|
308
|
+
...(launched.configuration.mission ? {} : { mission: member.coworkRole }) }
|
|
309
|
+
: presentationFromStatePath(launchedDir, settings, member.coworkRole);
|
|
253
310
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
254
311
|
state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
|
|
255
312
|
agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
|
|
256
313
|
agent_template: settings.template, agent_template_hash: settings.templateHash,
|
|
314
|
+
presentation,
|
|
257
315
|
...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
|
|
258
316
|
launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
|
|
259
317
|
} });
|
|
260
318
|
recordFleetAuditPresentation({ kind: 'agent_started', id: member.name, name: member.name,
|
|
261
319
|
lifetime: 'temporary', brain: selectionSummary(settings.definition.brain),
|
|
262
|
-
role: selectionSummary(settings.definition.role),
|
|
320
|
+
role: selectionSummary(settings.definition.role),
|
|
321
|
+
harness: presentation.harness, session: 'acp',
|
|
263
322
|
permissions: permissionSummary(settings.definition), parent: provision.roomId,
|
|
264
|
-
actionId: launched.creationActionId, inherited: []
|
|
323
|
+
actionId: launched.creationActionId, inherited: [],
|
|
324
|
+
configuration: presentation });
|
|
265
325
|
}
|
|
266
326
|
catch (error) {
|
|
267
327
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
@@ -313,6 +373,9 @@ function reconcileMemberSeats(roomId, members, observed) {
|
|
|
313
373
|
}
|
|
314
374
|
export async function provisionMembers(input) {
|
|
315
375
|
const { cfg, cowork, roomId, taskId, template } = input;
|
|
376
|
+
// Deletion-epoch pre-check; each member launch re-checks under the lock.
|
|
377
|
+
if (taskId && taskDeletionState(taskId) !== 'none')
|
|
378
|
+
throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
|
|
316
379
|
const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
|
|
317
380
|
const members = expandMembers(template, prefix);
|
|
318
381
|
// Resolve every Agent before persisting launch intent or touching Cowork membership.
|
|
@@ -371,13 +434,15 @@ export async function provisionMembers(input) {
|
|
|
371
434
|
.find(seat => seat.role_name === member.name);
|
|
372
435
|
if (currentSeat.seat_state === 'active') {
|
|
373
436
|
if (!await retainRunningLaunch({
|
|
374
|
-
provision: input, member, task, roomIdentityCid,
|
|
437
|
+
provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
|
|
375
438
|
})) {
|
|
376
439
|
throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
|
|
377
440
|
}
|
|
378
441
|
continue;
|
|
379
442
|
}
|
|
380
|
-
if (await retainRunningLaunch({
|
|
443
|
+
if (await retainRunningLaunch({
|
|
444
|
+
provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
|
|
445
|
+
}))
|
|
381
446
|
continue;
|
|
382
447
|
const issued = await cowork.issueInvite(roomId, {
|
|
383
448
|
mode: 'one_time', role: member.coworkRole, min_accepts: 1,
|
|
@@ -410,8 +475,17 @@ export async function provisionMembers(input) {
|
|
|
410
475
|
catch (error) {
|
|
411
476
|
const reason = error instanceof Error ? error.message : String(error);
|
|
412
477
|
setSagaError(roomId, reason, 'Member invite or launch failed. Retry with `task recover`.', 'member_failed');
|
|
413
|
-
if (taskId)
|
|
414
|
-
|
|
478
|
+
if (taskId) {
|
|
479
|
+
// A deletion-pending (or already-terminal) task rejects the block
|
|
480
|
+
// overlay; the original launch failure must still propagate.
|
|
481
|
+
try {
|
|
482
|
+
blockTask(taskId, reason);
|
|
483
|
+
}
|
|
484
|
+
catch (blockError) {
|
|
485
|
+
if (!(blockError instanceof TaskStateError))
|
|
486
|
+
throw blockError;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
415
489
|
throw error;
|
|
416
490
|
}
|
|
417
491
|
advanceSaga(roomId, 'wait_seats', 5);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent } from './types.js';
|
|
1
|
+
import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase } from './types.js';
|
|
2
2
|
export declare const tasksDir: () => string;
|
|
3
3
|
export declare class TaskStateError extends Error {
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Accepted deletion is the per-task epoch guard: once pending, every lifecycle
|
|
7
|
+
* mutation, terminal settlement, and room publication must fail boundedly.
|
|
8
|
+
*/
|
|
9
|
+
export declare function assertNoPendingDeletion(task: TaskRecord): void;
|
|
5
10
|
export interface CreateTaskInput {
|
|
6
11
|
title: string;
|
|
7
12
|
brief?: string;
|
|
@@ -21,6 +26,7 @@ export declare function updateTaskExecutionPlan(id: string, executionPlan: NonNu
|
|
|
21
26
|
export declare function listTasks(filter?: {
|
|
22
27
|
state?: TaskState | TaskState[];
|
|
23
28
|
listId?: string;
|
|
29
|
+
includeDeleting?: boolean;
|
|
24
30
|
}): TaskRecord[];
|
|
25
31
|
export declare function moveTaskToList(id: string, listId: string): TaskRecord;
|
|
26
32
|
export declare function findByIdempotencyKey(key: string): TaskRecord | undefined;
|
|
@@ -45,5 +51,109 @@ export declare function failTask(id: string, error: string): TaskRecord;
|
|
|
45
51
|
export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
|
|
46
52
|
export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
|
|
47
53
|
export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
|
|
48
|
-
/**
|
|
49
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Lenient read for deletion settlement and status surfaces: a broken list
|
|
56
|
+
* reference must never make a task unreadable or undeletable. Missing records
|
|
57
|
+
* throw the canonical task-not-found error; other read failures propagate.
|
|
58
|
+
*/
|
|
59
|
+
export declare function getDeletingTask(id: string): TaskRecord;
|
|
60
|
+
/** Cheap durable deletion-epoch probe for room/member publication guards. */
|
|
61
|
+
export declare function taskDeletionState(id: string): 'none' | 'pending' | 'absent';
|
|
62
|
+
/**
|
|
63
|
+
* Durable, surface-independent audit evidence for a permanent deletion. The
|
|
64
|
+
* receipt outlives the task record: written with the acceptance intent, and
|
|
65
|
+
* completed before settlement is reported. Metadata only — never brief or
|
|
66
|
+
* room content.
|
|
67
|
+
*/
|
|
68
|
+
export interface TaskDeletionReceipt {
|
|
69
|
+
schema_version: 1;
|
|
70
|
+
task_id: string;
|
|
71
|
+
title: string;
|
|
72
|
+
accepted_at: string;
|
|
73
|
+
actor: TaskDeletionActor;
|
|
74
|
+
original_state: TaskState;
|
|
75
|
+
room_id?: string;
|
|
76
|
+
member_count: number;
|
|
77
|
+
settled_at?: string;
|
|
78
|
+
result?: 'deleted';
|
|
79
|
+
}
|
|
80
|
+
export declare const deletionReceiptsDir: () => string;
|
|
81
|
+
export declare function readTaskDeletionReceipt(id: string): TaskDeletionReceipt | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Ensure the acceptance receipt exists before any cleanup side effect,
|
|
84
|
+
* backfilling it from the durable intent (heals a crash between the intent
|
|
85
|
+
* write and the receipt write). Fails closed: a receipt write error aborts
|
|
86
|
+
* settlement rather than deleting resources without audit evidence.
|
|
87
|
+
*/
|
|
88
|
+
export declare function ensureTaskDeletionReceipt(id: string): void;
|
|
89
|
+
/** Record settlement on the receipt; idempotent, tolerant of legacy absence. */
|
|
90
|
+
export declare function completeTaskDeletionReceipt(id: string): void;
|
|
91
|
+
export type TaskDeletionAcceptance = {
|
|
92
|
+
status: 'accepted' | 'pending';
|
|
93
|
+
task: TaskRecord;
|
|
94
|
+
} | {
|
|
95
|
+
status: 'already_absent';
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Persist the first-wins durable deletion intent. Accepts every lifecycle
|
|
99
|
+
* state, takes precedence over a pending terminal intent, and never touches
|
|
100
|
+
* remote resources. Repeat requests while pending re-arm settlement; a request
|
|
101
|
+
* for a missing record reports the idempotent already-absent outcome.
|
|
102
|
+
*
|
|
103
|
+
* Low-level record mutation only. Callers serialize acceptance with settlement
|
|
104
|
+
* through deletion.ts, which holds the common task-operation lock.
|
|
105
|
+
*/
|
|
106
|
+
export declare function beginTaskDeletionIntent(id: string, actor: TaskDeletionActor): TaskDeletionAcceptance;
|
|
107
|
+
/** Record an actionable settlement failure; the task stays hidden and recoverable. */
|
|
108
|
+
export declare function setTaskDeletionError(id: string, error: string, recoveryHint: string): TaskRecord;
|
|
109
|
+
/** Marker proving a member never launched, mirroring close.ts's short path. */
|
|
110
|
+
export declare const DELETION_MEMBER_NEVER_LAUNCHED = "never-launched";
|
|
111
|
+
/** Marker proving absence verified against temp state AND the CID-wide identity scan. */
|
|
112
|
+
export declare const DELETION_MEMBER_ABSENT_VERIFIED = "absent-verified";
|
|
113
|
+
/**
|
|
114
|
+
* Advance a member retirement cursor carried by the deletion intent. Only
|
|
115
|
+
* adjacent forward transitions are legal — plus the explicit
|
|
116
|
+
* pending → identity_absent short path proved by the 'never-launched' marker —
|
|
117
|
+
* so no managed agent can be claimed retired without the full evidence chain.
|
|
118
|
+
* launch_id and archive_path are immutable once recorded; same-phase retries
|
|
119
|
+
* are idempotent but must not change evidence.
|
|
120
|
+
*/
|
|
121
|
+
export declare function advanceTaskDeletionMember(id: string, name: string, phase: TaskDeletionMemberPhase, launchId?: string, archivePath?: string): TaskRecord;
|
|
122
|
+
interface SeatEvidence {
|
|
123
|
+
role_name: string;
|
|
124
|
+
identity_cid?: string;
|
|
125
|
+
retirement?: {
|
|
126
|
+
phase: TaskDeletionMemberPhase;
|
|
127
|
+
launch_id: string;
|
|
128
|
+
archive_path?: string;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Durably register cursors for late-provisioned members before retirement
|
|
133
|
+
* begins: provisioning publishes room seats before updateTaskMembers, so the
|
|
134
|
+
* acceptance snapshot can be empty while live seats exist. Seats without a
|
|
135
|
+
* recorded identity CID are not registered here — until a CID is recorded
|
|
136
|
+
* there is no managed identity to orphan, and the room record still carries
|
|
137
|
+
* those seats through the close saga that follows.
|
|
138
|
+
*/
|
|
139
|
+
export declare function upsertTaskDeletionMembersFromSeats(id: string, seats: ReadonlyArray<SeatEvidence>): TaskRecord;
|
|
140
|
+
/**
|
|
141
|
+
* The pre-room-delete checkpoint: import completed retirement evidence from
|
|
142
|
+
* room seats into the deletion cursors, so a crash between room-record
|
|
143
|
+
* deletion and task unlink retries from durable cursors instead of
|
|
144
|
+
* reconstructing consumed evidence. Call ONLY after closeManagedRoom has
|
|
145
|
+
* completed retirement and BEFORE cowork.deleteRoom/deleteRoomRecord.
|
|
146
|
+
*
|
|
147
|
+
* The room saga is trusted for phase jumps, but partial or corrupt retained
|
|
148
|
+
* records must not become false success: every seat must be identity_absent;
|
|
149
|
+
* a real launch requires archive evidence; an identity-less seat is accepted
|
|
150
|
+
* only via the never-launched proof.
|
|
151
|
+
*/
|
|
152
|
+
export declare function importTaskDeletionRetirementEvidence(id: string, seats: ReadonlyArray<SeatEvidence>): TaskRecord;
|
|
153
|
+
/**
|
|
154
|
+
* Physically remove a deletion-pending task record. Settlement-only: callers
|
|
155
|
+
* must have completed member retirement and room cleanup first. Missing
|
|
156
|
+
* records are the idempotent already-settled outcome.
|
|
157
|
+
*/
|
|
158
|
+
export declare function unlinkDeletedTask(id: string): boolean;
|
|
159
|
+
export {};
|