@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,16 +1,29 @@
1
1
  import { fork } from 'child_process';
2
- import { join, dirname } from 'path';
2
+ import { join, dirname, resolve as resolvePath } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { mkdirSync } from 'fs';
4
5
  import { LOGS_DIR } from './config.js';
5
- import { addTask, generateId, ensureDirs } from './store.js';
6
+ import { addTask, generateId, ensureDirs, updateTask } from './store.js';
7
+ import {
8
+ isOmpSessionlessRun,
9
+ resolveOmpStorageRoot,
10
+ resolveOmpOwnerKind,
11
+ } from './omp-storage-root.js';
12
+ import {
13
+ readOwnership,
14
+ retireOmpOwnershipAtTerminalBoundary,
15
+ writeProvisionalOwnership,
16
+ } from './omp-session-ownership.js';
6
17
  import { createRequire } from 'module';
7
18
 
8
19
  const require = createRequire(import.meta.url);
9
20
  const {
10
21
  buildOmpPrompt,
11
22
  getProviderRegistryEntry,
23
+ normalizeProviderName,
12
24
  prepareSingleAgentProviderCommand,
13
25
  } = require('./provider-helper-runtime.js');
26
+ const { getDefaultProviderId } = require('../lib/provider-names.js');
14
27
  const {
15
28
  ISOLATED_SETTINGS_FILE_ENV,
16
29
  ISOLATED_SETTINGS_FILE_MARKER,
@@ -23,6 +36,11 @@ const {
23
36
  } = require('../src/worktree-claude-config');
24
37
  const { TASK_SPAWN_OWNERSHIP_TOKEN_ENV } = require('../src/task-spawn-cleanup-ownership');
25
38
  const { sendWatcherPrompt } = require('../src/watcher-prompt-channel');
39
+ const {
40
+ generateOmpPartitionId,
41
+ partitionPathFor,
42
+ createOmpSessionPartitionDirectory,
43
+ } = require('../src/omp-session-partition');
26
44
  export {
27
45
  isOwnedProcessTreeRunning,
28
46
  isProcessRunning,
@@ -32,6 +50,194 @@ export {
32
50
 
33
51
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
52
 
53
+ /**
54
+ * Cross-check the caller-supplied resume descriptor against the prior owner's *persisted* record
55
+ * and return the expectation the watcher will re-verify.
56
+ *
57
+ * The descriptor arrives over argv (from the agent's own `providerSession.ompSession` snapshot, or
58
+ * from `zeroshot task resume`), so it is never trusted on its own: the task row named by
59
+ * `priorOwnerTaskId` is the authority, and every field the descriptor asserts must match it
60
+ * exactly. A descriptor and a row that disagree are conflicting identities and fail closed here,
61
+ * before a task row is even created — the "conflicting IDs never reach a resume prompt" case.
62
+ */
63
+ export function resolveOmpResumeExpectation({ descriptor, storageRoot, canonicalWorkspace }) {
64
+ const prior = readOwnership(descriptor.priorOwnerTaskId);
65
+ if (!prior) {
66
+ throw new Error(
67
+ `OMP resume: task ${descriptor.priorOwnerTaskId} has no valid OMP session ownership record.`
68
+ );
69
+ }
70
+ if (prior.state !== 'committed' || !prior.session || !prior.partitionIdentity) {
71
+ throw new Error(
72
+ `OMP resume: task ${descriptor.priorOwnerTaskId} ownership is '${prior.state}', not a committed resumable session.`
73
+ );
74
+ }
75
+ if (prior.storageRoot !== resolvePath(storageRoot)) {
76
+ throw new Error(
77
+ `OMP resume: storage root ${resolvePath(storageRoot)} does not match the recorded ${prior.storageRoot}.`
78
+ );
79
+ }
80
+ // Moved/deleted workspace and "existing-but-wrong recorded cwd": a session belongs to the
81
+ // workspace it was recorded against and may never be continued from a different one.
82
+ if (prior.canonicalWorkspace !== resolvePath(canonicalWorkspace)) {
83
+ throw new Error(
84
+ `OMP resume: workspace ${resolvePath(canonicalWorkspace)} does not match the recorded ${prior.canonicalWorkspace}.`
85
+ );
86
+ }
87
+
88
+ const mismatches = [];
89
+ const requireExact = (label, actual, expected) => {
90
+ if (actual !== expected) mismatches.push(`${label} (${actual} != ${expected})`);
91
+ };
92
+ requireExact('partitionId', descriptor.partitionId, prior.partitionId);
93
+ requireExact('sessionId', descriptor.expectedSessionId, prior.session.sessionId);
94
+ requireExact('sessionFileName', descriptor.sessionFileName, prior.session.fileName);
95
+ requireExact(
96
+ 'sessionFileIdentity',
97
+ `${descriptor.expectedSessionFileIdentity?.device}:${descriptor.expectedSessionFileIdentity?.inode}`,
98
+ `${prior.session.fileIdentity.device}:${prior.session.fileIdentity.inode}`
99
+ );
100
+ // partitionIdentity is deliberately absent from the agent's `providerSession.ompSession`
101
+ // snapshot (issue #866 fixes that field list, and the snapshot never carries partition paths or
102
+ // storage-root state), so it is authoritative from the row only. `zeroshot task resume`, which
103
+ // reads the row directly, does assert it — check it whenever it is supplied.
104
+ if (descriptor.expectedPartitionIdentity !== undefined) {
105
+ requireExact(
106
+ 'partitionIdentity',
107
+ `${descriptor.expectedPartitionIdentity?.device}:${descriptor.expectedPartitionIdentity?.inode}`,
108
+ `${prior.partitionIdentity.device}:${prior.partitionIdentity.inode}`
109
+ );
110
+ }
111
+ requireExact(
112
+ 'artifactManifestDigest',
113
+ descriptor.expectedArtifactManifestDigest,
114
+ prior.session.artifactManifestDigest
115
+ );
116
+ requireExact(
117
+ 'executionFingerprint',
118
+ descriptor.expectedExecutionFingerprint,
119
+ prior.session.executionFingerprint
120
+ );
121
+ requireExact(
122
+ 'selectedProvider',
123
+ descriptor.expectedSelectedProvider,
124
+ prior.session.selectedProvider
125
+ );
126
+ requireExact('selectedModel', descriptor.expectedSelectedModel, prior.session.selectedModel);
127
+ if (mismatches.length > 0) {
128
+ throw new Error(
129
+ `OMP resume: descriptor conflicts with the persisted owner record: ${mismatches.join(', ')}.`
130
+ );
131
+ }
132
+
133
+ return {
134
+ priorOwnerTaskId: descriptor.priorOwnerTaskId,
135
+ partitionId: prior.partitionId,
136
+ partitionPath: prior.partitionPath,
137
+ canonicalWorkspace: prior.canonicalWorkspace,
138
+ sessionFileName: prior.session.fileName,
139
+ sessionFilePath: join(prior.partitionPath, prior.session.fileName),
140
+ expectedSessionId: prior.session.sessionId,
141
+ expectedPartitionIdentity: prior.partitionIdentity,
142
+ expectedSessionFileIdentity: prior.session.fileIdentity,
143
+ expectedArtifactManifestDigest: prior.session.artifactManifestDigest,
144
+ expectedExecutionFingerprint: prior.session.executionFingerprint,
145
+ expectedSelectedProvider: prior.session.selectedProvider,
146
+ expectedSelectedModel: prior.session.selectedModel,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Resolve this task's OMP session plan, or null for every other provider / structured-output
152
+ * recovery turn. Allocates (but does not yet create on disk) a fresh partition, or resolves the
153
+ * resume descriptor against the prior owner's persisted record. The partition directory itself is
154
+ * created only after the task row is durable (see spawnTask below — row-before-directory), and
155
+ * the structural/identity/fingerprint verification plus the owner transfer happen in the rpc-stdio
156
+ * watcher, not here.
157
+ */
158
+ function resolveOmpSessionPlan({ id, cwd, options }) {
159
+ if (options.structuredOutputRecovery) return null;
160
+ const providerName = normalizeProviderName(options.provider || getDefaultProviderId());
161
+ if (providerName !== 'omp') return null;
162
+ // Docker stays fresh-only (issue #866). Returning null here means no partition is allocated, no
163
+ // ownership row is written, and the adapter falls back to `--no-session`.
164
+ if (isOmpSessionlessRun(options)) return null;
165
+
166
+ const storageRoot = resolveOmpStorageRoot(options);
167
+ mkdirSync(storageRoot, { recursive: true });
168
+ const ownerKind = resolveOmpOwnerKind(options);
169
+ const owner = { ...ownerKind, taskId: id };
170
+
171
+ if (options.ompResume) {
172
+ const expectation = resolveOmpResumeExpectation({
173
+ descriptor: options.ompResume,
174
+ storageRoot,
175
+ canonicalWorkspace: cwd,
176
+ });
177
+ return {
178
+ session: {
179
+ kind: 'resume',
180
+ partition: { path: expectation.partitionPath },
181
+ file: { path: expectation.sessionFilePath },
182
+ },
183
+ resumeExpectation: expectation,
184
+ provisionalOwnership: writeProvisionalOwnership({
185
+ partitionId: expectation.partitionId,
186
+ storageRoot,
187
+ canonicalWorkspace: cwd,
188
+ owner,
189
+ }),
190
+ createDirectory: () => {}, // must already exist; the watcher verifies before spawn
191
+ };
192
+ }
193
+
194
+ const partitionId = generateOmpPartitionId();
195
+ const partitionPath = partitionPathFor(storageRoot, partitionId);
196
+ return {
197
+ session: { kind: 'fresh', partition: { path: partitionPath } },
198
+ resumeExpectation: null,
199
+ provisionalOwnership: writeProvisionalOwnership({
200
+ partitionId,
201
+ storageRoot,
202
+ canonicalWorkspace: cwd,
203
+ owner,
204
+ }),
205
+ createDirectory: () => createOmpSessionPartitionDirectory(partitionPath),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Close a spawn that failed after its row was written but before anything could own the task.
211
+ *
212
+ * Two durable transitions, in this order and both idempotent, so a retry or a crash-recovery
213
+ * replay converges on the same state:
214
+ * 1. the OMP ownership record is retired to `cleanup-required`, releasing the partition claim
215
+ * that would otherwise block every future reclaim of that directory;
216
+ * 2. the task row reaches a terminal status, so nothing downstream (status, kill, resume, the
217
+ * stuck-task recovery sweep) keeps treating it as a live run.
218
+ *
219
+ * The decision comes from this boundary alone. Whether the partition directory exists is not
220
+ * consulted and must not be: a partial mkdir and a clean failure are indistinguishable on disk,
221
+ * and the row is the only thing that knows a spawn was attempted at all.
222
+ */
223
+ function failSpawnAtProvisionalBoundary(id, error) {
224
+ retireOmpOwnershipAtTerminalBoundary(id, (ownershipError) => {
225
+ console.warn(
226
+ `Warning: failed to retire the OMP session ownership of task ${id}: ${ownershipError.message}`
227
+ );
228
+ });
229
+ try {
230
+ updateTask(id, {
231
+ status: 'failed',
232
+ pid: null,
233
+ exitCode: 1,
234
+ error: `Task spawn failed before the provider started: ${error.message}`,
235
+ });
236
+ } catch (updateError) {
237
+ console.warn(`Warning: failed to mark task ${id} failed: ${updateError.message}`);
238
+ }
239
+ }
240
+
35
241
  export function spawnTask(prompt, options = {}) {
36
242
  ensureDirs();
37
243
 
@@ -41,10 +247,12 @@ export function spawnTask(prompt, options = {}) {
41
247
 
42
248
  const outputFormat = resolveOutputFormat(options);
43
249
  const jsonSchema = resolveJsonSchema(options, outputFormat);
250
+ const ompPlan = resolveOmpSessionPlan({ id, cwd, options });
44
251
  const prepared = prepareTaskProviderCommandFromResolved(prompt, options, {
45
252
  outputFormat,
46
253
  jsonSchema,
47
254
  cwd,
255
+ ompSession: ompPlan?.session,
48
256
  });
49
257
  const providerName = prepared.adapter.id;
50
258
  const modelSpec = prepared.options.modelSpec;
@@ -59,16 +267,34 @@ export function spawnTask(prompt, options = {}) {
59
267
  providerName,
60
268
  modelSpec,
61
269
  commandSpec,
270
+ ompSessionOwnership: ompPlan?.provisionalOwnership ?? null,
62
271
  });
63
272
 
273
+ // Row-before-directory: the SQL row is durable proof of an attempted allocation before the
274
+ // partition directory (or anything else OMP-owned) exists on disk. A *crash* between these two
275
+ // lines leaves a provisional row pointing at a path with nothing there yet — cleanup safely
276
+ // no-ops on a nonexistent path, and normal task-lifecycle recovery handles the row itself.
277
+ //
278
+ // A *thrown* materialization failure is different, and must not be left to recovery: this
279
+ // process is still alive and owns the row, so it has to close the boundary itself. Without this,
280
+ // an EACCES/ENOSPC mkdir left a row that looks forever like a live task holding a live
281
+ // provisional claim on a partition — permanently unreclaimable, because cleanup refuses to touch
282
+ // a partition any other row still claims provisionally.
64
283
  addTask(task);
284
+ try {
285
+ ompPlan?.createDirectory();
286
+ } catch (error) {
287
+ failSpawnAtProvisionalBoundary(id, error);
288
+ throw error;
289
+ }
65
290
 
66
291
  const watcherConfig = buildWatcherConfig(
67
292
  outputFormat,
68
293
  jsonSchema,
69
294
  options,
70
295
  providerName,
71
- commandSpec
296
+ commandSpec,
297
+ ompPlan
72
298
  );
73
299
  const watcherScript = resolveWatcherScript(
74
300
  {
@@ -137,6 +363,7 @@ function buildProviderOptions(options, runtime, modelSelection) {
137
363
  ...(structuredOutputRecovery ? {} : mcpConfigOption(options)),
138
364
  ...claudeSettingsFileOption(),
139
365
  ...(!structuredOutputRecovery && options.resume ? { resumeSessionId: options.resume } : {}),
366
+ ...(runtime.ompSession ? { ompSession: runtime.ompSession } : {}),
140
367
  ...(process.env.ZEROSHOT_OPENCODE_AGENT?.trim()
141
368
  ? { agentName: process.env.ZEROSHOT_OPENCODE_AGENT.trim() }
142
369
  : {}),
@@ -218,6 +445,7 @@ export function buildTaskRecord({
218
445
  providerName,
219
446
  modelSpec,
220
447
  commandSpec = {},
448
+ ompSessionOwnership = null,
221
449
  }) {
222
450
  return {
223
451
  id,
@@ -251,6 +479,7 @@ export function buildTaskRecord({
251
479
  terminationStrategy: null,
252
480
  cancelRequested: false,
253
481
  spawnOwnershipToken: process.env[TASK_SPAWN_OWNERSHIP_TOKEN_ENV] || null,
482
+ ompSessionOwnership,
254
483
  commandCleanup:
255
484
  commandSpec.cleanup?.length > 0
256
485
  ? {
@@ -267,8 +496,8 @@ function isRpcStdioLane(providerName) {
267
496
 
268
497
  // The returned object is JSON-serialized into the detached watcher's argv, so it must never carry
269
498
  // prompt or other task content: `ps` and /proc/<pid>/cmdline expose argv to every local user for
270
- // the whole lifetime of the watcher.
271
- function buildWatcherConfig(outputFormat, jsonSchema, options, providerName, commandSpec) {
499
+ // the whole lifetime of the watcher. Partition paths, ids, and digests are not secret.
500
+ function buildWatcherConfig(outputFormat, jsonSchema, options, providerName, commandSpec, ompPlan) {
272
501
  return {
273
502
  outputFormat,
274
503
  jsonSchema,
@@ -278,6 +507,15 @@ function buildWatcherConfig(outputFormat, jsonSchema, options, providerName, com
278
507
  command: commandSpec.binary,
279
508
  env: commandSpec.env || {},
280
509
  commandSpec: buildWatcherCommandSpec(commandSpec, isRpcStdioLane(providerName)),
510
+ ...(ompPlan
511
+ ? {
512
+ ompSession: ompPlan.session,
513
+ ompResumeExpectation: ompPlan.resumeExpectation,
514
+ // The workspace the ownership row was canonicalized against; the watcher compares it to
515
+ // the session header's own recorded cwd after materialization.
516
+ ompCanonicalWorkspace: ompPlan.provisionalOwnership.canonicalWorkspace,
517
+ }
518
+ : {}),
281
519
  };
282
520
  }
283
521
 
package/task-lib/store.js CHANGED
@@ -10,9 +10,14 @@ import { join } from 'path';
10
10
  import Database from 'better-sqlite3';
11
11
  import { TASKS_DIR, LOGS_DIR } from './config.js';
12
12
  import { generateName } from './name-generator.js';
13
+ import {
14
+ inspectStoredOmpSessionOwnership,
15
+ parseOmpSessionOwnership,
16
+ serializeOmpSessionOwnership,
17
+ } from './omp-session-ownership-schema.js';
13
18
 
14
19
  const DB_FILE = join(TASKS_DIR, 'store.db');
15
- export const TASK_STORE_SCHEMA_VERSION = 4;
20
+ export const TASK_STORE_SCHEMA_VERSION = 5;
16
21
 
17
22
  /** @type {Database.Database | null} */
18
23
  let db = null;
@@ -59,7 +64,8 @@ function getDb() {
59
64
  termination_strategy TEXT,
60
65
  command_cleanup TEXT,
61
66
  cancel_requested INTEGER DEFAULT 0,
62
- spawn_ownership_token TEXT
67
+ spawn_ownership_token TEXT,
68
+ omp_session_ownership TEXT
63
69
  );
64
70
 
65
71
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
@@ -110,6 +116,15 @@ function serializeCommandCleanup(value) {
110
116
  return value ? JSON.stringify(value) : null;
111
117
  }
112
118
 
119
+ /**
120
+ * Internal accessor for modules that need direct prepared-statement access (SQL compare-and-swap
121
+ * transitions) beyond what the generic load/save/update helpers below offer. See
122
+ * task-lib/omp-session-ownership.js.
123
+ */
124
+ export function getTaskStoreDatabase() {
125
+ return getDb();
126
+ }
127
+
113
128
  export function migrateTaskStore(database) {
114
129
  ensureTaskColumn(database, 'process_group_id', 'INTEGER');
115
130
  ensureTaskColumn(database, 'termination_strategy', 'TEXT');
@@ -119,6 +134,9 @@ export function migrateTaskStore(database) {
119
134
  ensureTaskColumn(database, 'requested_resume_session_id', 'TEXT');
120
135
  ensureTaskColumn(database, 'session_id_conflict', 'INTEGER NOT NULL DEFAULT 0');
121
136
  ensureTaskColumn(database, 'resume_identity_verified', 'INTEGER NOT NULL DEFAULT 0');
137
+ // No backfill: every pre-v5 row has no OMP session concept, so NULL is exact truth, never a
138
+ // fabricated value. A non-OMP task's resume path is untouched by this column.
139
+ ensureTaskColumn(database, 'omp_session_ownership', 'TEXT');
122
140
  database.exec(`
123
141
  CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_spawn_ownership_token
124
142
  ON tasks(spawn_ownership_token)
@@ -144,6 +162,9 @@ export function migrateTaskStore(database) {
144
162
  if (version < 4) {
145
163
  database.prepare('UPDATE tasks SET resume_identity_verified = 0').run();
146
164
  }
165
+ if (version < 5) {
166
+ // omp_session_ownership already defaults to NULL via ensureTaskColumn above; no backfill.
167
+ }
147
168
  database.pragma(`user_version = ${TASK_STORE_SCHEMA_VERSION}`);
148
169
  })();
149
170
  }
@@ -192,9 +213,23 @@ function rowToTask(row) {
192
213
  commandCleanup: parseCommandCleanup(row.command_cleanup),
193
214
  cancelRequested: Boolean(row.cancel_requested),
194
215
  spawnOwnershipToken: row.spawn_ownership_token,
216
+ ompSessionOwnership: parseOmpSessionOwnership(row.omp_session_ownership),
217
+ // Raw-presence seam (see inspectStoredOmpSessionOwnership). `ompSessionOwnership: null` alone
218
+ // cannot distinguish "this task never had an OMP session" from "this task's owner record is
219
+ // unreadable", and those demand opposite handling: the first is nothing to clean, the second
220
+ // means a partition may exist that only this row still points at. The malformed bytes
221
+ // themselves are deliberately not exposed — nothing may act on them.
222
+ ompSessionOwnershipPresent: inspectStoredOmpSessionOwnership(row.omp_session_ownership).present,
195
223
  };
196
224
  }
197
225
 
226
+ /** True when a task row carries an `omp_session_ownership` value that exists but cannot be read as
227
+ * the closed schema. Such a row is retained by every cleanup surface with a warning: deleting it
228
+ * would orphan whatever partition the unreadable record described. */
229
+ export function hasUnreadableOmpSessionOwnership(task) {
230
+ return Boolean(task?.ompSessionOwnershipPresent) && !task?.ompSessionOwnership;
231
+ }
232
+
198
233
  /**
199
234
  * Load all tasks as object keyed by id
200
235
  * @returns {Object.<string, Object>}
@@ -209,77 +244,6 @@ export function loadTasks() {
209
244
  return tasks;
210
245
  }
211
246
 
212
- /**
213
- * Save all tasks (replaces entire store - for migration compatibility)
214
- * @param {Object.<string, Object>} tasks
215
- */
216
- export function saveTasks(tasks) {
217
- const database = getDb();
218
- const insert = database.prepare(`
219
- INSERT OR REPLACE INTO tasks (
220
- id, prompt, full_prompt, cwd, status, pid, session_id, session_id_conflict, requested_resume_session_id, resume_identity_verified, log_file,
221
- created_at, updated_at, exit_code, error, provider, model,
222
- schedule_id, socket_path, attachable, process_group_id, termination_strategy,
223
- command_cleanup, cancel_requested, spawn_ownership_token
224
- ) VALUES (
225
- @id, @prompt, @fullPrompt, @cwd, @status, @pid, @sessionId, @sessionIdConflict, @requestedResumeSessionId, @resumeIdentityVerified, @logFile,
226
- @createdAt, @updatedAt, @exitCode, @error, @provider, @model,
227
- @scheduleId, @socketPath, @attachable, @processGroupId, @terminationStrategy,
228
- @commandCleanup, @cancelRequested, @spawnOwnershipToken
229
- )
230
- `);
231
-
232
- const insertMany = database.transaction((tasksObj) => {
233
- // Clear existing
234
- database.prepare('DELETE FROM tasks').run();
235
- // Insert all
236
- for (const task of Object.values(tasksObj)) {
237
- insert.run({
238
- id: task.id,
239
- prompt: task.prompt || null,
240
- fullPrompt: task.fullPrompt || null,
241
- cwd: task.cwd || null,
242
- status: task.status || 'pending',
243
- pid: task.pid || null,
244
- sessionId: task.sessionId || null,
245
- sessionIdConflict: task.sessionIdConflict ? 1 : 0,
246
- requestedResumeSessionId: nullable(task.requestedResumeSessionId),
247
- resumeIdentityVerified: task.resumeIdentityVerified ? 1 : 0,
248
- logFile: task.logFile || null,
249
- createdAt: task.createdAt || new Date().toISOString(),
250
- updatedAt: task.updatedAt || new Date().toISOString(),
251
- exitCode: task.exitCode ?? null,
252
- error: task.error || null,
253
- provider: task.provider || null,
254
- model: task.model || null,
255
- scheduleId: task.scheduleId || null,
256
- socketPath: task.socketPath || null,
257
- attachable: task.attachable ? 1 : 0,
258
- processGroupId: task.processGroupId || null,
259
- terminationStrategy: task.terminationStrategy || null,
260
- commandCleanup: serializeCommandCleanup(task.commandCleanup),
261
- cancelRequested: task.cancelRequested ? 1 : 0,
262
- spawnOwnershipToken: task.spawnOwnershipToken || null,
263
- });
264
- }
265
- });
266
-
267
- insertMany(tasks);
268
- }
269
-
270
- /**
271
- * For API compatibility - just runs the modifier synchronously
272
- * SQLite WAL handles concurrency, no lock needed
273
- * @param {Function} modifier
274
- * @returns {any}
275
- */
276
- export function withTasksLock(modifier) {
277
- const tasks = loadTasks();
278
- const result = modifier(tasks);
279
- saveTasks(tasks);
280
- return result;
281
- }
282
-
283
247
  /**
284
248
  * Get a single task by id
285
249
  * @param {string} id
@@ -350,7 +314,15 @@ export function updateTask(id, updates) {
350
314
  termination_strategy = @terminationStrategy,
351
315
  command_cleanup = @commandCleanup,
352
316
  cancel_requested =
353
- CASE WHEN @hasCancelRequested = 1 THEN @cancelRequested ELSE cancel_requested END
317
+ CASE WHEN @hasCancelRequested = 1 THEN @cancelRequested ELSE cancel_requested END,
318
+ -- Only ever written when the caller explicitly supplies it. This is a read-modify-write
319
+ -- update, so unconditionally rewriting the ownership column would let an unrelated
320
+ -- updateTask (the watcher persisting spawn evidence, say) clobber an owner-fenced
321
+ -- compare-and-swap another process performed in between — see
322
+ -- task-lib/omp-session-ownership.js, whose transitions bypass this statement for exactly
323
+ -- that reason.
324
+ omp_session_ownership =
325
+ CASE WHEN @hasOmpSessionOwnership = 1 THEN @ompSessionOwnership ELSE omp_session_ownership END
354
326
  WHERE id = @id
355
327
  `
356
328
  )
@@ -379,6 +351,10 @@ export function updateTask(id, updates) {
379
351
  commandCleanup: serializeCommandCleanup(updated.commandCleanup),
380
352
  hasCancelRequested: Object.prototype.hasOwnProperty.call(updates, 'cancelRequested') ? 1 : 0,
381
353
  cancelRequested: updated.cancelRequested ? 1 : 0,
354
+ hasOmpSessionOwnership: Object.prototype.hasOwnProperty.call(updates, 'ompSessionOwnership')
355
+ ? 1
356
+ : 0,
357
+ ompSessionOwnership: serializeOmpSessionOwnership(updated.ompSessionOwnership || null),
382
358
  });
383
359
 
384
360
  return updated;
@@ -404,12 +380,12 @@ export function addTask(task) {
404
380
  id, prompt, full_prompt, cwd, status, pid, session_id, session_id_conflict, requested_resume_session_id, resume_identity_verified, log_file,
405
381
  created_at, updated_at, exit_code, error, provider, model,
406
382
  schedule_id, socket_path, attachable, process_group_id, termination_strategy,
407
- command_cleanup, cancel_requested, spawn_ownership_token
383
+ command_cleanup, cancel_requested, spawn_ownership_token, omp_session_ownership
408
384
  ) VALUES (
409
385
  @id, @prompt, @fullPrompt, @cwd, @status, @pid, @sessionId, @sessionIdConflict, @requestedResumeSessionId, @resumeIdentityVerified, @logFile,
410
386
  @createdAt, @updatedAt, @exitCode, @error, @provider, @model,
411
387
  @scheduleId, @socketPath, @attachable, @processGroupId, @terminationStrategy,
412
- @commandCleanup, @cancelRequested, @spawnOwnershipToken
388
+ @commandCleanup, @cancelRequested, @spawnOwnershipToken, @ompSessionOwnership
413
389
  )
414
390
  `
415
391
  )
@@ -439,6 +415,7 @@ export function addTask(task) {
439
415
  commandCleanup: serializeCommandCleanup(fullTask.commandCleanup),
440
416
  cancelRequested: fullTask.cancelRequested ? 1 : 0,
441
417
  spawnOwnershipToken: fullTask.spawnOwnershipToken || null,
418
+ ompSessionOwnership: serializeOmpSessionOwnership(fullTask.ompSessionOwnership || null),
442
419
  });
443
420
 
444
421
  return fullTask;
@@ -452,6 +429,59 @@ export function removeTask(id) {
452
429
  getDb().prepare('DELETE FROM tasks WHERE id = ?').run(id);
453
430
  }
454
431
 
432
+ /**
433
+ * Remove a task row only while it still matches the snapshot the caller validated.
434
+ *
435
+ * `clean`/`purge` decide what to remove from one `loadTasks()` snapshot and then do real work
436
+ * (partition staging, command cleanup, log deletion) before they get to the delete. A watcher,
437
+ * a resume's ownership transfer, or a kill can land in that window; deleting on the strength of a
438
+ * stale snapshot would destroy a row that is no longer the row that was examined. The status and
439
+ * the exact ownership bytes are the two fields that decide whether removal is still correct, so
440
+ * both are the fence. `store.js` writes the ownership column only through
441
+ * `serializeOmpSessionOwnership`, whose output is canonical per record, which is what makes a
442
+ * byte comparison an exact "same record" test.
443
+ *
444
+ * @param {string} id
445
+ * @param {{status: string, ompSessionOwnership: object|null}} expected snapshot values
446
+ * @returns {boolean} true when the row was removed
447
+ */
448
+ export function removeTaskIfUnchanged(id, expected) {
449
+ const result = getDb()
450
+ .prepare(
451
+ `DELETE FROM tasks
452
+ WHERE id = ? AND status IS ? AND omp_session_ownership IS ?`
453
+ )
454
+ .run(
455
+ id,
456
+ expected?.status ?? null,
457
+ serializeOmpSessionOwnership(expected?.ompSessionOwnership || null)
458
+ );
459
+ return result.changes === 1;
460
+ }
461
+
462
+ /**
463
+ * Clear one row's persisted command-cleanup receipt, and nothing else, only if it is still the
464
+ * exact serialized receipt the caller processed.
465
+ *
466
+ * Deliberately narrower than updateTask(), which is a read-modify-write over every column and
467
+ * would write back whatever the caller's snapshot held for the rest of the row. This is used on
468
+ * the retained path of `clean`, where a concurrent writer may already have installed a new cleanup
469
+ * receipt that must survive.
470
+ *
471
+ * @param {string} id
472
+ * @param {object} expected exact command-cleanup receipt processed by the caller
473
+ * @returns {boolean} true when that exact receipt was cleared
474
+ */
475
+ export function clearTaskCommandCleanup(id, expected) {
476
+ const result = getDb()
477
+ .prepare(
478
+ `UPDATE tasks SET command_cleanup = NULL, updated_at = ?
479
+ WHERE id = ? AND command_cleanup IS ?`
480
+ )
481
+ .run(new Date().toISOString(), id, serializeCommandCleanup(expected));
482
+ return result.changes === 1;
483
+ }
484
+
455
485
  export function generateId() {
456
486
  return generateName('task');
457
487
  }