@the-open-engine/zeroshot 6.25.0 → 6.25.1
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/cli/index.js +102 -74
- package/package.json +1 -1
- package/src/agent/provider-session.js +18 -4
- package/src/omp-session-limits.js +25 -1
- package/src/omp-session-partition.js +159 -32
- package/src/omp-session-verifier.js +251 -87
- package/task-lib/commands/clean.js +80 -46
- package/task-lib/commands/kill.js +21 -0
- package/task-lib/commands/resume.js +20 -0
- package/task-lib/commands/run.js +17 -2
- package/task-lib/omp-session-cleanup.js +46 -9
- package/task-lib/omp-session-ownership-schema.js +21 -15
- package/task-lib/omp-session-ownership.js +66 -31
- package/task-lib/rpc-watcher.js +51 -15
- package/task-lib/runner.js +51 -4
- package/task-lib/store.js +67 -72
package/cli/index.js
CHANGED
|
@@ -2086,12 +2086,9 @@ async function getPurgeData(orchestrator) {
|
|
|
2086
2086
|
cluster.state === 'running' || cluster.state === 'initializing' || cluster.state === 'setup'
|
|
2087
2087
|
);
|
|
2088
2088
|
const { loadTasks } = await import('../task-lib/store.js');
|
|
2089
|
-
const { isProcessRunning } = await import('../task-lib/runner.js');
|
|
2090
2089
|
const tasks = Object.values(loadTasks());
|
|
2091
|
-
const runningTasks = tasks.filter(
|
|
2092
|
-
|
|
2093
|
-
);
|
|
2094
|
-
return { clusters, runningClusters, tasks, runningTasks, isProcessRunning };
|
|
2090
|
+
const runningTasks = tasks.filter((task) => task.status === 'running');
|
|
2091
|
+
return { clusters, runningClusters, tasks, runningTasks };
|
|
2095
2092
|
}
|
|
2096
2093
|
|
|
2097
2094
|
function printPurgeSummary({ clusters, runningClusters, tasks, runningTasks }) {
|
|
@@ -2133,45 +2130,105 @@ async function confirmPurge(options) {
|
|
|
2133
2130
|
return answer.toLowerCase() === 'y';
|
|
2134
2131
|
}
|
|
2135
2132
|
|
|
2133
|
+
function validateClusterKillResults(runningClusters, clusterResults) {
|
|
2134
|
+
if (
|
|
2135
|
+
clusterResults === null ||
|
|
2136
|
+
typeof clusterResults !== 'object' ||
|
|
2137
|
+
!Array.isArray(clusterResults.killed) ||
|
|
2138
|
+
!Array.isArray(clusterResults.errors)
|
|
2139
|
+
) {
|
|
2140
|
+
throw new Error(
|
|
2141
|
+
'Refusing destructive cluster cleanup: kill-all returned incomplete or invalid results ' +
|
|
2142
|
+
'(malformed fields: killed or errors). Retry after confirming every cluster process ' +
|
|
2143
|
+
'boundary is terminal.'
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const expectedIds = runningClusters.map((cluster) => cluster.id);
|
|
2148
|
+
const expected = new Set(expectedIds);
|
|
2149
|
+
const outcomeIds = [...clusterResults.killed, ...clusterResults.errors.map((error) => error?.id)];
|
|
2150
|
+
const counts = outcomeIds.reduce((byId, id) => {
|
|
2151
|
+
byId.set(id, (byId.get(id) || 0) + 1);
|
|
2152
|
+
return byId;
|
|
2153
|
+
}, new Map());
|
|
2154
|
+
const problems = [
|
|
2155
|
+
['unknown outcomes', outcomeIds.filter((id) => typeof id !== 'string' || !expected.has(id))],
|
|
2156
|
+
['duplicate outcomes', expectedIds.filter((id) => (counts.get(id) || 0) > 1)],
|
|
2157
|
+
['missing outcomes', expectedIds.filter((id) => (counts.get(id) || 0) === 0)],
|
|
2158
|
+
]
|
|
2159
|
+
.filter(([, ids]) => ids.length > 0)
|
|
2160
|
+
.map(([label, ids]) => `${label}: ${ids.join(', ')}`);
|
|
2161
|
+
|
|
2162
|
+
if (problems.length > 0) {
|
|
2163
|
+
throw new Error(
|
|
2164
|
+
`Refusing destructive cluster cleanup: kill-all returned incomplete or invalid results (${problems.join(
|
|
2165
|
+
'; '
|
|
2166
|
+
)}). Retry after confirming every cluster process boundary is terminal.`
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
return clusterResults;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2136
2173
|
async function killRunningClusters(orchestrator, runningClusters) {
|
|
2137
2174
|
if (runningClusters.length === 0) {
|
|
2138
2175
|
return;
|
|
2139
2176
|
}
|
|
2140
2177
|
console.log(chalk.bold('Killing running clusters...'));
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2178
|
+
const { killed, errors } = validateClusterKillResults(
|
|
2179
|
+
runningClusters,
|
|
2180
|
+
await orchestrator.killAll()
|
|
2181
|
+
);
|
|
2182
|
+
|
|
2183
|
+
for (const id of killed) {
|
|
2143
2184
|
console.log(chalk.green(`✓ Killed cluster: ${id}`));
|
|
2144
2185
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
2186
|
+
if (errors.length > 0) {
|
|
2187
|
+
for (const err of errors) {
|
|
2188
|
+
console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
|
|
2189
|
+
}
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`Refusing destructive cluster cleanup: termination failed for ${errors
|
|
2192
|
+
.map((error) => error.id)
|
|
2193
|
+
.join(', ')}. Retry after confirming every cluster process boundary is terminal.`
|
|
2194
|
+
);
|
|
2147
2195
|
}
|
|
2148
2196
|
}
|
|
2149
2197
|
|
|
2150
|
-
async function killRunningTasks(runningTasks
|
|
2198
|
+
async function killRunningTasks(runningTasks) {
|
|
2151
2199
|
if (runningTasks.length === 0) {
|
|
2152
2200
|
return;
|
|
2153
2201
|
}
|
|
2154
2202
|
console.log(chalk.bold('Killing running tasks...'));
|
|
2155
|
-
const {
|
|
2156
|
-
|
|
2157
|
-
|
|
2203
|
+
const [{ killTaskCommand }, { getTask }] = await Promise.all([
|
|
2204
|
+
import('../task-lib/commands/kill.js'),
|
|
2205
|
+
import('../task-lib/store.js'),
|
|
2206
|
+
]);
|
|
2207
|
+
const unconfirmed = [];
|
|
2208
|
+
|
|
2209
|
+
// Reuse the standalone kill boundary instead of treating successful signal delivery as process
|
|
2210
|
+
// termination. Then verify its durable terminal write: killTaskCommand also serves the interactive
|
|
2211
|
+
// CLI and reports an unconfirmed boundary through process.exitCode rather than throwing. Purge
|
|
2212
|
+
// must turn that report into a hard gate before it reaches any destructive cleanup.
|
|
2158
2213
|
for (const task of runningTasks) {
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2214
|
+
await killTaskCommand(task.id);
|
|
2215
|
+
const current = getTask(task.id);
|
|
2216
|
+
if (
|
|
2217
|
+
!current ||
|
|
2218
|
+
current.status === 'running' ||
|
|
2219
|
+
Number.isInteger(current.pid) ||
|
|
2220
|
+
Number.isInteger(current.processGroupId)
|
|
2221
|
+
) {
|
|
2222
|
+
unconfirmed.push(task.id);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
if (unconfirmed.length > 0) {
|
|
2227
|
+
throw new Error(
|
|
2228
|
+
`Refusing destructive task cleanup: provider termination is unconfirmed for ${unconfirmed.join(
|
|
2229
|
+
', '
|
|
2230
|
+
)}. Retry after confirming the persisted provider process boundary is terminal.`
|
|
2231
|
+
);
|
|
2175
2232
|
}
|
|
2176
2233
|
}
|
|
2177
2234
|
|
|
@@ -2183,17 +2240,18 @@ async function killRunningTasks(runningTasks, isProcessRunning) {
|
|
|
2183
2240
|
* cannot be safely resolved keeps its owner record plus a warning instead of being deleted.
|
|
2184
2241
|
*/
|
|
2185
2242
|
async function deleteClusterOmpSessions(clusters) {
|
|
2186
|
-
const { cleanupOmpSessionPartitionsForCluster } =
|
|
2187
|
-
'../task-lib/omp-session-cleanup.js'
|
|
2188
|
-
);
|
|
2243
|
+
const { cleanupOmpSessionPartitionsForCluster } =
|
|
2244
|
+
await import('../task-lib/omp-session-cleanup.js');
|
|
2189
2245
|
let deleted = 0;
|
|
2190
2246
|
let retained = 0;
|
|
2247
|
+
const unreadable = new Set();
|
|
2191
2248
|
for (const cluster of clusters) {
|
|
2192
2249
|
const result = cleanupOmpSessionPartitionsForCluster(cluster.id, (message) =>
|
|
2193
2250
|
console.log(chalk.yellow(`Warning: ${message}`))
|
|
2194
2251
|
);
|
|
2195
2252
|
deleted += result.deleted.length;
|
|
2196
2253
|
retained += result.retained.length;
|
|
2254
|
+
for (const taskId of result.unreadable) unreadable.add(taskId);
|
|
2197
2255
|
}
|
|
2198
2256
|
if (deleted > 0) {
|
|
2199
2257
|
console.log(chalk.green(`✓ Deleted ${deleted} OMP session partition(s)`));
|
|
@@ -2201,6 +2259,13 @@ async function deleteClusterOmpSessions(clusters) {
|
|
|
2201
2259
|
if (retained > 0) {
|
|
2202
2260
|
console.log(chalk.yellow(`○ Retained ${retained} OMP session partition(s) for inspection`));
|
|
2203
2261
|
}
|
|
2262
|
+
if (unreadable.size > 0) {
|
|
2263
|
+
console.log(
|
|
2264
|
+
chalk.yellow(
|
|
2265
|
+
`○ ${unreadable.size} task row(s) hold an unreadable OMP session ownership record and were left intact`
|
|
2266
|
+
)
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2204
2269
|
}
|
|
2205
2270
|
|
|
2206
2271
|
async function deleteClusterData(orchestrator, clusters) {
|
|
@@ -3204,11 +3269,8 @@ program
|
|
|
3204
3269
|
);
|
|
3205
3270
|
|
|
3206
3271
|
const { loadTasks } = await import('../task-lib/store.js');
|
|
3207
|
-
const { isProcessRunning } = await import('../task-lib/runner.js');
|
|
3208
3272
|
const tasks = loadTasks();
|
|
3209
|
-
const runningTasks = Object.values(tasks).filter(
|
|
3210
|
-
(t) => t.status === 'running' && isProcessRunning(t.pid)
|
|
3211
|
-
);
|
|
3273
|
+
const runningTasks = Object.values(tasks).filter((task) => task.status === 'running');
|
|
3212
3274
|
|
|
3213
3275
|
const totalCount = runningClusters.length + runningTasks.length;
|
|
3214
3276
|
|
|
@@ -3253,44 +3315,9 @@ program
|
|
|
3253
3315
|
|
|
3254
3316
|
console.log('');
|
|
3255
3317
|
|
|
3256
|
-
|
|
3257
|
-
if (runningClusters.length > 0) {
|
|
3258
|
-
const clusterResults = await orchestrator.killAll();
|
|
3259
|
-
for (const id of clusterResults.killed) {
|
|
3260
|
-
console.log(chalk.green(`✓ Killed cluster: ${id}`));
|
|
3261
|
-
}
|
|
3262
|
-
for (const err of clusterResults.errors) {
|
|
3263
|
-
console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
|
|
3264
|
-
}
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
|
-
// Kill tasks
|
|
3268
|
-
if (runningTasks.length > 0) {
|
|
3269
|
-
const { killTask, isProcessRunning: checkPid } = await import('../task-lib/runner.js');
|
|
3270
|
-
const { updateTask } = await import('../task-lib/store.js');
|
|
3271
|
-
|
|
3272
|
-
for (const task of runningTasks) {
|
|
3273
|
-
if (!checkPid(task.pid)) {
|
|
3274
|
-
updateTask(task.id, {
|
|
3275
|
-
status: 'stale',
|
|
3276
|
-
error: 'Process died unexpectedly',
|
|
3277
|
-
});
|
|
3278
|
-
console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
|
|
3279
|
-
continue;
|
|
3280
|
-
}
|
|
3318
|
+
await killRunningClusters(orchestrator, runningClusters);
|
|
3281
3319
|
|
|
3282
|
-
|
|
3283
|
-
if (killed) {
|
|
3284
|
-
updateTask(task.id, {
|
|
3285
|
-
status: 'killed',
|
|
3286
|
-
error: 'Killed by kill-all',
|
|
3287
|
-
});
|
|
3288
|
-
console.log(chalk.green(`✓ Killed task: ${task.id}`));
|
|
3289
|
-
} else {
|
|
3290
|
-
console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
|
|
3291
|
-
}
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3320
|
+
await killRunningTasks(runningTasks);
|
|
3294
3321
|
|
|
3295
3322
|
console.log(chalk.bold.green(`\nDone.`));
|
|
3296
3323
|
} catch (error) {
|
|
@@ -3801,7 +3828,7 @@ program
|
|
|
3801
3828
|
console.log('');
|
|
3802
3829
|
|
|
3803
3830
|
await killRunningClusters(orchestrator, purgeData.runningClusters);
|
|
3804
|
-
await killRunningTasks(purgeData.runningTasks
|
|
3831
|
+
await killRunningTasks(purgeData.runningTasks);
|
|
3805
3832
|
await deleteClusterData(orchestrator, purgeData.clusters);
|
|
3806
3833
|
await deleteTaskData(purgeData.tasks);
|
|
3807
3834
|
|
|
@@ -6089,4 +6116,5 @@ module.exports = {
|
|
|
6089
6116
|
renderRecentMessagesToTerminal,
|
|
6090
6117
|
isStartupUpdateEligible,
|
|
6091
6118
|
resolveRunMode,
|
|
6119
|
+
killRunningClusters,
|
|
6092
6120
|
};
|
package/package.json
CHANGED
|
@@ -65,13 +65,25 @@ const OMP_SESSION_KEYS = new Set([
|
|
|
65
65
|
]);
|
|
66
66
|
const IDENTITY_KEYS = new Set(['device', 'inode']);
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A `{device, inode}` pair, both already canonical unsigned decimal *strings* (issue #866).
|
|
70
|
+
*
|
|
71
|
+
* The type is required, not coerced. `String(value.device)` used to accept a JSON number, a
|
|
72
|
+
* boxed String, a one-element array, or anything else whose `toString()` happened to look decimal,
|
|
73
|
+
* and then write the coerced result into the snapshot — so a snapshot that had never contained the
|
|
74
|
+
* canonical form would compare equal to the persisted ownership record it is supposed to be
|
|
75
|
+
* checked against. A snapshot that is not already canonical is not this writer's snapshot, and is
|
|
76
|
+
* rejected rather than repaired. Both keys are required and no others are allowed.
|
|
77
|
+
*/
|
|
68
78
|
function normalizeIdentity(value) {
|
|
69
79
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
70
|
-
|
|
71
|
-
if (
|
|
80
|
+
const keys = Object.keys(value);
|
|
81
|
+
if (keys.length !== IDENTITY_KEYS.size || !keys.every((key) => IDENTITY_KEYS.has(key))) {
|
|
72
82
|
return null;
|
|
73
83
|
}
|
|
74
|
-
|
|
84
|
+
if (typeof value.device !== 'string' || typeof value.inode !== 'string') return null;
|
|
85
|
+
if (!DECIMAL_STRING.test(value.device) || !DECIMAL_STRING.test(value.inode)) return null;
|
|
86
|
+
return { device: value.device, inode: value.inode };
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
@@ -299,7 +311,9 @@ function providerSessionFromCompletedTask({
|
|
|
299
311
|
// rpc-watcher.js never populates the generic sessionId column; the OMP-observed session ID
|
|
300
312
|
// committed alongside ompSession is the one authoritative identity for this provider.
|
|
301
313
|
const sessionId = isOmp
|
|
302
|
-
? normalizeNonEmptyString(
|
|
314
|
+
? normalizeNonEmptyString(
|
|
315
|
+
ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null
|
|
316
|
+
)
|
|
303
317
|
: normalizeNonEmptyString(taskInfo.sessionId);
|
|
304
318
|
const taskId = normalizeNonEmptyString(taskInfo.id);
|
|
305
319
|
const generation = agent?.iteration;
|
|
@@ -14,4 +14,28 @@ const OMP_SESSION_LIMITS = Object.freeze({
|
|
|
14
14
|
maxReferencedBlobBytes: 67108864,
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The largest single JSONL record the verifier will buffer, DERIVED from the constants above
|
|
19
|
+
* rather than chosen — it is `maxReferencedBlobBytes`, and it is not a new knob (there is nothing
|
|
20
|
+
* to configure and no caller may override it).
|
|
21
|
+
*
|
|
22
|
+
* Why a per-record bound is needed at all: `maxSessionBytes` bounds the *file*, not a line within
|
|
23
|
+
* it. A hostile 256 MiB session with no newline in it is one record, and buffering it would cost
|
|
24
|
+
* the raw bytes, a concatenated copy, a UTF-16 string for JSON.parse, and the parsed value — a
|
|
25
|
+
* multi-hundred-megabyte spike driven entirely by the attacker's choice of where to put newlines.
|
|
26
|
+
*
|
|
27
|
+
* Why this value: `maxReferencedBlobBytes` is the issue's own answer to "how large may one
|
|
28
|
+
* addressable unit of session content be". OMP externalizes anything bigger than a message to the
|
|
29
|
+
* shared CAS store (blob-store.ts) and leaves only a 76-byte `blob:sha256:<hex>` reference in the
|
|
30
|
+
* record, so a legitimate record is orders of magnitude smaller than this; the bound exists to cap
|
|
31
|
+
* the pathological case, not to constrain real transcripts.
|
|
32
|
+
*
|
|
33
|
+
* Remaining allocation, exactly: verification buffers at most MAX_SESSION_RECORD_BYTES of raw
|
|
34
|
+
* record bytes, and `JSON.parse` necessarily materializes that record as one UTF-16 string plus its
|
|
35
|
+
* parsed value. Peak per-record cost is therefore O(MAX_SESSION_RECORD_BYTES) and independent of
|
|
36
|
+
* `maxSessionBytes`, the record count, and the file's newline placement. Nothing else in the
|
|
37
|
+
* verifier accumulates session, artifact, or blob bytes.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_SESSION_RECORD_BYTES = OMP_SESSION_LIMITS.maxReferencedBlobBytes;
|
|
40
|
+
|
|
41
|
+
module.exports = { OMP_SESSION_LIMITS, MAX_SESSION_RECORD_BYTES };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { randomUUID } = require('crypto');
|
|
3
|
+
const { createHash, randomUUID } = require('crypto');
|
|
4
4
|
const { isInsideOmpBlobsDir } = require('./omp-blob-root');
|
|
5
5
|
|
|
6
6
|
// Every OMP session partition lives under <storageRoot>/omp-sessions/<uuid>/. storageRoot is the
|
|
@@ -83,16 +83,34 @@ function currentUid() {
|
|
|
83
83
|
return typeof process.getuid === 'function' ? String(process.getuid()) : '0';
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function stagingPathForOwnership(root, ownership) {
|
|
87
|
+
const owner = ownership.owner ?? {};
|
|
88
|
+
const persistedOwnerIdentity = JSON.stringify([
|
|
89
|
+
ownership.partitionId,
|
|
90
|
+
ownership.ownerUid ?? null,
|
|
91
|
+
ownership.storageRootIdentity?.device ?? null,
|
|
92
|
+
ownership.storageRootIdentity?.inode ?? null,
|
|
93
|
+
ownership.partitionIdentity?.device ?? null,
|
|
94
|
+
ownership.partitionIdentity?.inode ?? null,
|
|
95
|
+
owner.kind ?? null,
|
|
96
|
+
owner.clusterId ?? null,
|
|
97
|
+
owner.agentId ?? null,
|
|
98
|
+
owner.taskId ?? null,
|
|
99
|
+
]);
|
|
100
|
+
const digest = createHash('sha256').update(persistedOwnerIdentity).digest('hex');
|
|
101
|
+
return path.join(root, `${DELETING_PREFIX}${ownership.partitionId}-${digest}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
/**
|
|
87
105
|
* Phase 1 of deletion: validate the owner record against what is actually on disk and move the
|
|
88
106
|
* partition out of its canonical name.
|
|
89
107
|
*
|
|
90
108
|
* The check/use race (CodeQL js/file-system-race) is closed by *moving before deleting*: the
|
|
91
|
-
* partition is renamed, within its own parent, to
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* proves it is still the same directory that passed
|
|
95
|
-
*
|
|
109
|
+
* partition is renamed, within its own parent, to a deterministic staging name bound to its
|
|
110
|
+
* partition id and exact persisted owner identity, and only then re-pinned. A retry can therefore
|
|
111
|
+
* recover the staged directory after a crash or failed recursive removal. `rename(2)` is atomic,
|
|
112
|
+
* and the post-rename identity comparison proves it is still the same directory that passed
|
|
113
|
+
* validation. A canonical/staged conflict or identity mismatch leaves both names untouched.
|
|
96
114
|
*
|
|
97
115
|
* Splitting the rename from the recursive removal is what lets a caller hold a *task-store* write
|
|
98
116
|
* fence across "no other row claims this partition" -> "the partition no longer answers to its
|
|
@@ -178,28 +196,100 @@ function stageOmpSessionPartitionForDeletion(ownership) {
|
|
|
178
196
|
};
|
|
179
197
|
}
|
|
180
198
|
if (String(storagePin.uid) !== currentUid()) {
|
|
181
|
-
return {
|
|
199
|
+
return {
|
|
200
|
+
staged: false,
|
|
201
|
+
deleted: false,
|
|
202
|
+
reason: `${storageRoot} is not owned by the current user`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let stagingPath;
|
|
207
|
+
try {
|
|
208
|
+
stagingPath = stagingPathForOwnership(root, ownership);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return {
|
|
211
|
+
staged: false,
|
|
212
|
+
deleted: false,
|
|
213
|
+
reason: `could not derive a staging name from the persisted owner identity: ${error.message}`,
|
|
214
|
+
};
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
let before;
|
|
185
218
|
try {
|
|
186
219
|
before = pinDirectoryIdentity(expectedPartitionPath);
|
|
187
220
|
} catch (error) {
|
|
188
|
-
if (error.code
|
|
189
|
-
|
|
190
|
-
|
|
221
|
+
if (error.code !== 'ENOENT') {
|
|
222
|
+
if (error.code === 'ELOOP' || error.code === 'EMLINK') {
|
|
223
|
+
return {
|
|
224
|
+
staged: false,
|
|
225
|
+
deleted: false,
|
|
226
|
+
reason: `${expectedPartitionPath} is a symlink; refusing to delete`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (error.code === 'ENOTDIR') {
|
|
230
|
+
return {
|
|
231
|
+
staged: false,
|
|
232
|
+
deleted: false,
|
|
233
|
+
reason: `${expectedPartitionPath} is not a real directory; refusing to delete`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { staged: false, deleted: false, reason: error.message };
|
|
191
237
|
}
|
|
192
|
-
|
|
238
|
+
|
|
239
|
+
let recovered;
|
|
240
|
+
try {
|
|
241
|
+
recovered = pinDirectoryIdentity(stagingPath);
|
|
242
|
+
} catch (stagedError) {
|
|
243
|
+
if (stagedError.code === 'ENOENT') {
|
|
244
|
+
return { staged: false, deleted: true, reason: 'already absent' };
|
|
245
|
+
}
|
|
193
246
|
return {
|
|
194
247
|
staged: false,
|
|
195
248
|
deleted: false,
|
|
196
|
-
reason:
|
|
249
|
+
reason: `canonical partition is absent, but staged ${stagingPath} is unsafe: ${stagedError.message}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (String(recovered.uid) !== currentUid()) {
|
|
253
|
+
return {
|
|
254
|
+
staged: false,
|
|
255
|
+
deleted: false,
|
|
256
|
+
reason: `staged ${stagingPath} is not owned by the current user`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
ownership.partitionIdentity &&
|
|
261
|
+
!sameIdentity(recovered.identity, ownership.partitionIdentity)
|
|
262
|
+
) {
|
|
263
|
+
return {
|
|
264
|
+
staged: false,
|
|
265
|
+
deleted: false,
|
|
266
|
+
reason: `staged ${stagingPath} identity ${recovered.identity.device}:${recovered.identity.inode} does not match the recorded ${ownership.partitionIdentity.device}:${ownership.partitionIdentity.inode}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { staged: true, stagingPath };
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
pinDirectoryIdentity(stagingPath);
|
|
273
|
+
return {
|
|
274
|
+
staged: false,
|
|
275
|
+
deleted: false,
|
|
276
|
+
reason: `both canonical ${expectedPartitionPath} and staged ${stagingPath} exist; refusing to choose one`,
|
|
277
|
+
};
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error.code !== 'ENOENT') {
|
|
280
|
+
return {
|
|
281
|
+
staged: false,
|
|
282
|
+
deleted: false,
|
|
283
|
+
reason: `staging conflict at ${stagingPath}: ${error.message}`,
|
|
197
284
|
};
|
|
198
285
|
}
|
|
199
|
-
return { staged: false, deleted: false, reason: error.message };
|
|
200
286
|
}
|
|
201
287
|
if (String(before.uid) !== currentUid()) {
|
|
202
|
-
return {
|
|
288
|
+
return {
|
|
289
|
+
staged: false,
|
|
290
|
+
deleted: false,
|
|
291
|
+
reason: `${expectedPartitionPath} is not owned by the current user`,
|
|
292
|
+
};
|
|
203
293
|
}
|
|
204
294
|
if (ownership.partitionIdentity && !sameIdentity(before.identity, ownership.partitionIdentity)) {
|
|
205
295
|
return {
|
|
@@ -209,12 +299,14 @@ function stageOmpSessionPartitionForDeletion(ownership) {
|
|
|
209
299
|
};
|
|
210
300
|
}
|
|
211
301
|
|
|
212
|
-
const stagingPath = path.join(root, `${DELETING_PREFIX}${randomUUID()}`);
|
|
213
302
|
try {
|
|
214
303
|
fs.renameSync(expectedPartitionPath, stagingPath);
|
|
215
304
|
} catch (error) {
|
|
216
|
-
|
|
217
|
-
|
|
305
|
+
return {
|
|
306
|
+
staged: false,
|
|
307
|
+
deleted: false,
|
|
308
|
+
reason: `could not stage ${expectedPartitionPath}: ${error.message}`,
|
|
309
|
+
};
|
|
218
310
|
}
|
|
219
311
|
|
|
220
312
|
let after;
|
|
@@ -224,7 +316,7 @@ function stageOmpSessionPartitionForDeletion(ownership) {
|
|
|
224
316
|
return {
|
|
225
317
|
staged: false,
|
|
226
318
|
deleted: false,
|
|
227
|
-
reason: `staged ${stagingPath} could not be pinned (${error.message}); left in place for inspection`,
|
|
319
|
+
reason: `staged ${stagingPath} could not be pinned (${error.message}); left in place for retry or inspection`,
|
|
228
320
|
};
|
|
229
321
|
}
|
|
230
322
|
if (!sameIdentity(after.identity, before.identity)) {
|
|
@@ -239,26 +331,61 @@ function stageOmpSessionPartitionForDeletion(ownership) {
|
|
|
239
331
|
}
|
|
240
332
|
|
|
241
333
|
/**
|
|
242
|
-
* Phase 2: remove a directory that {@link stageOmpSessionPartitionForDeletion}
|
|
243
|
-
*
|
|
244
|
-
*
|
|
334
|
+
* Phase 2: remove a directory that {@link stageOmpSessionPartitionForDeletion} parked under its
|
|
335
|
+
* deterministic, owner-bound staging name. Safe to run outside the task-store lock after
|
|
336
|
+
* revalidating that exact staged name and its persisted partition identity.
|
|
245
337
|
*/
|
|
246
|
-
function removeStagedOmpSessionPartition(stagingPath) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// rather than trusting the caller to have got it from stageOmpSessionPartitionForDeletion.
|
|
250
|
-
if (typeof stagingPath !== 'string' || !path.isAbsolute(stagingPath)) {
|
|
251
|
-
return { deleted: false, reason: `${stagingPath} is not an absolute staged partition path` };
|
|
338
|
+
function removeStagedOmpSessionPartition(stagingPath, ownership) {
|
|
339
|
+
if (!ownership || typeof ownership !== 'object') {
|
|
340
|
+
return { deleted: false, reason: 'no ownership record for staged partition removal' };
|
|
252
341
|
}
|
|
253
|
-
|
|
254
|
-
|
|
342
|
+
|
|
343
|
+
let expectedPartitionPath;
|
|
344
|
+
try {
|
|
345
|
+
expectedPartitionPath = partitionPathFor(ownership.storageRoot, ownership.partitionId);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return { deleted: false, reason: error.message };
|
|
255
348
|
}
|
|
256
|
-
if (
|
|
349
|
+
if (expectedPartitionPath !== ownership.partitionPath) {
|
|
257
350
|
return {
|
|
258
351
|
deleted: false,
|
|
259
|
-
reason: `${
|
|
352
|
+
reason: `${ownership.partitionPath} is not the canonical partition path for ${ownership.partitionId}`,
|
|
260
353
|
};
|
|
261
354
|
}
|
|
355
|
+
const root = ompSessionsRoot(ownership.storageRoot);
|
|
356
|
+
let expectedStagingPath;
|
|
357
|
+
try {
|
|
358
|
+
expectedStagingPath = stagingPathForOwnership(root, ownership);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return {
|
|
361
|
+
deleted: false,
|
|
362
|
+
reason: `could not derive a staging name from the persisted owner identity: ${error.message}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (stagingPath !== expectedStagingPath) {
|
|
366
|
+
return {
|
|
367
|
+
deleted: false,
|
|
368
|
+
reason: `${stagingPath} is not the staged path bound to partition ${ownership.partitionId} and its persisted owner identity`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let staged;
|
|
373
|
+
try {
|
|
374
|
+
staged = pinDirectoryIdentity(stagingPath);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error.code === 'ENOENT') return { deleted: true, reason: 'already absent' };
|
|
377
|
+
return { deleted: false, reason: `${stagingPath}: ${error.message}` };
|
|
378
|
+
}
|
|
379
|
+
if (String(staged.uid) !== currentUid()) {
|
|
380
|
+
return { deleted: false, reason: `${stagingPath} is not owned by the current user` };
|
|
381
|
+
}
|
|
382
|
+
if (ownership.partitionIdentity && !sameIdentity(staged.identity, ownership.partitionIdentity)) {
|
|
383
|
+
return {
|
|
384
|
+
deleted: false,
|
|
385
|
+
reason: `${stagingPath} identity ${staged.identity.device}:${staged.identity.inode} does not match the recorded ${ownership.partitionIdentity.device}:${ownership.partitionIdentity.inode}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
262
389
|
try {
|
|
263
390
|
fs.rmSync(stagingPath, { recursive: true, force: true });
|
|
264
391
|
} catch (error) {
|
|
@@ -279,7 +406,7 @@ function removeStagedOmpSessionPartition(stagingPath) {
|
|
|
279
406
|
function deleteOmpSessionPartition(ownership) {
|
|
280
407
|
const staged = stageOmpSessionPartitionForDeletion(ownership);
|
|
281
408
|
if (!staged.staged) return { deleted: staged.deleted === true, reason: staged.reason };
|
|
282
|
-
return removeStagedOmpSessionPartition(staged.stagingPath);
|
|
409
|
+
return removeStagedOmpSessionPartition(staged.stagingPath, ownership);
|
|
283
410
|
}
|
|
284
411
|
|
|
285
412
|
module.exports = {
|