@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 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
- (task) => task.status === 'running' && isProcessRunning(task.pid)
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 clusterResults = await orchestrator.killAll();
2142
- for (const id of clusterResults.killed) {
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
- for (const err of clusterResults.errors) {
2146
- console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
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, isProcessRunning) {
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 { killTask } = await import('../task-lib/runner.js');
2156
- const { updateTask } = await import('../task-lib/store.js');
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
- if (!isProcessRunning(task.pid)) {
2160
- updateTask(task.id, {
2161
- status: 'stale',
2162
- error: 'Process died unexpectedly',
2163
- });
2164
- console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
2165
- continue;
2166
- }
2167
-
2168
- const killed = killTask(task.pid);
2169
- if (killed) {
2170
- updateTask(task.id, { status: 'killed', error: 'Killed by clear' });
2171
- console.log(chalk.green(`✓ Killed task: ${task.id}`));
2172
- } else {
2173
- console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
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 } = await import(
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
- // Kill clusters
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
- const killed = killTask(task.pid);
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, purgeData.isProcessRunning);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-open-engine/zeroshot",
3
- "version": "6.25.0",
3
+ "version": "6.25.1",
4
4
  "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -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
- if (!Object.keys(value).every((key) => IDENTITY_KEYS.has(key))) return null;
71
- if (!DECIMAL_STRING.test(String(value.device)) || !DECIMAL_STRING.test(String(value.inode))) {
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
- return { device: String(value.device), inode: String(value.inode) };
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(ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null)
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
- module.exports = { OMP_SESSION_LIMITS };
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 an unguessable `.zeroshot-deleting-<uuid>` name
92
- * and only then re-pinned. `rename(2)` is atomic, so after it succeeds the object under that fresh
93
- * name can no longer be swapped by racing the original path; the post-rename identity comparison
94
- * proves it is still the same directory that passed validation, and any mismatch aborts with the
95
- * directory parked under its clearly-marked name rather than recursively deleting an unknown tree.
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 { staged: false, deleted: false, reason: `${storageRoot} is not owned by the current user` };
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 === 'ENOENT') return { staged: false, deleted: true, reason: 'already absent' };
189
- if (error.code === 'ELOOP' || error.code === 'EMLINK') {
190
- return { staged: false, deleted: false, reason: `${expectedPartitionPath} is a symlink; refusing to delete` };
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
- if (error.code === 'ENOTDIR') {
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: `${expectedPartitionPath} is not a real directory; refusing to delete`,
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 { staged: false, deleted: false, reason: `${expectedPartitionPath} is not owned by the current user` };
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
- if (error.code === 'ENOENT') return { staged: false, deleted: true, reason: 'already absent' };
217
- return { staged: false, deleted: false, reason: `could not stage ${expectedPartitionPath}: ${error.message}` };
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} already parked under
243
- * its unguessable staging name. Safe to run outside any lock — the tree no longer answers to a name
244
- * anything else knows.
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
- // This is an exported recursive delete, so it re-derives that its argument really is a staging
248
- // name this module minted a direct `.zeroshot-deleting-*` child of an `omp-sessions/` root —
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
- if (!path.basename(stagingPath).startsWith(DELETING_PREFIX)) {
254
- return { deleted: false, reason: `${stagingPath} is not a staged partition directory` };
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 (path.basename(path.dirname(stagingPath)) !== OMP_SESSIONS_SUBDIR) {
349
+ if (expectedPartitionPath !== ownership.partitionPath) {
257
350
  return {
258
351
  deleted: false,
259
- reason: `${stagingPath} does not live directly under an ${OMP_SESSIONS_SUBDIR}/ root`,
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 = {