@ours.network/fleet 1.1.0-nightly.12 → 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.
@@ -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 = [];
@@ -139,7 +164,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
139
164
  }
140
165
  }
141
166
  async function retainRunningLaunch(input) {
142
- const { provision, member, task, roomIdentityCid } = input;
167
+ const { provision, member, settings, task, roomIdentityCid } = input;
143
168
  let seat = getRoomRecord(provision.roomId).member_seats
144
169
  .find(candidate => candidate.role_name === member.name);
145
170
  const dir = agentDir(member.name, true);
@@ -171,9 +196,14 @@ async function retainRunningLaunch(input) {
171
196
  throw new Error(`existing launch for ${member.name} has unknown liveness`);
172
197
  const retainedLaunch = seat.launch;
173
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);
174
204
  updateMemberStartup(provision.roomId, member.name, { launch: {
175
205
  ...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
176
- updated_at: new Date().toISOString(),
206
+ presentation, updated_at: new Date().toISOString(),
177
207
  } });
178
208
  return true;
179
209
  }
@@ -210,7 +240,25 @@ async function retainRunningLaunch(input) {
210
240
  }
211
241
  return false;
212
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
+ */
213
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) {
214
262
  const { provision, member, settings, startup } = input;
215
263
  const seat = getRoomRecord(provision.roomId).member_seats
216
264
  .find(candidate => candidate.role_name === member.name);
@@ -255,18 +303,25 @@ async function launchMember(input) {
255
303
  if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
256
304
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
257
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);
258
310
  updateMemberStartup(provision.roomId, member.name, { launch: {
259
311
  state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
260
312
  agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
261
313
  agent_template: settings.template, agent_template_hash: settings.templateHash,
314
+ presentation,
262
315
  ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
263
316
  launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
264
317
  } });
265
318
  recordFleetAuditPresentation({ kind: 'agent_started', id: member.name, name: member.name,
266
319
  lifetime: 'temporary', brain: selectionSummary(settings.definition.brain),
267
- role: selectionSummary(settings.definition.role), harness: 'resolved', session: 'acp',
320
+ role: selectionSummary(settings.definition.role),
321
+ harness: presentation.harness, session: 'acp',
268
322
  permissions: permissionSummary(settings.definition), parent: provision.roomId,
269
- actionId: launched.creationActionId, inherited: [] });
323
+ actionId: launched.creationActionId, inherited: [],
324
+ configuration: presentation });
270
325
  }
271
326
  catch (error) {
272
327
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -318,6 +373,9 @@ function reconcileMemberSeats(roomId, members, observed) {
318
373
  }
319
374
  export async function provisionMembers(input) {
320
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`);
321
379
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
322
380
  const members = expandMembers(template, prefix);
323
381
  // Resolve every Agent before persisting launch intent or touching Cowork membership.
@@ -376,13 +434,15 @@ export async function provisionMembers(input) {
376
434
  .find(seat => seat.role_name === member.name);
377
435
  if (currentSeat.seat_state === 'active') {
378
436
  if (!await retainRunningLaunch({
379
- provision: input, member, task, roomIdentityCid,
437
+ provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
380
438
  })) {
381
439
  throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
382
440
  }
383
441
  continue;
384
442
  }
385
- if (await retainRunningLaunch({ provision: input, member, task, roomIdentityCid }))
443
+ if (await retainRunningLaunch({
444
+ provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
445
+ }))
386
446
  continue;
387
447
  const issued = await cowork.issueInvite(roomId, {
388
448
  mode: 'one_time', role: member.coworkRole, min_accepts: 1,
@@ -415,8 +475,17 @@ export async function provisionMembers(input) {
415
475
  catch (error) {
416
476
  const reason = error instanceof Error ? error.message : String(error);
417
477
  setSagaError(roomId, reason, 'Member invite or launch failed. Retry with `task recover`.', 'member_failed');
418
- if (taskId)
419
- blockTask(taskId, reason);
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
+ }
420
489
  throw error;
421
490
  }
422
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
- /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
49
- export declare function deleteTask(id: string): boolean;
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 {};