@ours.network/fleet 1.2.0-nightly.13 → 1.2.0-nightly.14
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/task-room-service.d.ts +3 -0
- package/dist/application/task-room-service.js +1 -1
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/build-info.json +4 -4
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +1 -0
- package/dist/fleet-command-audit.js +9 -5
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/rooms-tasks/close.d.ts +2 -2
- package/dist/rooms-tasks/close.js +128 -22
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +41 -9
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/room-state.d.ts +3 -1
- package/dist/rooms-tasks/room-state.js +37 -7
- package/dist/rooms-tasks/task-state.d.ts +10 -4
- package/dist/rooms-tasks/task-state.js +54 -12
- package/dist/rooms-tasks/types.d.ts +2 -0
- package/dist/temp-lifecycle.d.ts +5 -0
- package/dist/temp-lifecycle.js +28 -12
- package/docs/task-workspaces.md +34 -2
- package/package.json +1 -1
|
@@ -638,7 +638,7 @@ export class TaskRoomApplicationService {
|
|
|
638
638
|
createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
|
|
639
639
|
const room = getRoomRecord(input.roomId);
|
|
640
640
|
if (!room) {
|
|
641
|
-
|
|
641
|
+
return { room: undefined, settlementRequired: true };
|
|
642
642
|
}
|
|
643
643
|
return { room: await acceptManagedRoomClose(input.roomId), settlementRequired: true };
|
|
644
644
|
}
|
package/dist/atomic-file.d.ts
CHANGED
|
@@ -36,3 +36,8 @@ export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<
|
|
|
36
36
|
* intact.
|
|
37
37
|
*/
|
|
38
38
|
export declare function replaceFileAtomically(path: string, contents: string, mode?: number, deps?: WriteDeps): void;
|
|
39
|
+
/** Synchronous Lamport bakery lock: unique claims are never reused, so stale
|
|
40
|
+
* reclamation cannot remove a successor's lock. Atomic claim replacement keeps
|
|
41
|
+
* choosing/ticket reads complete. Dead claims can be removed by any contender.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withSynchronousFileLock<T>(path: string, work: () => T): T;
|
package/dist/atomic-file.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
const DEFAULT_STALE_MS = 10_000;
|
|
@@ -136,3 +136,64 @@ export function replaceFileAtomically(path, contents, mode = 0o600, deps = {}) {
|
|
|
136
136
|
}
|
|
137
137
|
catch { /* platform does not allow fsync on a directory */ }
|
|
138
138
|
}
|
|
139
|
+
/** Synchronous Lamport bakery lock: unique claims are never reused, so stale
|
|
140
|
+
* reclamation cannot remove a successor's lock. Atomic claim replacement keeps
|
|
141
|
+
* choosing/ticket reads complete. Dead claims can be removed by any contender.
|
|
142
|
+
*/
|
|
143
|
+
export function withSynchronousFileLock(path, work) {
|
|
144
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
145
|
+
const token = `${process.pid}-${randomUUID()}.json`;
|
|
146
|
+
const own = join(path, token);
|
|
147
|
+
const deadline = Date.now() + 10_000;
|
|
148
|
+
const claims = () => {
|
|
149
|
+
const result = [];
|
|
150
|
+
for (const name of readdirSync(path).filter(name => /^[0-9]+-[a-f0-9-]{36}\.json$/.test(name))) {
|
|
151
|
+
const file = join(path, name);
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = JSON.parse(readFileSync(file, 'utf8'));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error.code === 'ENOENT')
|
|
158
|
+
continue;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || !Number.isSafeInteger(value.ticket) || value.ticket < 0)
|
|
162
|
+
throw new Error('Invalid synchronous lock claim');
|
|
163
|
+
if (name !== token) {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(value.pid, 0);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error.code === 'ESRCH') {
|
|
169
|
+
// This dead contender alone owned this UUID path. Never remove
|
|
170
|
+
// the namespace or a successor's differently named claim.
|
|
171
|
+
rmSync(file, { force: true });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
result.push({ name, value });
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
};
|
|
180
|
+
replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: true, ticket: 0 }));
|
|
181
|
+
try {
|
|
182
|
+
const ticket = Math.max(0, ...claims().map(claim => claim.value.ticket)) + 1;
|
|
183
|
+
if (!Number.isSafeInteger(ticket))
|
|
184
|
+
throw new Error('Synchronous lock ticket overflow');
|
|
185
|
+
replaceFileAtomically(own, JSON.stringify({ pid: process.pid, choosing: false, ticket }));
|
|
186
|
+
for (;;) {
|
|
187
|
+
const preceding = claims().some(claim => claim.name !== token && (claim.value.choosing
|
|
188
|
+
|| claim.value.ticket < ticket || (claim.value.ticket === ticket && claim.name < token)));
|
|
189
|
+
if (!preceding)
|
|
190
|
+
return work();
|
|
191
|
+
if (Date.now() >= deadline)
|
|
192
|
+
throw new Error('Timed out acquiring synchronous journal lock');
|
|
193
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
rmSync(own, { force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.14",
|
|
3
|
+
"buildId": "b88d0d158cc7",
|
|
4
|
+
"commit": "717f6d8a09e6173b9b8a017fc5efb08d71927524",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-24T14:15:57.206Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"cowork.http-management-v1",
|
|
9
9
|
"monitor.interrupt.after_tool"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const erasedArg: (value: string) => string;
|
|
2
|
+
export declare function redactErasedContent<T>(value: T): T;
|
|
3
|
+
/** Every cooperative writer filters stale in-memory snapshots under the same lock as erasure. */
|
|
4
|
+
export declare function writePrivacyFilteredLedger(path: string, value: unknown): void;
|
|
5
|
+
/** Content-free resource IDs persist to prevent active writers resurrecting erased labels. */
|
|
6
|
+
export declare function eraseResourcePresentations(resources: ReadonlyArray<{
|
|
7
|
+
kind: 'task' | 'room' | 'agent';
|
|
8
|
+
id: string;
|
|
9
|
+
}>): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
|
|
5
|
+
import { stateRoot } from './paths.js';
|
|
6
|
+
const registryPath = () => join(stateRoot(), 'erased-resources.json');
|
|
7
|
+
const lockPath = () => join(stateRoot(), 'locks', 'audit-erasure');
|
|
8
|
+
const ledgerNames = ['.fleet-command-audit.json', '.owner-channel-command-audit.json', '.owner-channel-lifecycle-outbox.json'];
|
|
9
|
+
export const erasedArg = (value) => `[erased:${createHash('sha256').update(value).digest('hex')}]`;
|
|
10
|
+
function registry() {
|
|
11
|
+
if (!existsSync(registryPath()))
|
|
12
|
+
return new Set();
|
|
13
|
+
const value = JSON.parse(readFileSync(registryPath(), 'utf8'));
|
|
14
|
+
if (!Array.isArray(value) || value.some(v => typeof v !== 'string'))
|
|
15
|
+
throw new Error('Invalid erasure registry');
|
|
16
|
+
return new Set(value);
|
|
17
|
+
}
|
|
18
|
+
/** Preserve delivery/deduplication metadata while removing owned presentation content. */
|
|
19
|
+
function redact(value, erased) {
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.map(item => redact(item, erased));
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return value;
|
|
24
|
+
const next = { ...value };
|
|
25
|
+
const kind = value.kind === 'lifecycle_failure' ? value.resource?.toLowerCase() : value.kind === 'agent_started' ? 'agent' : value.kind;
|
|
26
|
+
if (typeof value.id === 'string' && erased.has(`${kind}:${value.id}`)) {
|
|
27
|
+
for (const key of ['title', 'reason', 'roomName', 'label', 'template', 'configuration', 'model', 'permissions'])
|
|
28
|
+
delete next[key];
|
|
29
|
+
if (kind === 'task')
|
|
30
|
+
next.agents = [];
|
|
31
|
+
if (kind === 'room') {
|
|
32
|
+
delete next.name;
|
|
33
|
+
next.participants = [];
|
|
34
|
+
}
|
|
35
|
+
if (kind === 'agent') {
|
|
36
|
+
next.name = 'erased';
|
|
37
|
+
next.brain = '[erased]';
|
|
38
|
+
next.role = '[erased]';
|
|
39
|
+
next.inherited = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (typeof value.roomId === 'string' && erased.has(`room:${value.roomId}`)) {
|
|
43
|
+
delete next.roomName;
|
|
44
|
+
if (kind === 'task')
|
|
45
|
+
next.agents = [];
|
|
46
|
+
}
|
|
47
|
+
const resources = value.outcome?.resourceIds;
|
|
48
|
+
const ownsAttempt = resources && Object.entries(resources).some(([key, id]) => erased.has(`${key}:${id}`));
|
|
49
|
+
const ownsPresentation = value.outcome?.presentations?.some((p) => erased.has(`${p.kind === 'agent_started' ? 'agent' : p.kind}:${p.id}`));
|
|
50
|
+
if ((ownsAttempt || ownsPresentation) && Array.isArray(value.argv) && !value.erased) {
|
|
51
|
+
next.argv = value.argv.map(erasedArg);
|
|
52
|
+
next.erased = true;
|
|
53
|
+
}
|
|
54
|
+
for (const key of Object.keys(next))
|
|
55
|
+
if (typeof next[key] === 'object')
|
|
56
|
+
next[key] = redact(next[key], erased);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
export function redactErasedContent(value) { return redact(value, registry()); }
|
|
60
|
+
/** Every cooperative writer filters stale in-memory snapshots under the same lock as erasure. */
|
|
61
|
+
export function writePrivacyFilteredLedger(path, value) {
|
|
62
|
+
withSynchronousFileLock(lockPath(), () => replaceFileAtomically(path, JSON.stringify(redact(value, registry())) + '\n'));
|
|
63
|
+
}
|
|
64
|
+
/** Content-free resource IDs persist to prevent active writers resurrecting erased labels. */
|
|
65
|
+
export function eraseResourcePresentations(resources) {
|
|
66
|
+
withSynchronousFileLock(lockPath(), () => {
|
|
67
|
+
const erased = registry();
|
|
68
|
+
for (const resource of resources)
|
|
69
|
+
erased.add(`${resource.kind}:${resource.id}`);
|
|
70
|
+
replaceFileAtomically(registryPath(), JSON.stringify([...erased]));
|
|
71
|
+
for (const root of [join(stateRoot(), 'agents'), join(stateRoot(), 'tmp'), join(stateRoot(), 'recovery', 'temporary')]) {
|
|
72
|
+
if (!existsSync(root))
|
|
73
|
+
continue;
|
|
74
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
75
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
76
|
+
continue;
|
|
77
|
+
for (const name of ledgerNames) {
|
|
78
|
+
const path = join(root, entry.name, name);
|
|
79
|
+
if (!existsSync(path) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())
|
|
80
|
+
continue;
|
|
81
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
replaceFileAtomically(path, JSON.stringify(redact(old, erased)) + '\n');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { erasedArg, redactErasedContent, writePrivacyFilteredLedger } from './erased-resources.js';
|
|
1
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { Buffer } from 'node:buffer';
|
|
4
|
-
import { replaceFileAtomically } from './atomic-file.js';
|
|
5
5
|
import { isSensitiveConfigKey } from './sensitive-config.js';
|
|
6
6
|
import { mandatoryConfigurationFits } from './lifecycle-summary.js';
|
|
7
7
|
import { MARKDOWN_MAX_BYTES, MARKDOWN_MAX_CODE_POINTS, markdownCode, markdownProse, } from './rooms-tasks/markdown.js';
|
|
@@ -91,7 +91,7 @@ export function recordFleetAuditPresentation(value) {
|
|
|
91
91
|
: value.kind === 'lifecycle_failure'
|
|
92
92
|
? { label: fleetPresentationLabel(value.label) }
|
|
93
93
|
: {};
|
|
94
|
-
const presentation = structuredClone({ ...value, ...labels, eventId: basis });
|
|
94
|
+
const presentation = redactErasedContent(structuredClone({ ...value, ...labels, eventId: basis }));
|
|
95
95
|
const digest = lifecycleEventDigestBasis(presentation);
|
|
96
96
|
if (!collection.presentations.some(existing => lifecycleEventDigestBasis(existing) === digest))
|
|
97
97
|
collection.presentations.push(presentation);
|
|
@@ -305,11 +305,12 @@ export class FleetCommandAuditStore {
|
|
|
305
305
|
if (recovered)
|
|
306
306
|
this.persist();
|
|
307
307
|
}
|
|
308
|
-
list() { return this.attempts.map(item => structuredClone(item)); }
|
|
308
|
+
list() { return redactErasedContent(this.attempts).map(item => structuredClone(item)); }
|
|
309
309
|
begin(requestId, caller, argv) {
|
|
310
|
+
this.attempts = redactErasedContent(this.attempts);
|
|
310
311
|
const existing = this.attempts.find(item => item.caller === caller && item.requestId === requestId);
|
|
311
312
|
if (existing) {
|
|
312
|
-
if (JSON.stringify(existing.argv) !== JSON.stringify(redactFleetArgv(argv)))
|
|
313
|
+
if (JSON.stringify(existing.argv) !== JSON.stringify(existing.erased ? redactFleetArgv(argv).map(erasedArg) : redactFleetArgv(argv)))
|
|
313
314
|
throw new Error('fleet command request ID was reused with different argv');
|
|
314
315
|
return structuredClone(existing);
|
|
315
316
|
}
|
|
@@ -329,6 +330,8 @@ export class FleetCommandAuditStore {
|
|
|
329
330
|
return structuredClone(attempt);
|
|
330
331
|
}
|
|
331
332
|
finish(correlationId, caller, outcome) {
|
|
333
|
+
this.attempts = redactErasedContent(this.attempts);
|
|
334
|
+
outcome = redactErasedContent({ outcome }).outcome;
|
|
332
335
|
const attempt = this.owned(correlationId, caller);
|
|
333
336
|
if (!attempt.outcome)
|
|
334
337
|
attempt.outcome = { ...outcome, completedAt: this.deps.now().toISOString(), delivery: 'sending' };
|
|
@@ -360,7 +363,8 @@ export class FleetCommandAuditStore {
|
|
|
360
363
|
return attempt;
|
|
361
364
|
}
|
|
362
365
|
persist() {
|
|
363
|
-
|
|
366
|
+
this.attempts = redactErasedContent(this.attempts);
|
|
367
|
+
writePrivacyFilteredLedger(this.path, { version: 1, attempts: this.attempts });
|
|
364
368
|
}
|
|
365
369
|
}
|
|
366
370
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { redactErasedContent, writePrivacyFilteredLedger } from '../erased-resources.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
4
|
import { lifecycleEventDigestBasis, validateFleetAuditPresentations, } from '../fleet-command-audit.js';
|
|
5
5
|
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
6
6
|
const LIMIT = 5_000;
|
|
@@ -63,7 +63,7 @@ export class FleetLifecycleOutbox {
|
|
|
63
63
|
}
|
|
64
64
|
pending() {
|
|
65
65
|
this.assertHealthy();
|
|
66
|
-
return this.entries.filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
|
|
66
|
+
return redactErasedContent(this.entries).filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
|
|
67
67
|
}
|
|
68
68
|
finish(digest, delivery) {
|
|
69
69
|
this.assertHealthy();
|
|
@@ -82,6 +82,7 @@ export class FleetLifecycleOutbox {
|
|
|
82
82
|
throw new Error(this.corruptReason);
|
|
83
83
|
}
|
|
84
84
|
persist() {
|
|
85
|
-
|
|
85
|
+
this.entries = redactErasedContent(this.entries);
|
|
86
|
+
writePrivacyFilteredLedger(this.path, { version: 1, entries: this.entries });
|
|
86
87
|
}
|
|
87
88
|
}
|
|
@@ -27,7 +27,7 @@ export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrch
|
|
|
27
27
|
export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
|
|
28
28
|
export declare function closeManagedRoom(input: {
|
|
29
29
|
roomId: string;
|
|
30
|
-
cowork: Pick<CoworkAdapter, 'closeRoom'
|
|
30
|
+
cowork: Pick<CoworkAdapter, 'closeRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
|
|
31
31
|
deps?: RoomCloseDeps;
|
|
32
32
|
}): Promise<RoomOrchestrationRecord>;
|
|
33
33
|
export interface ManagedRoomDeleteResult {
|
|
@@ -41,6 +41,6 @@ export declare function deleteLegacyClosedRooms(input: {
|
|
|
41
41
|
/** Retire live resources through the existing cursor, then delete retained state. */
|
|
42
42
|
export declare function deleteManagedRoom(input: {
|
|
43
43
|
roomId: string;
|
|
44
|
-
cowork: Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'
|
|
44
|
+
cowork: Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
|
|
45
45
|
deps?: RoomCloseDeps;
|
|
46
46
|
}): Promise<ManagedRoomDeleteResult>;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { detachDeletedRoom } from './task-state.js';
|
|
2
|
+
import { binderKey } from '../agent-ours/state.js';
|
|
3
|
+
import { eraseMemberArtifacts } from './erasure.js';
|
|
1
4
|
import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
|
|
2
5
|
import { collectWorkspaceArchives } from './workspace-artifacts.js';
|
|
3
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -9,7 +12,7 @@ import { agentDir, stateRoot } from '../paths.js';
|
|
|
9
12
|
import { readClientProfile } from '../client-profile.js';
|
|
10
13
|
import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, tempArchiveForLaunch, tempArchiveForCreationAction, } from '../temp-lifecycle.js';
|
|
11
14
|
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
12
|
-
import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
|
|
15
|
+
import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, recordRetirementMemberCids, } from './room-state.js';
|
|
13
16
|
export const CLOSE_LOCK_STALE_MS = 5 * 60_000;
|
|
14
17
|
const STOP_POLLS = 50;
|
|
15
18
|
const STOP_POLL_MS = 100;
|
|
@@ -22,6 +25,20 @@ function errorText(error) {
|
|
|
22
25
|
function exactMemberIdentity(seat) {
|
|
23
26
|
const dir = agentDir(seat.role_name, true);
|
|
24
27
|
const identityPath = join(dir, '.identity');
|
|
28
|
+
if (existsSync(dir) && !existsSync(identityPath) && seat.launch?.action_id && seat.launch.launch_id) {
|
|
29
|
+
const supervisor = readTempSupervisor(dir);
|
|
30
|
+
let creation;
|
|
31
|
+
try {
|
|
32
|
+
creation = JSON.parse(readFileSync(join(dir, 'creation.json'), 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== 'ENOENT')
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (supervisor?.role === seat.role_name && supervisor.launchId === seat.launch.launch_id
|
|
39
|
+
&& creation?.role === seat.role_name && creation.creationActionId === seat.launch.action_id)
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
25
42
|
if (!existsSync(dir) || !existsSync(identityPath)) {
|
|
26
43
|
throw new Error(`room member '${seat.role_name}' has no live Fleet temp-state identity proof; refusing retirement`);
|
|
27
44
|
}
|
|
@@ -33,7 +50,8 @@ function exactMemberIdentity(seat) {
|
|
|
33
50
|
export async function inspectMember(seat) {
|
|
34
51
|
exactMemberIdentity(seat);
|
|
35
52
|
const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
|
|
36
|
-
if (!supervisor || supervisor.role !== seat.role_name
|
|
53
|
+
if (!supervisor || supervisor.role !== seat.role_name
|
|
54
|
+
|| (seat.launch?.launch_id && supervisor.launchId !== seat.launch.launch_id)) {
|
|
37
55
|
throw new Error(`room member '${seat.role_name}' has no exact Fleet supervisor ownership proof`);
|
|
38
56
|
}
|
|
39
57
|
return { launchId: supervisor.launchId };
|
|
@@ -93,14 +111,19 @@ export async function identityCidPresent(cid) {
|
|
|
93
111
|
}
|
|
94
112
|
export async function removeExactMemberIdentity(seat) {
|
|
95
113
|
await withIdentityClient(async (client) => {
|
|
96
|
-
const
|
|
97
|
-
|
|
114
|
+
const rows = await client.listIdentities();
|
|
115
|
+
const before = listedIdentity(rows, seat.role_name);
|
|
116
|
+
if (!before) {
|
|
117
|
+
if (rows.some(row => row.name === seat.role_name || (seat.identity_cid && 'cid' in row
|
|
118
|
+
&& row.cid.toLowerCase() === seat.identity_cid.toLowerCase())))
|
|
119
|
+
throw new Error(`room member '${seat.role_name}' identity absence is not proven`);
|
|
98
120
|
return;
|
|
121
|
+
}
|
|
99
122
|
if (!seat.identity_cid) {
|
|
100
|
-
throw new Error(`room member '${seat.role_name}' exists without a recorded authenticated CID; refusing removal`);
|
|
123
|
+
throw new Error(`room member '${seat.role_name}' exists without a recorded authenticated CID; identity absence is not proven; refusing removal`);
|
|
101
124
|
}
|
|
102
125
|
if (before.cid?.toLowerCase() !== seat.identity_cid.toLowerCase()) {
|
|
103
|
-
throw new Error(`room member '${seat.role_name}' identity CID mismatch: recorded ${seat.identity_cid}, found ${before.cid ?? 'none'}`);
|
|
126
|
+
throw new Error(`room member '${seat.role_name}' identity absence is not proven: CID mismatch: recorded ${seat.identity_cid}, found ${before.cid ?? 'none'}`);
|
|
104
127
|
}
|
|
105
128
|
try {
|
|
106
129
|
await client.removeIdentity({ name: seat.role_name });
|
|
@@ -115,8 +138,9 @@ export async function removeExactMemberIdentity(seat) {
|
|
|
115
138
|
if (after)
|
|
116
139
|
throw error;
|
|
117
140
|
}
|
|
118
|
-
const after =
|
|
119
|
-
if (after
|
|
141
|
+
const after = await client.listIdentities();
|
|
142
|
+
if (after.some(row => row.name === seat.role_name || (seat.identity_cid && 'cid' in row
|
|
143
|
+
&& row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
|
|
120
144
|
throw new Error(`room member '${seat.role_name}' identity still exists after remove_identity`);
|
|
121
145
|
}
|
|
122
146
|
});
|
|
@@ -135,9 +159,9 @@ async function retireMember(roomId, seat, deps) {
|
|
|
135
159
|
let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
|
|
136
160
|
let retirement = current.retirement;
|
|
137
161
|
if (retirement?.phase === 'identity_absent') {
|
|
138
|
-
if (
|
|
162
|
+
if (!deps.inspectMember) {
|
|
139
163
|
if (existsSync(agentDir(current.role_name, true)))
|
|
140
|
-
throw new Error(
|
|
164
|
+
throw new Error('Retired member has replacement launch live state');
|
|
141
165
|
await assertMemberIdentityAbsent(current);
|
|
142
166
|
}
|
|
143
167
|
return;
|
|
@@ -146,6 +170,8 @@ async function retireMember(roomId, seat, deps) {
|
|
|
146
170
|
if (!existsSync(agentDir(current.role_name, true)) && current.launch?.launch_id && current.launch.action_id) {
|
|
147
171
|
const archived = tempArchiveForLaunch(current.role_name, current.launch.launch_id);
|
|
148
172
|
const created = tempArchiveForCreationAction(current.role_name, current.launch.action_id);
|
|
173
|
+
if (archived && (!created || created.path !== archived || created.launchId !== current.launch.launch_id))
|
|
174
|
+
throw new Error('Archived member creation ownership mismatch');
|
|
149
175
|
if (archived && created?.path === archived && created.launchId === current.launch.launch_id) {
|
|
150
176
|
if (readFileSync(join(archived, '.identity'), 'utf8').trim() !== current.role_name)
|
|
151
177
|
throw new Error(`room member '${current.role_name}' archive identity mismatch`);
|
|
@@ -153,6 +179,7 @@ async function retireMember(roomId, seat, deps) {
|
|
|
153
179
|
throw new Error(`room member '${current.role_name}' archived supervisor is not proven stopped`);
|
|
154
180
|
// A terminated launch can archive itself before room retirement begins.
|
|
155
181
|
// Accept its exact durable provenance only when no identity needs removal.
|
|
182
|
+
await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
|
|
156
183
|
await assertMemberIdentityAbsent(current);
|
|
157
184
|
const latest = getRoomRecord(roomId)?.member_seats.find(seat => seat.role_name === current.role_name);
|
|
158
185
|
if (existsSync(agentDir(current.role_name, true))
|
|
@@ -164,14 +191,6 @@ async function retireMember(roomId, seat, deps) {
|
|
|
164
191
|
return;
|
|
165
192
|
}
|
|
166
193
|
}
|
|
167
|
-
if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
|
|
168
|
-
// A failure before applyRole (for example invite-secret validation) has
|
|
169
|
-
// no supervisor to stop or archive. Settle only proven absence; this
|
|
170
|
-
// path never removes an identity based on missing local evidence.
|
|
171
|
-
await assertMemberIdentityAbsent(current);
|
|
172
|
-
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
194
|
if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
|
|
176
195
|
if (existsSync(agentDir(current.role_name, true))) {
|
|
177
196
|
throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
|
|
@@ -180,12 +199,31 @@ async function retireMember(roomId, seat, deps) {
|
|
|
180
199
|
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'never-launched');
|
|
181
200
|
return;
|
|
182
201
|
}
|
|
202
|
+
if (!deps.inspectMember && !existsSync(agentDir(current.role_name, true))) {
|
|
203
|
+
await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
|
|
204
|
+
if (existsSync(agentDir(current.role_name, true)))
|
|
205
|
+
throw new Error(`room member '${current.role_name}' acquired replacement state during retirement`);
|
|
206
|
+
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'absent-verified');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
183
209
|
const ownership = await (deps.inspectMember ?? inspectMember)(current);
|
|
184
210
|
room = advanceMemberRetirement(roomId, current.role_name, 'stop_requested', ownership.launchId);
|
|
185
211
|
current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
|
|
186
212
|
retirement = current.retirement;
|
|
187
213
|
}
|
|
214
|
+
if (['stop_requested', 'liveness_absent', 'archive_secured'].includes(retirement.phase) && !deps.requestStop
|
|
215
|
+
&& !existsSync(agentDir(current.role_name, true))) {
|
|
216
|
+
const archive = tempArchiveForLaunch(current.role_name, retirement.launch_id);
|
|
217
|
+
if (archive && await tempSupervisorLiveness(archive) !== 'stopped')
|
|
218
|
+
throw new Error('Archived supervisor is not proven stopped');
|
|
219
|
+
await (deps.removeIdentity ?? removeExactMemberIdentity)(current);
|
|
220
|
+
await assertMemberIdentityAbsent(current);
|
|
221
|
+
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', retirement.launch_id, archive, !archive);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
188
224
|
if (retirement.phase === 'stop_requested') {
|
|
225
|
+
if (!deps.inspectMember && (await inspectMember(current)).launchId !== retirement.launch_id)
|
|
226
|
+
throw new Error('Member launch changed before stop');
|
|
189
227
|
await (deps.requestStop ?? (async (role) => { await stopTempSupervisor(role); }))(current.role_name);
|
|
190
228
|
await (deps.waitForLivenessAbsent ?? waitForLivenessAbsent)(current.role_name, retirement.launch_id);
|
|
191
229
|
room = advanceMemberRetirement(roomId, current.role_name, 'liveness_absent', retirement.launch_id);
|
|
@@ -215,17 +253,31 @@ export async function closeManagedRoom(input) {
|
|
|
215
253
|
const lock = deps.lock ?? withFileLock;
|
|
216
254
|
return lock(roomCloseLockPath(input.roomId), async () => {
|
|
217
255
|
let room = beginRoomClose(input.roomId);
|
|
218
|
-
if (room.state === 'closed')
|
|
256
|
+
if (room.state === 'closed') {
|
|
257
|
+
if (!deps.inspectMember)
|
|
258
|
+
for (const seat of room.member_seats) {
|
|
259
|
+
if (existsSync(agentDir(seat.role_name, true)))
|
|
260
|
+
throw new Error('Closed room has replacement live state');
|
|
261
|
+
await assertMemberIdentityAbsent(seat);
|
|
262
|
+
}
|
|
219
263
|
return room;
|
|
264
|
+
}
|
|
220
265
|
try {
|
|
221
266
|
if (room.close?.phase === 'retire_members') {
|
|
267
|
+
room = await recoverMemberCids(room, input.cowork);
|
|
222
268
|
for (const seat of room.member_seats) {
|
|
223
269
|
await retireMember(input.roomId, seat, deps);
|
|
224
270
|
}
|
|
225
271
|
room = advanceRoomClose(input.roomId, 'close_cowork');
|
|
226
272
|
}
|
|
227
273
|
if (room.close?.phase === 'close_cowork') {
|
|
228
|
-
|
|
274
|
+
try {
|
|
275
|
+
await input.cowork.closeRoom(input.roomId);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
229
281
|
}
|
|
230
282
|
return closeRoom(input.roomId);
|
|
231
283
|
}
|
|
@@ -258,7 +310,8 @@ export async function deleteLegacyClosedRooms(input) {
|
|
|
258
310
|
}
|
|
259
311
|
/** Retire live resources through the existing cursor, then delete retained state. */
|
|
260
312
|
export async function deleteManagedRoom(input) {
|
|
261
|
-
|
|
313
|
+
if (getRoomRecord(input.roomId))
|
|
314
|
+
await closeManagedRoom(input);
|
|
262
315
|
return withFileLock(roomCloseLockPath(input.roomId), async () => {
|
|
263
316
|
try {
|
|
264
317
|
try {
|
|
@@ -276,12 +329,65 @@ export async function deleteManagedRoom(input) {
|
|
|
276
329
|
collectWorkspaceArchives(retained.workspace);
|
|
277
330
|
deleteWorkspace(retained.workspace, 'room', input.roomId);
|
|
278
331
|
}
|
|
332
|
+
if (retained)
|
|
333
|
+
await eraseMemberArtifacts('room', input.roomId, retained.member_seats, [input.roomId]);
|
|
334
|
+
if (retained?.task_id)
|
|
335
|
+
detachDeletedRoom(retained.task_id, input.roomId);
|
|
279
336
|
deleteRoomRecord(input.roomId);
|
|
280
337
|
return { room_id: input.roomId, deleted: true };
|
|
281
338
|
}
|
|
282
339
|
catch (error) {
|
|
283
|
-
|
|
340
|
+
if (getRoomRecord(input.roomId))
|
|
341
|
+
setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
|
|
284
342
|
throw error;
|
|
285
343
|
}
|
|
286
344
|
}, {}, CLOSE_LOCK_STALE_MS);
|
|
287
345
|
}
|
|
346
|
+
/** Recover only an authenticated seat tied to this exact room and issued invite.
|
|
347
|
+
* Display names and the current daemon name inventory are never binding proof.
|
|
348
|
+
*/
|
|
349
|
+
async function recoverMemberCids(room, cowork) {
|
|
350
|
+
if (!room.member_seats.some(seat => !seat.identity_cid))
|
|
351
|
+
return room;
|
|
352
|
+
const profile = room.member_seats.some(seat => seat.launch?.action_id) ? readClientProfile(process.env) : undefined;
|
|
353
|
+
let remote;
|
|
354
|
+
if (cowork.getRoom && room.room_identity_cid) {
|
|
355
|
+
try {
|
|
356
|
+
remote = await cowork.getRoom(room.room_id);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
if (remote && (remote.room_id !== room.room_id || remote.identity_cid.toLowerCase() !== room.room_identity_cid.toLowerCase()))
|
|
363
|
+
throw new Error('Room identity mismatch during deletion recovery');
|
|
364
|
+
}
|
|
365
|
+
const seats = room.member_seats.map(seat => {
|
|
366
|
+
let runtimeCid;
|
|
367
|
+
if (profile && seat.launch?.action_id) {
|
|
368
|
+
const dir = join(stateRoot(), 'private-ours', binderKey(profile.expectedInstanceId, seat.role_name));
|
|
369
|
+
try {
|
|
370
|
+
const state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'));
|
|
371
|
+
const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
|
|
372
|
+
if (state.name === seat.role_name && state.daemon === profile.expectedInstanceId
|
|
373
|
+
&& state.lifetime === 'temporary' && state.action === seat.launch.action_id
|
|
374
|
+
&& instance.role === seat.role_name && instance.temporary === true && instance.instance === state.instance)
|
|
375
|
+
runtimeCid = state.cid;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (error.code !== 'ENOENT')
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const matches = seat.invite_id ? remote?.seats.filter(value => value.invite_id === seat.invite_id && value.role === seat.cowork_role) ?? [] : [];
|
|
383
|
+
if (matches.length > 1)
|
|
384
|
+
throw new Error('Ambiguous authenticated member identity during deletion recovery');
|
|
385
|
+
const candidates = [seat.identity_cid, runtimeCid, matches[0]?.identity_cid].filter((v) => !!v);
|
|
386
|
+
if (candidates.some(cid => !/^[a-f0-9]{64}$/i.test(cid)))
|
|
387
|
+
throw new Error('Invalid authenticated member CID');
|
|
388
|
+
if (new Set(candidates.map(cid => cid.toLowerCase())).size > 1)
|
|
389
|
+
throw new Error('Contradictory authenticated member CID evidence');
|
|
390
|
+
return candidates.length ? { ...seat, identity_cid: candidates[0] } : seat;
|
|
391
|
+
});
|
|
392
|
+
return recordRetirementMemberCids(room.room_id, seats);
|
|
393
|
+
}
|
|
@@ -3,7 +3,7 @@ import { type CoworkAdapter } from './cowork-adapter.js';
|
|
|
3
3
|
import { type TaskDeletionAcceptance } from './task-state.js';
|
|
4
4
|
import type { TaskDeletionActor, TaskRecord } from './types.js';
|
|
5
5
|
export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
6
|
-
type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'
|
|
6
|
+
type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'> & Partial<Pick<CoworkAdapter, 'getRoom'>>;
|
|
7
7
|
export interface TaskDeletionSettleDeps {
|
|
8
8
|
roomClose?: RoomCloseDeps;
|
|
9
9
|
/** Test seam for the temp-state existence proof. */
|