@awak-app/simy-cli 0.7.11 → 0.7.12

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +47 -7
  3. package/src/auto-update.js +32 -9
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  6. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  7. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  8. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  9. package/src/delivery-hooks/supervisor.js +39 -10
  10. package/src/desktop-host.js +54 -8
  11. package/src/desktop-status.js +1 -1
  12. package/src/direct-executor.js +104 -4
  13. package/src/local-task.js +14 -3
  14. package/src/runner.js +19 -4
  15. package/src/session-supervision/claude-sessions.js +57 -5
  16. package/src/session-supervision/codex-app-server.js +20 -8
  17. package/src/sqm/claude-plugin.js +5 -0
  18. package/src/sqm/review-worker.js +15 -6
  19. package/src/update-detachment/claude-observation.js +33 -0
  20. package/src/update-detachment/codex-context.js +27 -0
  21. package/src/update-detachment/coordinator.js +117 -0
  22. package/src/update-detachment/dispatch-gate.js +1 -0
  23. package/src/update-detachment/ledger-adapter.js +62 -0
  24. package/src/update-detachment/ledger-reader.js +42 -0
  25. package/src/update-detachment/provenance.js +1 -0
  26. package/src/update-detachment/provider-adapters.js +33 -0
  27. package/src/update-detachment/reconcile.js +105 -0
  28. package/src/update-detachment/reconciliation-adapter.js +13 -0
  29. package/src/update-detachment/runtime.js +319 -0
  30. package/src/update-detachment/state-store.js +1 -0
  31. package/src/update-detachment/worker-entry.js +145 -0
  32. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  33. package/src/update-detachment/worker-ledger.js +138 -0
  34. package/src/update-detachment/worker-storage.js +29 -0
  35. package/src/update-detachment/worker.js +167 -0
@@ -0,0 +1,138 @@
1
+ import { normalizeDispatchIdentity } from './provenance.js';
2
+ import { realpathSync } from 'node:fs';
3
+ import { ownedProcessIdentity } from './worker-storage.js';
4
+ import { recordWorkerEvidence } from './worker-ledger-runtime.js';
5
+ import * as ledgerStore from './state-store.js';
6
+ import * as ledgerGate from './dispatch-gate.js';
7
+ import path from 'node:path';
8
+ import { readFile, readdir, realpath } from 'node:fs/promises';
9
+ import { createStateStore, digest, atomicSave, readState, withLock } from './state-store.js';
10
+ import { captureDispatchOwner, dispatchWithFence, readDispatchGate, confirmDispatch, detachmentRoot } from './dispatch-gate.js';
11
+ import { createExecutionWorker } from './worker.js';
12
+
13
+ // Identical canonical fields/order are used by scopes, root dispatch and IPC.
14
+ export function workerScopeIdentity(value) {
15
+ return normalizeDispatchIdentity(value);
16
+ }
17
+ export function createLedgerExecutionWorker({ identity, worker } = {}) {
18
+ identity = workerScopeIdentity(identity);
19
+ const store = createStateStore(identity);
20
+ const storageRoot = path.join(identity.root, 'execution-workers');
21
+ worker ||= createExecutionWorker({ root: storageRoot });
22
+ const dispatchOwner = captureDispatchOwner(identity.root);
23
+ const binding = handle => {
24
+ if (digest(workerScopeIdentity(handle.identity)) !== digest(identity)) throw new Error('Worker scope mismatch');
25
+ const gate = readDispatchGate(identity.root);
26
+ const attempt = gate.attempts[digest(handle.attemptId)];
27
+ if (!attempt || attempt.identityHash !== digest(identity) || attempt.taskHash !== digest(handle.taskId) || attempt.generation !== handle.generation) throw new Error('Worker attempt/generation mismatch');
28
+ const expectedRef = path.join(storageRoot, 'attempts', handle.attemptId, 'snapshot.json');
29
+ if (handle.recordRef !== expectedRef || store.read().tasks[digest(handle.taskId)]?.recordRef !== expectedRef) throw new Error('Worker registered snapshot mismatch');
30
+ const snapshot = readState(expectedRef);
31
+ if (!snapshot || snapshot.task.id !== handle.taskId || snapshot.attemptId !== handle.attemptId || snapshot.generation !== handle.generation || digest(snapshot.identity) !== digest(identity)) throw new Error('Worker preservation snapshot mismatch');
32
+ return attempt;
33
+ };
34
+ return {
35
+ identity, root: identity.root, scope: store,
36
+ canHandoff: worker.canHandoff,
37
+ async start(spec) {
38
+ if (realpathSync(spec.cwd) !== identity.worktree) throw new Error('Worker execution binding mismatch');
39
+ const taskId = spec.taskId || spec.attemptId;
40
+ const attemptId = spec.attemptId;
41
+ if (!spec.preservation || !['model', 'permissions', 'approvals', 'budget', 'deadline'].every(key => Object.hasOwn(spec.preservation.policy || {}, key))) throw new Error('Original preservation policy required');
42
+ if (spec.preservation.policy.deadline !== spec.deadlineAt || spec.backend !== identity.provider) throw new Error('Worker preservation binding mismatch');
43
+ const recordRef = path.join(storageRoot, 'attempts', attemptId, 'snapshot.json');
44
+ const generation = dispatchOwner.generation;
45
+ const ledger = { identity, identityHash: store.identityHash, taskId, attemptId, generation, recordRef };
46
+ const handle = { attemptId, taskId, identity, generation, recordRef };
47
+ try { await worker.start({ ...spec, cwd: identity.worktree, identity, ledger,
48
+ beforeDispatch: () => {
49
+ // Immutable authority copy is recorded before fenced transport initiation.
50
+ atomicSave(recordRef, { schemaVersion: 1, revision: 1, identity, generation, attemptId,
51
+ task: { id: taskId, state: spec.preservation.taskState || 'active', stopRequested: false },
52
+ metadata: structuredClone(spec.preservation.metadata || null),
53
+ policy: structuredClone(spec.preservation.policy), artifacts: structuredClone(spec.preservation.artifacts || []) });
54
+ store.registerTask({ taskId, recordRef, identity });
55
+ },
56
+ dispatch: send => dispatchWithFence(dispatchOwner, { taskId, attemptId, identity }, send),
57
+ }); } catch (error) { error.workerHandle = handle; throw error; }
58
+ return handle;
59
+ },
60
+ async adopt(handle) {
61
+ binding(handle);
62
+ const state = await worker.adopt(handle);
63
+ const directory = path.dirname(handle.recordRef);
64
+ if (state.owner && (state.owner.generation !== handle.generation || state.owner.identityHash !== store.identityHash)) throw new Error('Worker owner scope/generation mismatch');
65
+ if (state.state === 'completed') {
66
+ if (state.receipt.generation !== handle.generation || state.receipt.identityHash !== store.identityHash) throw new Error('Worker result scope/generation mismatch');
67
+ // Worker intake is ordered after result commit; an interrupted intake can be retried read-only.
68
+ // Late result intake can be repaired from the bound durable receipt,
69
+ // including after worker/old host exit. It never dispatches another turn.
70
+ try {
71
+ recordWorkerEvidence({ ledger: { ...handle, identityHash: store.identityHash }, directory, runtime: state.receipt.runtime }, 'result', state.receipt, { ...ledgerStore, ...ledgerGate });
72
+ } catch (error) {
73
+ if (error.code === 'state_busy' || error.code === 'revision_conflict') return { state: 'reconciling', reason: 'result_intake_busy' };
74
+ throw error;
75
+ }
76
+ }
77
+ return state;
78
+ },
79
+ async result(handle) { return this.adopt(handle); },
80
+ async stop(handle) {
81
+ binding(handle);
82
+ // Persist stop intent before signalling; both result intake and stop use
83
+ // the same snapshot lock so neither can discard the other's references.
84
+ withLock(handle.recordRef, () => {
85
+ const snapshot = readState(handle.recordRef);
86
+ if (!snapshot.task.stopRequested) {
87
+ snapshot.task.stopRequested = true; snapshot.task.state = 'stopped'; snapshot.revision++;
88
+ atomicSave(handle.recordRef, snapshot);
89
+ }
90
+ });
91
+ const acknowledgement = await worker.stop(handle);
92
+ return acknowledgement;
93
+ },
94
+ async handoff(handle) {
95
+ const state = await this.adopt(handle);
96
+ if (state.state === 'completed') return { verified: true, pendingWrites: 0, unsettledPermissions: 0, state, preservation: this.readSnapshot(handle), identityHash: store.identityHash, generation: handle.generation };
97
+ if (!state.owner || state.storageError || state.state !== 'running' && state.state !== 'stopping') return { verified: false, state };
98
+ const ack = JSON.parse(await readFile(path.join(path.dirname(handle.recordRef), 'handoff.json'), 'utf8'));
99
+ const snapshot = readState(handle.recordRef);
100
+ if (!ack.providerProcessIdentity || await ownedProcessIdentity(ack.providerPid) !== ack.providerProcessIdentity) return { verified: false, reason: 'owned_provider_process_unconfirmed', state };
101
+ if (ack.generation !== handle.generation || ack.identityHash !== store.identityHash || ack.pid !== state.owner.pid || digest(snapshot.identity) !== digest(identity)) throw new Error('Handoff proof mismatch');
102
+ return { verified: true, pendingWrites: 0, unsettledPermissions: 0, state, acknowledgement: ack, preservation: snapshot, identityHash: store.identityHash, generation: handle.generation };
103
+ },
104
+ async collect(handle) {
105
+ const state = await this.adopt(handle);
106
+ if (state.state === 'completed') confirmDispatch({ root: identity.root, attemptId: handle.attemptId });
107
+ return state;
108
+ },
109
+ readSnapshot(handle) { binding(handle); return readState(handle.recordRef); },
110
+ };
111
+ }
112
+
113
+ export const workerStorageRoot = (root = detachmentRoot()) => path.join(path.resolve(root), 'execution-workers');
114
+
115
+ // Enumerate only this authenticated SIMY actor's registered worker records.
116
+ // Returning an empty list is allowed only for an absent store, never corruption.
117
+ export async function discoverLedgerWorkers({ root = detachmentRoot(), account, device, origin }) {
118
+ if (![account, device, origin].every(value => typeof value === 'string' && value)) throw new Error('Authenticated worker actor required');
119
+ root = realpathSync(root); origin = new URL(origin).origin;
120
+ const scopes = path.join(root, 'update-detachment/scopes');
121
+ let names;
122
+ try { names = await readdir(scopes); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
123
+ const handles = [];
124
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
125
+ const scope = readState(path.join(scopes, name));
126
+ for (const { recordRef } of Object.values(scope.tasks)) {
127
+ const relative = path.relative(workerStorageRoot(root), path.resolve(recordRef));
128
+ if (relative.startsWith('..') || path.isAbsolute(relative) || path.basename(recordRef) !== 'snapshot.json') continue;
129
+ const snapshot = readState(recordRef);
130
+ const identity = workerScopeIdentity(snapshot.identity);
131
+ if (identity.root !== root || identity.account !== account || identity.device !== device || identity.origin !== origin) continue;
132
+ const store = createStateStore(identity);
133
+ if (store.identityHash !== scope.identityHash) throw new Error('Registered worker scope mismatch');
134
+ handles.push({ identity, attemptId: snapshot.attemptId, taskId: snapshot.task.id, generation: snapshot.generation, recordRef });
135
+ }
136
+ }
137
+ return handles;
138
+ }
@@ -0,0 +1,29 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { open, rename, lstat, mkdir } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ export async function privateDirectory(directory, { create = true } = {}) {
7
+ if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
8
+ const info = await lstat(directory);
9
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || info.uid !== process.getuid()) {
10
+ throw new Error('Worker storage must be a private owned directory.');
11
+ }
12
+ }
13
+
14
+ export async function durableJson(directory, name, value) {
15
+ const temporary = path.join(directory, `${name}.tmp`);
16
+ const file = await open(temporary, 'w', 0o600);
17
+ try { await file.writeFile(JSON.stringify(value)); await file.sync(); } finally { await file.close(); }
18
+ await rename(temporary, path.join(directory, name));
19
+ const parent = await open(directory, 'r');
20
+ try { await parent.sync(); } finally { await parent.close(); }
21
+ }
22
+
23
+ export async function ownedProcessIdentity(pid) {
24
+ if (!Number.isSafeInteger(pid) || pid < 2) return null;
25
+ try {
26
+ const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,pgid='], { timeout: 2000, maxBuffer: 2048 });
27
+ return stdout.trim() || null;
28
+ } catch { return null; }
29
+ }
@@ -0,0 +1,167 @@
1
+ import { fork } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { connect } from 'node:net';
4
+ import { readFile, mkdir, writeFile, rm, readdir, lstat, realpath } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { privateDirectory, durableJson, ownedProcessIdentity } from './worker-storage.js';
9
+
10
+ const source = path.dirname(fileURLToPath(import.meta.url));
11
+ const identityKeys = ['provider', 'session', 'account', 'origin', 'worktree'];
12
+ export function canHandoff({ runner, platform = process.platform } = {}) {
13
+ const supported = runner === 'direct' && platform !== 'win32';
14
+ return { supported, action: supported ? 'adopt_worker' : 'wait_for_safe_boundary', reason: supported ? null : 'executor_owner_not_transferable' };
15
+ }
16
+ function identityOf(identity) {
17
+ if (!identity || identityKeys.some((key) => typeof identity[key] !== 'string' || !identity[key])) throw new Error('Complete worker binding required.');
18
+ return Object.fromEntries([...identityKeys, ...["device", "root"].filter(key => Object.hasOwn(identity, key))].map((key) => [key, identity[key]]));
19
+ }
20
+ function attemptDirectory(root, attemptId) {
21
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(attemptId)) throw new Error('Invalid worker attempt ID.');
22
+ return path.join(root, 'attempts', attemptId);
23
+ }
24
+ async function json(directory, name) {
25
+ try { return JSON.parse(await readFile(path.join(directory, name), 'utf8')); }
26
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
27
+ }
28
+ async function request(owner, operation) {
29
+ return new Promise((resolve, reject) => {
30
+ const socket = connect(owner.socket);
31
+ let data = '';
32
+ socket.setTimeout(2000, () => socket.destroy(new Error('Worker IPC timed out.')));
33
+ socket.on('error', reject);
34
+ socket.on('connect', () => socket.write(JSON.stringify({ attemptId: owner.attemptId, identity: owner.identity, operation }) + '\n'));
35
+ socket.on('data', (chunk) => { data += chunk; if (Buffer.byteLength(data) > 1024 * 1024) socket.destroy(new Error('Invalid owner response.')); });
36
+ socket.on('end', () => {
37
+ try {
38
+ const response = JSON.parse(data);
39
+ if (response.attemptId !== owner.attemptId || response.pid !== owner.pid || response.runtime !== owner.runtime || response.ownerToken !== owner.ownerToken || response.processIdentity !== owner.processIdentity || JSON.stringify(response.identity) !== JSON.stringify(owner.identity) || !response.acknowledged) throw new Error('Worker owner identity mismatch.');
40
+ resolve(response);
41
+ } catch (error) { reject(error); }
42
+ });
43
+ });
44
+ }
45
+ async function runtimeMutation(root, operation) {
46
+ const lock = path.join(root, 'runtime-mutation');
47
+ for (let i = 0; ; i++) {
48
+ try { await mkdir(lock, { mode: 0o700 }); break; }
49
+ catch (error) {
50
+ if (error.code !== 'EEXIST' || i >= 100) throw error;
51
+ await new Promise((resolve) => setTimeout(resolve, 20));
52
+ }
53
+ }
54
+ // A crash leaves the lock in place: never steal a potentially live writer's lease.
55
+ try { return await operation(); } finally { await rm(lock, { recursive: true }); }
56
+ }
57
+ export function createExecutionWorker({ root = path.join(homedir(), '.simy', 'execution-workers') } = {}) {
58
+ root = path.resolve(root);
59
+ return {
60
+ canHandoff,
61
+ async start({ attemptId = randomUUID(), identity, command, cwd, backend, deadlineAt, ledger = null, beforeDispatch = () => {}, dispatch = send => send() }) {
62
+ if (!canHandoff({ runner: 'direct' }).supported) throw new Error('Worker handoff unsupported.');
63
+ identity = identityOf(identity);
64
+ if (deadlineAt !== null && (!Number.isSafeInteger(deadlineAt) || deadlineAt <= Date.now() || deadlineAt - Date.now() > 2147483647)) throw new Error('An original deadline must be valid when supplied.');
65
+ let matchingWorktree = false;
66
+ let verifiedCwd;
67
+ try { verifiedCwd = await realpath(cwd); matchingWorktree = verifiedCwd === await realpath(identity.worktree); } catch { /* An unavailable scope is not an execution binding. */ }
68
+ if (!['codex', 'claude'].includes(backend) || backend !== identity.provider || !matchingWorktree) throw new Error('Worker execution binding mismatch.');
69
+ // Later async preparation or caller hooks must not redirect a verified alias.
70
+ cwd = verifiedCwd;
71
+ if (!command || typeof command.bin !== 'string' || !Array.isArray(command.args) || command.args.some((arg) => typeof arg !== 'string')) throw new Error('Invalid worker command.');
72
+ await privateDirectory(root);
73
+ await privateDirectory(path.join(root, 'attempts'));
74
+ await privateDirectory(path.join(root, 'runtimes'));
75
+ // Content addressing fixes the worker/decoder version across install replacement and rollback.
76
+ const files = [
77
+ ['worker-entry.mjs', await readFile(path.join(source, 'worker-entry.js'))],
78
+ ['worker-storage.mjs', await readFile(path.join(source, 'worker-storage.js'))],
79
+ ['provider-stream.mjs', await readFile(path.join(source, '..', 'provider-stream.js'))],
80
+ ['worker-ledger-runtime.mjs', await readFile(path.join(source, 'worker-ledger-runtime.js'))],
81
+ ...await Promise.all(['update-state-store.mjs', 'update-dispatch-gate.mjs', 'update-dispatch-identity.mjs'].map(async name => [name, await readFile(path.join(source, '..', 'codex-plugins/simy/hooks', name))])),
82
+ ];
83
+ const runtime = createHash('sha256').update(Buffer.concat(files.map(([, bytes]) => bytes))).digest('hex');
84
+ const runtimeDirectory = path.join(root, 'runtimes', runtime);
85
+ const directory = attemptDirectory(root, attemptId);
86
+ await runtimeMutation(root, async () => {
87
+ await privateDirectory(runtimeDirectory);
88
+ for (const [name, bytes] of files) {
89
+ try { await writeFile(path.join(runtimeDirectory, name), bytes, { flag: 'wx', mode: 0o600 }); }
90
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
91
+ if (!(await readFile(path.join(runtimeDirectory, name))).equals(bytes)) throw new Error('Retained worker runtime integrity mismatch.');
92
+ }
93
+ // Permanent exclusive claim. Even orphaned/ambiguous attempts are never relaunched.
94
+ await mkdir(directory, { mode: 0o700 });
95
+ await durableJson(directory, 'intent.json', { attemptId, identity, runtime, deadlineAt, ledger, createdAt: Date.now() });
96
+ });
97
+ const child = dispatch(() => {
98
+ beforeDispatch();
99
+ return fork(path.join(runtimeDirectory, 'worker-entry.mjs'), [], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], execArgv: [] });
100
+ });
101
+ const acknowledgement = new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => reject(new Error('Worker launch ACK unknown; do not replay.')), 30000);
103
+ const done = (error, value) => { clearTimeout(timer); error ? reject(error) : resolve(value); };
104
+ child.once('error', (error) => done(error));
105
+ child.once('exit', () => done(new Error('Worker exited before launch ACK; reconcile receipts.')));
106
+ child.once('message', (message) => done(message.error ? new Error(message.error) : null, message));
107
+ });
108
+ child.send({ attemptId, identity, runtime, deadlineAt, directory, ledger, command: { bin: command.bin, args: command.args, env: command.env || {} }, cwd, backend });
109
+ try {
110
+ const ack = await acknowledgement;
111
+ if (ack.attemptId !== attemptId || ack.runtime !== runtime || JSON.stringify(ack.identity) !== JSON.stringify(identity)) throw new Error('Invalid launch ACK.');
112
+ return { attemptId, identity, runtime };
113
+ } finally {
114
+ if (child.connected) child.disconnect();
115
+ child.unref();
116
+ }
117
+ },
118
+ async adopt({ attemptId, identity }) {
119
+ identity = identityOf(identity);
120
+ const directory = attemptDirectory(root, attemptId);
121
+ await privateDirectory(directory, { create: false });
122
+ const intent = await json(directory, 'intent.json');
123
+ if (!intent || JSON.stringify(intent.identity) !== JSON.stringify(identity)) throw new Error('Worker binding mismatch.');
124
+ const receipt = await json(directory, 'result.json');
125
+ if (receipt) {
126
+ if (receipt.attemptId !== attemptId || receipt.runtime !== intent.runtime || JSON.stringify(receipt.identity) !== JSON.stringify(identity)) throw new Error('Result binding mismatch.');
127
+ return { state: 'completed', receipt };
128
+ }
129
+ const owner = await json(directory, 'owner.json');
130
+ try {
131
+ if (!owner || owner.attemptId !== attemptId || owner.runtime !== intent.runtime || JSON.stringify(owner.identity) !== JSON.stringify(identity)) throw new Error('Owner unavailable');
132
+ if (!owner.processIdentity || await ownedProcessIdentity(owner.pid) !== owner.processIdentity) throw new Error('Owner process identity changed');
133
+ const live = await request(owner, 'inspect');
134
+ return { state: live.storageError ? 'recovery_required' : live.stopped ? 'stopping' : 'running', owner: live, deadlineAt: intent.deadlineAt, output: live.output, token_usage: live.token_usage, storageError: live.storageError };
135
+ } catch {
136
+ // PID liveness is not proof of ownership. Keep all evidence and require reconciliation.
137
+ const lateReceipt = await json(directory, 'result.json');
138
+ if (lateReceipt && lateReceipt.attemptId === attemptId && lateReceipt.runtime === intent.runtime && JSON.stringify(lateReceipt.identity) === JSON.stringify(identity)) return { state: 'completed', receipt: lateReceipt };
139
+ return { state: 'recovery_required', reason: 'owner_unconfirmed_no_replay' };
140
+ }
141
+ },
142
+ async stop(handle) {
143
+ const state = await this.adopt(handle);
144
+ if (state.state === 'completed') return state;
145
+ if (!state.owner) throw new Error('Cannot stop an unconfirmed owner.');
146
+ const owner = await json(attemptDirectory(root, handle.attemptId), 'owner.json');
147
+ return request(owner, 'stop');
148
+ },
149
+ async result(handle) { return this.adopt(handle); },
150
+ async cleanupRuntime(runtime) {
151
+ if (!/^[a-f0-9]{64}$/.test(runtime)) throw new Error('Invalid retained runtime.');
152
+ await privateDirectory(root, { create: false });
153
+ return runtimeMutation(root, async () => {
154
+ // Conservatively retain every version referenced by an attempt, including ambiguous orphans.
155
+ for (const name of await readdir(path.join(root, 'attempts'))) {
156
+ const intent = await json(attemptDirectory(root, name), 'intent.json');
157
+ if (!intent || intent.runtime === runtime) return { removed: false, reason: 'retained_attempt' };
158
+ }
159
+ const directory = path.join(root, 'runtimes', runtime);
160
+ const info = await lstat(directory);
161
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Unsafe retained runtime.');
162
+ await rm(directory, { recursive: true });
163
+ return { removed: true };
164
+ });
165
+ },
166
+ };
167
+ }