@the-open-engine/zeroshot 6.24.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.
Files changed (42) hide show
  1. package/cli/index.js +135 -72
  2. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  3. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  4. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  5. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  7. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  8. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  9. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  10. package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
  11. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/provider-registry.js +7 -1
  13. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  14. package/lib/agent-cli-provider/types.d.ts +2 -0
  15. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  16. package/lib/agent-cli-provider/types.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/agent/agent-lifecycle.js +66 -2
  19. package/src/agent/agent-task-executor.js +72 -3
  20. package/src/agent/provider-session.js +125 -2
  21. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  22. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  23. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  24. package/src/agent-cli-provider/provider-registry.ts +7 -1
  25. package/src/agent-cli-provider/types.ts +4 -0
  26. package/src/omp-blob-root.js +110 -0
  27. package/src/omp-config-overlay.js +9 -1
  28. package/src/omp-execution-fingerprint.js +62 -0
  29. package/src/omp-session-limits.js +41 -0
  30. package/src/omp-session-partition.js +424 -0
  31. package/src/omp-session-verifier.js +740 -0
  32. package/task-lib/commands/clean.js +92 -35
  33. package/task-lib/commands/kill.js +21 -0
  34. package/task-lib/commands/resume.js +62 -0
  35. package/task-lib/commands/run.js +80 -0
  36. package/task-lib/omp-session-cleanup.js +197 -0
  37. package/task-lib/omp-session-ownership-schema.js +268 -0
  38. package/task-lib/omp-session-ownership.js +367 -0
  39. package/task-lib/omp-storage-root.js +35 -0
  40. package/task-lib/rpc-watcher.js +368 -2
  41. package/task-lib/runner.js +243 -5
  42. package/task-lib/store.js +106 -76
@@ -1,7 +1,88 @@
1
1
  import { unlinkSync, existsSync } from 'fs';
2
2
  import chalk from 'chalk';
3
- import { loadTasks, saveTasks } from '../store.js';
3
+ import { clearTaskCommandCleanup, loadTasks, removeTaskIfUnchanged } from '../store.js';
4
4
  import { createCommandSpecCleanup } from '../command-spec-cleanup.js';
5
+ import { cleanupOmpSessionPartitionForTask } from '../omp-session-cleanup.js';
6
+
7
+ /**
8
+ * Delete a task's OMP session partition directory as part of removing its row. Every ownership
9
+ * state is cleaned here, including `provisional`: the row is going away, so leaving its partition
10
+ * behind would orphan a directory nothing can ever reclaim. The shared OMP CAS blob root is never
11
+ * touched. An unsafe/unresolvable path — or an ownership record that exists but cannot be read —
12
+ * preserves the record, and therefore the whole task row, with an actionable warning.
13
+ */
14
+ export function cleanUpOmpSessionPartition(task, warn) {
15
+ return cleanupOmpSessionPartitionForTask(task, warn);
16
+ }
17
+
18
+ /**
19
+ * The live-task retention boundary, evaluated before *any* cleanup side effect.
20
+ *
21
+ * A running task owns everything the row points at: its OMP session partition is the working
22
+ * directory of a live provider process, and its command-cleanup receipt names paths that process
23
+ * is still using. This used to be checked only inside the `commandCleanup` branch — i.e. after the
24
+ * OMP partition had already been staged and recursively deleted — so `clean --all` could destroy a
25
+ * live session's transcript for any task that happened not to carry a cleanup receipt.
26
+ */
27
+ function isLiveTask(task) {
28
+ return task.status === 'running';
29
+ }
30
+
31
+ /**
32
+ * Remove one task row that `clean` selected, in the only order that is safe:
33
+ * live-task check, then OMP partition, then command cleanup, then log file, then an owner-fenced
34
+ * row delete.
35
+ *
36
+ * `task` is a snapshot from the caller's single `loadTasks()`, so the delete is conditional on the
37
+ * row still matching it (see removeTaskIfUnchanged). A watcher update, a kill, or a resume's
38
+ * ownership transfer landing mid-cleanup leaves the row in place rather than being reverted by a
39
+ * whole-table rewrite.
40
+ *
41
+ * @returns {{removed: boolean, reason: string|null}} `reason` is a short retention label
42
+ */
43
+ export function removeCleanedTask(task, { warn }) {
44
+ if (isLiveTask(task)) {
45
+ return { removed: false, reason: 'running' };
46
+ }
47
+ if (!cleanUpOmpSessionPartition(task, warn)) {
48
+ return { removed: false, reason: 'OMP partition cleanup pending' };
49
+ }
50
+
51
+ let cleanupCleared = false;
52
+ if (task.commandCleanup) {
53
+ let recovered = false;
54
+ try {
55
+ const cleanup = createCommandSpecCleanup(task.commandCleanup, (cleanupPath, error) => {
56
+ warn(`failed to clean up ${cleanupPath}: ${error.message}`);
57
+ });
58
+ recovered = cleanup.runSync();
59
+ } catch (error) {
60
+ warn(`failed to validate cleanup for task ${task.id}: ${error.message}`);
61
+ }
62
+ if (!recovered) {
63
+ return { removed: false, reason: 'command cleanup pending' };
64
+ }
65
+ cleanupCleared = true;
66
+ }
67
+
68
+ if (task.logFile && existsSync(task.logFile)) {
69
+ unlinkSync(task.logFile);
70
+ }
71
+
72
+ if (
73
+ !removeTaskIfUnchanged(task.id, {
74
+ status: task.status,
75
+ ompSessionOwnership: task.ompSessionOwnership ?? null,
76
+ })
77
+ ) {
78
+ // The row moved on under us. Its side effects are already done, so record the one piece of
79
+ // durable state that would otherwise be retried forever, using a single-column write that
80
+ // cannot clobber whatever the concurrent writer just persisted.
81
+ if (cleanupCleared) clearTaskCommandCleanup(task.id, task.commandCleanup);
82
+ return { removed: false, reason: 'the row changed while it was being cleaned' };
83
+ }
84
+ return { removed: true, reason: null };
85
+ }
5
86
 
6
87
  export function cleanTasks(options = {}) {
7
88
  const tasks = loadTasks();
@@ -36,45 +117,21 @@ export function cleanTasks(options = {}) {
36
117
  console.log(chalk.dim(`Removing ${toRemove.length} task(s)...\n`));
37
118
 
38
119
  for (const task of toRemove) {
39
- if (task.commandCleanup) {
40
- if (task.status === 'running') {
41
- cleanupFailed = true;
42
- console.log(
43
- chalk.yellow(` Retained: ${task.id} [running] (live command cleanup ownership)`)
44
- );
45
- continue;
46
- }
47
- let recovered = false;
48
- try {
49
- const cleanup = createCommandSpecCleanup(task.commandCleanup, (cleanupPath, error) => {
50
- console.log(chalk.yellow(`Warning: failed to clean up ${cleanupPath}: ${error.message}`));
51
- });
52
- recovered = cleanup.runSync();
53
- } catch (error) {
54
- console.log(
55
- chalk.yellow(`Warning: failed to validate cleanup for task ${task.id}: ${error.message}`)
56
- );
57
- }
58
- if (!recovered) {
59
- cleanupFailed = true;
60
- console.log(
61
- chalk.yellow(` Retained: ${task.id} [${task.status}] (command cleanup pending)`)
62
- );
63
- continue;
64
- }
65
- task.commandCleanup = null;
66
- }
67
- if (task.logFile && existsSync(task.logFile)) {
68
- unlinkSync(task.logFile);
120
+ const { removed, reason } = removeCleanedTask(task, {
121
+ warn: (message) => console.log(chalk.yellow(`Warning: ${message}`)),
122
+ });
123
+ if (!removed) {
124
+ cleanupFailed = true;
125
+ console.log(chalk.yellow(` Retained: ${task.id} [${task.status}] (${reason})`));
126
+ continue;
69
127
  }
70
-
71
128
  console.log(chalk.dim(` Removed: ${task.id} [${task.status}]`));
72
- delete tasks[task.id];
73
129
  removedCount++;
74
130
  }
75
131
 
76
- saveTasks(tasks);
77
-
132
+ // No whole-table rewrite here by design. Rows are deleted individually above, each fenced on the
133
+ // snapshot it was validated against, so a concurrent watcher/kill/ownership-transfer write is
134
+ // never reverted by cleanup finishing after it.
78
135
  console.log(chalk.green(`\n✓ Cleaned ${removedCount} task(s)`));
79
136
  if (cleanupFailed) process.exitCode = 1;
80
137
  }
@@ -2,6 +2,25 @@ import chalk from 'chalk';
2
2
  import { getTask, requestTaskCancellation, updateTask } from '../store.js';
3
3
  import { createCommandSpecCleanup } from '../command-spec-cleanup.js';
4
4
  import { terminateProcess } from '../process-termination.js';
5
+ import { retireOmpOwnershipAtTerminalBoundary } from '../omp-session-ownership.js';
6
+
7
+ /**
8
+ * Retire the task's OMP session ownership at a confirmed terminal boundary (killed / stale).
9
+ *
10
+ * A killed task's provisional partition claim would otherwise outlive the process that made it: no
11
+ * watcher is left to reach `finalizeOmpOwnership`, and cleanup refuses to reclaim a partition any
12
+ * row still claims provisionally, so the directory would be unreclaimable forever. Runs *before*
13
+ * the terminal status write so no window exists where the row is terminal but still claiming.
14
+ */
15
+ function retireOmpOwnershipForKilledTask(taskId) {
16
+ retireOmpOwnershipAtTerminalBoundary(taskId, (error) => {
17
+ console.log(
18
+ chalk.yellow(
19
+ `Warning: failed to retire the OMP session ownership of task ${taskId}: ${error.message}`
20
+ )
21
+ );
22
+ });
23
+ }
5
24
 
6
25
  async function cleanupTerminatedTask(task) {
7
26
  if (!task.commandCleanup) return {};
@@ -125,6 +144,7 @@ export async function killTaskCommand(taskId, options = {}) {
125
144
  }
126
145
  console.log(chalk.yellow('Process already dead, updating status...'));
127
146
  const cleanupUpdate = await cleanupTerminatedTask(task);
147
+ retireOmpOwnershipForKilledTask(taskId);
128
148
  updateTask(taskId, {
129
149
  status: 'stale',
130
150
  pid: null,
@@ -147,6 +167,7 @@ export async function killTaskCommand(taskId, options = {}) {
147
167
  if (result.degraded) {
148
168
  console.log(chalk.yellow(`Warning: ${result.degradedReason}`));
149
169
  }
170
+ retireOmpOwnershipForKilledTask(taskId);
150
171
  updateTask(taskId, {
151
172
  status: 'killed',
152
173
  pid: null,
@@ -2,14 +2,76 @@ import chalk from 'chalk';
2
2
  import { createRequire } from 'module';
3
3
  import { getTask } from '../store.js';
4
4
  import { spawnTask } from '../runner.js';
5
+ import { validateOwnedByTask } from '../omp-session-ownership-schema.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  const { providerSupportsCapability } = require('../../lib/provider-names.js');
8
9
 
10
+ /**
11
+ * Manual standalone resume (`zeroshot task resume <id>`) reuses the *exact* persisted partition
12
+ * under the storage root recorded on the owner row, and asserts the complete committed tuple —
13
+ * including the partition identity, which the cluster path can only learn from the row itself.
14
+ * `state === 'committed'` is required: a provisional or cleanup-required record never durably
15
+ * proved a resumable session, so anything less fails closed to a fresh context.
16
+ *
17
+ * This surface is standalone-only, and refuses a `cluster-agent` owner outright.
18
+ *
19
+ * A cluster-agent lineage belongs to a live agent generation: the committed record is the tail of
20
+ * an `agentId`/`clusterId`/iteration chain whose next turn is spawned by that agent process, and
21
+ * only that process can reach the post-hook boundary where a cluster-agent owner may be committed
22
+ * (agent-lifecycle.js#finalizeProviderSessionAfterCommit). Handing those ids to a detached
23
+ * `zeroshot task resume` would transfer the whole lineage onto a row no parent agent knows about
24
+ * or can ever commit: the prior owner's record is cleared by the transfer, the resumed row stays
25
+ * provisional to the end of time, and the partition becomes unreclaimable while the agent that
26
+ * *should* own the continuation silently falls back to a fresh context. Refusing before spawn is
27
+ * what keeps that lineage intact and resumable by its real owner.
28
+ */
29
+ function buildOmpResumeTaskOptions(task) {
30
+ const ownership = validateOwnedByTask(task.ompSessionOwnership, task.id);
31
+ if (!ownership) {
32
+ throw new Error(`Task ${task.id} has no valid OMP session ownership record; refusing resume.`);
33
+ }
34
+ if (ownership.owner.kind !== 'standalone') {
35
+ throw new Error(
36
+ `Task ${task.id} OMP session is owned by cluster agent ${ownership.owner.clusterId}/${ownership.owner.agentId}; manual resume is standalone-only. Let the owning agent continue it.`
37
+ );
38
+ }
39
+ if (ownership.state !== 'committed' || !ownership.session || !ownership.partitionIdentity) {
40
+ throw new Error(
41
+ `Task ${task.id} OMP session ownership is '${ownership.state}', not a committed resumable session; refusing resume.`
42
+ );
43
+ }
44
+ return {
45
+ cwd: ownership.canonicalWorkspace,
46
+ provider: task.provider,
47
+ storageRoot: ownership.storageRoot,
48
+ // A standalone owner carries null cluster/agent ids by schema, so the resumed row is
49
+ // standalone too. They are passed explicitly rather than omitted so this stays an exact
50
+ // lineage copy of the record above, not an inference.
51
+ clusterId: ownership.owner.clusterId,
52
+ agentId: ownership.owner.agentId,
53
+ ompResume: {
54
+ priorOwnerTaskId: task.id,
55
+ partitionId: ownership.partitionId,
56
+ sessionFileName: ownership.session.fileName,
57
+ expectedSessionId: ownership.session.sessionId,
58
+ expectedPartitionIdentity: ownership.partitionIdentity,
59
+ expectedSessionFileIdentity: ownership.session.fileIdentity,
60
+ expectedArtifactManifestDigest: ownership.session.artifactManifestDigest,
61
+ expectedExecutionFingerprint: ownership.session.executionFingerprint,
62
+ expectedSelectedProvider: ownership.session.selectedProvider,
63
+ expectedSelectedModel: ownership.session.selectedModel,
64
+ },
65
+ };
66
+ }
67
+
9
68
  export function buildResumeTaskOptions(task) {
10
69
  if (!providerSupportsCapability(task.provider, 'sessionResume')) {
11
70
  throw new Error(`Provider ${task.provider} does not support safe session resume.`);
12
71
  }
72
+ if (task.provider === 'omp') {
73
+ return buildOmpResumeTaskOptions(task);
74
+ }
13
75
  if (
14
76
  task.requestedResumeSessionId &&
15
77
  (task.status !== 'completed' || task.resumeIdentityVerified !== true)
@@ -1,6 +1,85 @@
1
1
  import chalk from 'chalk';
2
2
  import { shouldUseAttachableWatcher, spawnTask } from '../runner.js';
3
3
 
4
+ // Every field the agent's `providerSession.ompSession` snapshot carries, plus the outer tuple's
5
+ // session ID and the prior owner's task id. All are required: the descriptor is only ever built
6
+ // from a complete committed record, and task-lib/runner.js re-checks every one of them against
7
+ // that record before a task row exists. Missing/extra/mistyped fields fail closed here.
8
+ const OMP_RESUME_STRING_FIELDS = [
9
+ 'priorOwnerTaskId',
10
+ 'partitionId',
11
+ 'sessionFileName',
12
+ 'expectedSessionId',
13
+ 'expectedArtifactManifestDigest',
14
+ 'expectedExecutionFingerprint',
15
+ 'expectedSelectedProvider',
16
+ 'expectedSelectedModel',
17
+ ];
18
+ const OMP_RESUME_IDENTITY_FIELDS = ['expectedSessionFileIdentity'];
19
+ const OMP_RESUME_OPTIONAL_IDENTITY_FIELDS = ['expectedPartitionIdentity'];
20
+ const OMP_RESUME_ALLOWED_FIELDS = new Set([
21
+ ...OMP_RESUME_STRING_FIELDS,
22
+ ...OMP_RESUME_IDENTITY_FIELDS,
23
+ ...OMP_RESUME_OPTIONAL_IDENTITY_FIELDS,
24
+ ]);
25
+
26
+ // Issue #866 fixes device/inode as *canonical unsigned decimal strings*. This descriptor arrives
27
+ // over argv from another process, so the type is checked, never coerced: `String(value.device)`
28
+ // would have accepted the JSON number 42, `new String('42')`, `['42']`, and anything else with a
29
+ // matching toString, then silently canonicalized it into a string the persisted record never
30
+ // contained. A descriptor that does not already carry the canonical form is a descriptor built by
31
+ // something other than this codebase's writer, and it fails closed rather than being repaired.
32
+ const CANONICAL_DECIMAL = /^(0|[1-9][0-9]*)$/;
33
+ const IDENTITY_KEYS = new Set(['device', 'inode']);
34
+
35
+ function isCanonicalDecimalString(value) {
36
+ return typeof value === 'string' && CANONICAL_DECIMAL.test(value);
37
+ }
38
+
39
+ function isIdentityShape(value) {
40
+ return (
41
+ value !== null &&
42
+ typeof value === 'object' &&
43
+ !Array.isArray(value) &&
44
+ Object.keys(value).length === IDENTITY_KEYS.size &&
45
+ Object.keys(value).every((key) => IDENTITY_KEYS.has(key)) &&
46
+ isCanonicalDecimalString(value.device) &&
47
+ isCanonicalDecimalString(value.inode)
48
+ );
49
+ }
50
+
51
+ export function parseOmpResumeDescriptor(raw) {
52
+ if (!raw) return undefined;
53
+ let descriptor;
54
+ try {
55
+ descriptor = JSON.parse(raw);
56
+ } catch (error) {
57
+ throw new Error(`--omp-resume must be a JSON descriptor: ${error.message}`);
58
+ }
59
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
60
+ throw new Error('--omp-resume descriptor must be a JSON object.');
61
+ }
62
+ const unknown = Object.keys(descriptor).filter((key) => !OMP_RESUME_ALLOWED_FIELDS.has(key));
63
+ if (unknown.length > 0) {
64
+ throw new Error(`--omp-resume descriptor has unknown field(s): ${unknown.join(', ')}.`);
65
+ }
66
+ const missing = [
67
+ ...OMP_RESUME_STRING_FIELDS.filter(
68
+ (field) => typeof descriptor[field] !== 'string' || descriptor[field].length === 0
69
+ ),
70
+ ...OMP_RESUME_IDENTITY_FIELDS.filter((field) => !isIdentityShape(descriptor[field])),
71
+ ];
72
+ if (missing.length > 0) {
73
+ throw new Error(`--omp-resume descriptor is missing/invalid field(s): ${missing.join(', ')}.`);
74
+ }
75
+ for (const field of OMP_RESUME_OPTIONAL_IDENTITY_FIELDS) {
76
+ if (descriptor[field] !== undefined && !isIdentityShape(descriptor[field])) {
77
+ throw new Error(`--omp-resume descriptor field ${field} is not a device/inode identity.`);
78
+ }
79
+ }
80
+ return descriptor;
81
+ }
82
+
4
83
  export async function runTask(prompt, options = {}) {
5
84
  if (!prompt || prompt.trim().length === 0) {
6
85
  console.log(chalk.red('Error: Prompt is required'));
@@ -36,6 +115,7 @@ export async function runTask(prompt, options = {}) {
36
115
  provider: options.provider,
37
116
  resume: options.resume,
38
117
  continue: options.continue,
118
+ ompResume: parseOmpResumeDescriptor(options.ompResume),
39
119
  outputFormat,
40
120
  jsonSchema,
41
121
  mcpConfig: options.mcpConfig,
@@ -0,0 +1,197 @@
1
+ // The single implementation behind all three OMP session-partition cleanup surfaces required by
2
+ // issue #866: standalone task `clean` (task-lib/commands/clean.js), cluster clear
3
+ // (cli/index.js deleteClusterData, also reached by `zeroshot purge`), and global `purge`
4
+ // (cli/index.js, which runs cluster clear then `clean --all`).
5
+ //
6
+ // Three invariants hold on every surface:
7
+ // * A committed session stays available for resume until its own task record is being removed —
8
+ // cleanup is driven by the task row, never by scanning the partition tree for orphans.
9
+ // * A partition is never deleted while any *other* row holds an authoritative claim on it.
10
+ // Several rows can name one partition at once: a resumed row is inserted before its owner
11
+ // transfer runs, so two competing resumes of one committed session put three rows on it. The
12
+ // fence is therefore over every authoritative row (provisional or committed), never over the
13
+ // committed rows alone — after a transfer the live owner is `provisional` and no row is
14
+ // committed at all.
15
+ // * The shared, machine-wide OMP CAS blob root (src/omp-blob-root.js) is never touched. Blobs
16
+ // are addressed from *other* sessions' JSONL too, so deleting one is data loss for unrelated
17
+ // work; stageOmpSessionPartitionForDeletion refuses any path that resolves inside it.
18
+ //
19
+ // An unsafe or unresolvable path preserves the owner record with an actionable warning instead of
20
+ // deleting, so the operator can inspect it and the cleanup stays durably retryable.
21
+ import {
22
+ loadTasks,
23
+ updateTask,
24
+ getTaskStoreDatabase,
25
+ hasUnreadableOmpSessionOwnership,
26
+ } from './store.js';
27
+ import { findAuthoritativeOwnersForPartition } from './omp-session-ownership.js';
28
+ import {
29
+ serializeOmpSessionOwnership,
30
+ validateOwnedByTask,
31
+ } from './omp-session-ownership-schema.js';
32
+ import { createRequire } from 'module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const {
36
+ removeStagedOmpSessionPartition,
37
+ stageOmpSessionPartitionForDeletion,
38
+ } = require('../src/omp-session-partition.js');
39
+
40
+ /**
41
+ * Take the partition away from its canonical name under a task-store write fence, then remove the
42
+ * staged tree outside it.
43
+ *
44
+ * The fence spans exactly three steps: "this row still holds the record cleanup validated", "no
45
+ * other row holds an authoritative claim on this partition", and "the partition no longer answers
46
+ * to its canonical name". Without it, a resume could insert its provisional row — or win its
47
+ * ownership transfer — in the gap between the checks and the rename, and cleanup would move a live
48
+ * session out from under it. `BEGIN IMMEDIATE` takes the write lock up front, so every competing
49
+ * ownership write is serialized either wholly before the checks (and is therefore seen by them) or
50
+ * wholly after the rename, where it lands on a partition that is already gone and fails that turn
51
+ * closed rather than costing a live one its data.
52
+ *
53
+ * The row re-read closes the same race from the caller's side: every cleanup surface iterates a
54
+ * task snapshot taken by an earlier `loadTasks()`, and a resume that transferred this partition
55
+ * away in the meantime leaves that snapshot describing a record the row no longer holds — acting
56
+ * on it would delete the partition on behalf of an owner that has already released it. `store.js`
57
+ * writes this column only through `serializeOmpSessionOwnership`, whose output is canonical per
58
+ * record, so comparing the stored bytes is an exact "still the same record" test.
59
+ *
60
+ * The recursive removal runs *after* the fence is released. By then the tree only answers to its
61
+ * deterministic owner-bound staging name, and every retry revalidates that name and identity.
62
+ * Holding a write lock across an arbitrarily large `rm -r` would stall every other store writer.
63
+ */
64
+ function describeBlockingOwner(owner) {
65
+ if (owner.unknown) {
66
+ return `${owner.taskId} (ownership record is unreadable or invalid; inspect or repair that task row)`;
67
+ }
68
+ return `${owner.taskId} (${owner.state})`;
69
+ }
70
+
71
+ function stageUnderOwnerFence(ownership, taskId) {
72
+ const database = getTaskStoreDatabase();
73
+ const expectedRecord = serializeOmpSessionOwnership(ownership);
74
+ const readRecord = database.prepare(
75
+ 'SELECT omp_session_ownership AS record FROM tasks WHERE id = ?'
76
+ );
77
+ const fenced = database.transaction(() => {
78
+ const row = readRecord.get(taskId);
79
+ if (!row || row.record !== expectedRecord) {
80
+ return {
81
+ staged: false,
82
+ deleted: false,
83
+ reason: 'its ownership record changed while cleanup was running',
84
+ };
85
+ }
86
+ const owners = findAuthoritativeOwnersForPartition(ownership.partitionId, taskId, database);
87
+ if (owners.length > 0) {
88
+ return {
89
+ staged: false,
90
+ deleted: false,
91
+ reason: `it is still claimed by ${owners.map(describeBlockingOwner).join(', ')}`,
92
+ };
93
+ }
94
+ return stageOmpSessionPartitionForDeletion(ownership);
95
+ });
96
+ // BEGIN IMMEDIATE: take the write lock up front so the checks cannot run under a shared lock that
97
+ // another writer is simultaneously upgrading.
98
+ return fenced.immediate();
99
+ }
100
+
101
+ /**
102
+ * Delete one task's OMP session partition. Returns true when the task row is now safe to remove
103
+ * (nothing to clean, or the partition is gone).
104
+ *
105
+ * @param {object} task task record as returned by the store
106
+ * @param {(message: string) => void} warn receives an actionable message for a retained partition
107
+ * @param {{clearRecord?: boolean}} options `clearRecord` NULLs the row's ownership after a
108
+ * successful delete — required on surfaces (cluster clear) where the row itself survives.
109
+ */
110
+ export function cleanupOmpSessionPartitionForTask(task, warn, { clearRecord = false } = {}) {
111
+ // A SQL-NULL ownership column is exact truth that this task never allocated a partition: there
112
+ // is nothing to clean and the row is free to go. An *unreadable* column is the opposite — some
113
+ // partition may exist that only this row still points at — so the row and its evidence are
114
+ // retained for an operator instead of being deleted into an orphan. The malformed bytes are
115
+ // never parsed, canonicalized, or otherwise acted on.
116
+ if (hasUnreadableOmpSessionOwnership(task)) {
117
+ warn(
118
+ `Task ${task.id}: OMP session ownership record is present but unreadable; retaining the task row and its record for inspection. Nothing was deleted, and any partition it named must be reclaimed manually.`
119
+ );
120
+ return false;
121
+ }
122
+ if (!task?.ompSessionOwnership) return true;
123
+ const ownership = validateOwnedByTask(task.ompSessionOwnership, task.id);
124
+ if (!ownership) {
125
+ warn(
126
+ `Task ${task.id}: retained an OMP session ownership record that failed validation; nothing was deleted.`
127
+ );
128
+ return false;
129
+ }
130
+
131
+ // More than one row can name a single partition: a resume inserts its provisional row before its
132
+ // ownership transfer runs, and two competing resumes of one committed session put three rows on
133
+ // it. The fence is therefore every *authoritative* (provisional or committed) claim other than
134
+ // this row's — a crashed-before-transfer resume must not delete the prior owner's still-resumable
135
+ // session, and a losing competing resume must not delete the winner's live one.
136
+ const staged = stageUnderOwnerFence(ownership, task.id);
137
+ if (!staged.staged) {
138
+ if (staged.deleted) return finishCleanup(task, clearRecord);
139
+ warn(
140
+ `Task ${task.id}: retained OMP session partition ${ownership.partitionId} (${staged.reason}).`
141
+ );
142
+ return false;
143
+ }
144
+
145
+ const { deleted, reason } = removeStagedOmpSessionPartition(staged.stagingPath, ownership);
146
+ if (!deleted) {
147
+ warn(`Task ${task.id}: retained OMP session partition ${ownership.partitionId} (${reason}).`);
148
+ return false;
149
+ }
150
+ return finishCleanup(task, clearRecord);
151
+ }
152
+
153
+ function finishCleanup(task, clearRecord) {
154
+ if (clearRecord) {
155
+ updateTask(task.id, { ompSessionOwnership: null });
156
+ }
157
+ return true;
158
+ }
159
+
160
+ /**
161
+ * Delete every OMP session partition owned by a cluster's agents. Cluster partitions live under
162
+ * the cluster's own `storageDir`, so this is what makes cluster clear (and therefore purge)
163
+ * actually reclaim them; the task rows themselves survive and have their ownership cleared.
164
+ *
165
+ * A row whose ownership column is present but unreadable cannot be attributed to a cluster at all
166
+ * — the owner tuple is exactly what is unreadable — so it is reported separately (`unreadable`)
167
+ * rather than silently skipped. Cluster clear keeps task rows, so the evidence survives either way;
168
+ * the warning is what tells the operator a partition may need reclaiming by hand.
169
+ *
170
+ * @returns {{deleted: string[], retained: string[], unreadable: string[]}} partition ids, plus the
171
+ * task ids whose ownership record could not be read
172
+ */
173
+ export function cleanupOmpSessionPartitionsForCluster(clusterId, warn) {
174
+ const deleted = [];
175
+ const retained = [];
176
+ const unreadable = [];
177
+ if (!clusterId) return { deleted, retained, unreadable };
178
+
179
+ for (const task of Object.values(loadTasks())) {
180
+ if (hasUnreadableOmpSessionOwnership(task)) {
181
+ unreadable.push(task.id);
182
+ warn(
183
+ `Task ${task.id}: OMP session ownership record is present but unreadable, so it cannot be attributed to a cluster; the row and its record are retained for inspection.`
184
+ );
185
+ continue;
186
+ }
187
+ const ownership = validateOwnedByTask(task?.ompSessionOwnership ?? null, task?.id);
188
+ if (!ownership || ownership.owner.kind !== 'cluster-agent') continue;
189
+ if (ownership.owner.clusterId !== clusterId) continue;
190
+ if (cleanupOmpSessionPartitionForTask(task, warn, { clearRecord: true })) {
191
+ deleted.push(ownership.partitionId);
192
+ } else {
193
+ retained.push(ownership.partitionId);
194
+ }
195
+ }
196
+ return { deleted, retained, unreadable };
197
+ }