@the-open-engine/zeroshot 6.25.0 → 6.26.0

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.
@@ -26,9 +26,9 @@ import { getTask, updateTask } from './store.js';
26
26
  import { createCommandSpecCleanup } from './command-spec-cleanup.js';
27
27
  import {
28
28
  commitOwnership,
29
- markCleanupRequired,
30
29
  readOwnership,
31
30
  recordVerifiedMaterialization,
31
+ retireOmpOwnershipAtTerminalBoundary,
32
32
  transferOmpSessionOwnership,
33
33
  } from './omp-session-ownership.js';
34
34
  import {
@@ -98,14 +98,16 @@ function terminateOwnedProviderBoundary(exitObserved = false) {
98
98
  );
99
99
  }
100
100
 
101
+ /** Every failed/cancelled/uncertain terminal boundary in this watcher routes through the shared
102
+ * durable-boundary retirement, so a post-transfer failure leaves the resumed row cleanup-required
103
+ * rather than provisional forever. Idempotent and never throwing, so it cannot itself prevent the
104
+ * task from reaching a terminal status. */
101
105
  function markOmpCleanupRequiredSafely() {
102
- try {
103
- markCleanupRequired(taskId);
104
- } catch (error) {
106
+ retireOmpOwnershipAtTerminalBoundary(taskId, (error) =>
105
107
  emergencyLog(
106
108
  `[${Date.now()}][OMP-OWNERSHIP] Failed to mark cleanup-required: ${error.message}\n`
107
- );
108
- }
109
+ )
110
+ );
109
111
  }
110
112
 
111
113
  async function crashWithError(error, source) {
@@ -135,13 +137,14 @@ function identityText(identity) {
135
137
  * a re-minted session ID, a substituted inode, or an execution-contract change all fail here.
136
138
  *
137
139
  * `evidence` is null on the pre-spawn pass, where OMP has reported nothing yet; the structural and
138
- * identity halves still apply.
140
+ * identity halves still apply. When evidence *is* present this is the pre-prompt checkpoint, so
141
+ * the echoed session identity must be complete (see assertEchoedSessionMatches).
139
142
  */
140
143
  function assertNoResumeDrift(verified, evidence) {
141
144
  const expected = ompResumeExpectation;
142
145
  const drift = [];
143
146
 
144
- assertEchoedSessionMatches(evidence, drift);
147
+ assertEchoedSessionMatches(evidence, drift, { requireComplete: evidence !== null });
145
148
 
146
149
  if (verified.sessionFileName !== expected.sessionFileName) {
147
150
  drift.push(`sessionFileName ${verified.sessionFileName} != ${expected.sessionFileName}`);
@@ -149,13 +152,16 @@ function assertNoResumeDrift(verified, evidence) {
149
152
  if (verified.sessionFilePath !== expected.sessionFilePath) {
150
153
  drift.push(`sessionFilePath ${verified.sessionFilePath} != ${expected.sessionFilePath}`);
151
154
  }
152
- if (identityText(verified.partitionIdentity) !== identityText(expected.expectedPartitionIdentity)) {
155
+ if (
156
+ identityText(verified.partitionIdentity) !== identityText(expected.expectedPartitionIdentity)
157
+ ) {
153
158
  drift.push(
154
159
  `partitionIdentity ${identityText(verified.partitionIdentity)} != ${identityText(expected.expectedPartitionIdentity)}`
155
160
  );
156
161
  }
157
162
  if (
158
- identityText(verified.sessionFileIdentity) !== identityText(expected.expectedSessionFileIdentity)
163
+ identityText(verified.sessionFileIdentity) !==
164
+ identityText(expected.expectedSessionFileIdentity)
159
165
  ) {
160
166
  drift.push(
161
167
  `sessionFileIdentity ${identityText(verified.sessionFileIdentity)} != ${identityText(expected.expectedSessionFileIdentity)}`
@@ -192,11 +198,35 @@ function assertNoResumeDrift(verified, evidence) {
192
198
  * transcript has legitimately grown — re-running the manifest/inode comparison there would reject
193
199
  * a perfectly healthy turn. The identity and fingerprint comparisons below have no such staleness,
194
200
  * so a mid-turn switch to a different session or a changed model/thinking level is still caught.
201
+ *
202
+ * `requireComplete` is the difference between the two callers, and it is the difference between
203
+ * "OMP agreed it opened exactly this session" and "OMP declined to say".
204
+ *
205
+ * At the pre-prompt checkpoint of a resume it is set, and both echoed values must be present and
206
+ * exactly equal: the full session ID and the full absolute session file. Without it, a `get_state`
207
+ * that simply omits `sessionId` (or `sessionFile`) would transfer a committed lineage and receive
208
+ * the prompt on the strength of the *disk* alone — and disk state cannot answer the only question
209
+ * that matters here, which is which session the running OMP process actually attached to. A prefix
210
+ * is never enough either: OMP resolves `--resume` IDs by prefix (session-manager.ts), so a shorter
211
+ * echoed ID is precisely the ambiguity this check exists to reject.
212
+ *
213
+ * `session_info_update` passes leave it unset: those frames legitimately carry only a subset
214
+ * (docs/rpc.md), and the driver merges them onto evidence whose complete form this checkpoint has
215
+ * already proven, so a partial later frame is checked against that proof rather than replacing it.
195
216
  */
196
- function assertEchoedSessionMatches(evidence, drift) {
217
+ function assertEchoedSessionMatches(evidence, drift, { requireComplete = false } = {}) {
197
218
  if (!evidence) return;
198
219
  const expected = ompResumeExpectation;
199
220
 
221
+ if (requireComplete) {
222
+ if (!evidence.sessionId) {
223
+ drift.push('OMP reported no sessionId at the pre-prompt checkpoint');
224
+ }
225
+ if (!evidence.sessionFile) {
226
+ drift.push('OMP reported no sessionFile at the pre-prompt checkpoint');
227
+ }
228
+ }
229
+
200
230
  if (evidence.selectedProvider !== expected.expectedSelectedProvider) {
201
231
  drift.push(
202
232
  `selectedProvider ${evidence.selectedProvider} != ${expected.expectedSelectedProvider}`
@@ -263,10 +293,16 @@ function verifyOmpSessionBeforeSpawn() {
263
293
  /**
264
294
  * Owner-fenced ownership transfer, run from the `ready` hook after re-verification and strictly
265
295
  * before the prompt command is written. One transaction moves the prior committed owner's lineage
266
- * onto this task's provisional row and clears the prior row, so the partition is never owned by
267
- * two rows and never owned by none. A transfer that does not apply (the prior owner moved, this
268
- * row already advanced) throws, which fails the turn closed through the same cleanup-required path
269
- * as any other drift the resumed session is never steered on an unresolved ownership claim.
296
+ * onto this task's provisional row and clears the prior row, so the partition never has two
297
+ * committed owners.
298
+ *
299
+ * From here to this turn's own success boundary the partition has *no* committed owner at all —
300
+ * the authoritative live claimant is this still-`provisional` row, by design, and every partition
301
+ * fence is written for that (see findAuthoritativeOwnersForPartition). A transfer that does not
302
+ * apply (the prior owner moved, this row already advanced) throws, which fails the turn closed
303
+ * through the same cleanup-required path as any other drift: the resumed session is never steered
304
+ * on an unresolved ownership claim, and a turn that dies after the transfer retires the row it now
305
+ * holds rather than stranding the lineage.
270
306
  */
271
307
  function transferResumedOwnershipBeforePrompt() {
272
308
  if (ownershipTransferred) return;
@@ -3,13 +3,17 @@ import { join, dirname, resolve as resolvePath } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { mkdirSync } from 'fs';
5
5
  import { LOGS_DIR } from './config.js';
6
- import { addTask, generateId, ensureDirs } from './store.js';
6
+ import { addTask, generateId, ensureDirs, updateTask } from './store.js';
7
7
  import {
8
8
  isOmpSessionlessRun,
9
9
  resolveOmpStorageRoot,
10
10
  resolveOmpOwnerKind,
11
11
  } from './omp-storage-root.js';
12
- import { readOwnership, writeProvisionalOwnership } from './omp-session-ownership.js';
12
+ import {
13
+ readOwnership,
14
+ retireOmpOwnershipAtTerminalBoundary,
15
+ writeProvisionalOwnership,
16
+ } from './omp-session-ownership.js';
13
17
  import { createRequire } from 'module';
14
18
 
15
19
  const require = createRequire(import.meta.url);
@@ -202,6 +206,38 @@ function resolveOmpSessionPlan({ id, cwd, options }) {
202
206
  };
203
207
  }
204
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
+
205
241
  export function spawnTask(prompt, options = {}) {
206
242
  ensureDirs();
207
243
 
@@ -235,11 +271,22 @@ export function spawnTask(prompt, options = {}) {
235
271
  });
236
272
 
237
273
  // Row-before-directory: the SQL row is durable proof of an attempted allocation before the
238
- // partition directory (or anything else OMP-owned) exists on disk. A crash between these two
274
+ // partition directory (or anything else OMP-owned) exists on disk. A *crash* between these two
239
275
  // lines leaves a provisional row pointing at a path with nothing there yet — cleanup safely
240
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.
241
283
  addTask(task);
242
- ompPlan?.createDirectory();
284
+ try {
285
+ ompPlan?.createDirectory();
286
+ } catch (error) {
287
+ failSpawnAtProvisionalBoundary(id, error);
288
+ throw error;
289
+ }
243
290
 
244
291
  const watcherConfig = buildWatcherConfig(
245
292
  outputFormat,
package/task-lib/store.js CHANGED
@@ -11,6 +11,7 @@ import Database from 'better-sqlite3';
11
11
  import { TASKS_DIR, LOGS_DIR } from './config.js';
12
12
  import { generateName } from './name-generator.js';
13
13
  import {
14
+ inspectStoredOmpSessionOwnership,
14
15
  parseOmpSessionOwnership,
15
16
  serializeOmpSessionOwnership,
16
17
  } from './omp-session-ownership-schema.js';
@@ -213,9 +214,22 @@ function rowToTask(row) {
213
214
  cancelRequested: Boolean(row.cancel_requested),
214
215
  spawnOwnershipToken: row.spawn_ownership_token,
215
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,
216
223
  };
217
224
  }
218
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
+
219
233
  /**
220
234
  * Load all tasks as object keyed by id
221
235
  * @returns {Object.<string, Object>}
@@ -230,78 +244,6 @@ export function loadTasks() {
230
244
  return tasks;
231
245
  }
232
246
 
233
- /**
234
- * Save all tasks (replaces entire store - for migration compatibility)
235
- * @param {Object.<string, Object>} tasks
236
- */
237
- export function saveTasks(tasks) {
238
- const database = getDb();
239
- const insert = database.prepare(`
240
- INSERT OR REPLACE INTO tasks (
241
- id, prompt, full_prompt, cwd, status, pid, session_id, session_id_conflict, requested_resume_session_id, resume_identity_verified, log_file,
242
- created_at, updated_at, exit_code, error, provider, model,
243
- schedule_id, socket_path, attachable, process_group_id, termination_strategy,
244
- command_cleanup, cancel_requested, spawn_ownership_token, omp_session_ownership
245
- ) VALUES (
246
- @id, @prompt, @fullPrompt, @cwd, @status, @pid, @sessionId, @sessionIdConflict, @requestedResumeSessionId, @resumeIdentityVerified, @logFile,
247
- @createdAt, @updatedAt, @exitCode, @error, @provider, @model,
248
- @scheduleId, @socketPath, @attachable, @processGroupId, @terminationStrategy,
249
- @commandCleanup, @cancelRequested, @spawnOwnershipToken, @ompSessionOwnership
250
- )
251
- `);
252
-
253
- const insertMany = database.transaction((tasksObj) => {
254
- // Clear existing
255
- database.prepare('DELETE FROM tasks').run();
256
- // Insert all
257
- for (const task of Object.values(tasksObj)) {
258
- insert.run({
259
- id: task.id,
260
- prompt: task.prompt || null,
261
- fullPrompt: task.fullPrompt || null,
262
- cwd: task.cwd || null,
263
- status: task.status || 'pending',
264
- pid: task.pid || null,
265
- sessionId: task.sessionId || null,
266
- sessionIdConflict: task.sessionIdConflict ? 1 : 0,
267
- requestedResumeSessionId: nullable(task.requestedResumeSessionId),
268
- resumeIdentityVerified: task.resumeIdentityVerified ? 1 : 0,
269
- logFile: task.logFile || null,
270
- createdAt: task.createdAt || new Date().toISOString(),
271
- updatedAt: task.updatedAt || new Date().toISOString(),
272
- exitCode: task.exitCode ?? null,
273
- error: task.error || null,
274
- provider: task.provider || null,
275
- model: task.model || null,
276
- scheduleId: task.scheduleId || null,
277
- socketPath: task.socketPath || null,
278
- attachable: task.attachable ? 1 : 0,
279
- processGroupId: task.processGroupId || null,
280
- terminationStrategy: task.terminationStrategy || null,
281
- commandCleanup: serializeCommandCleanup(task.commandCleanup),
282
- cancelRequested: task.cancelRequested ? 1 : 0,
283
- spawnOwnershipToken: task.spawnOwnershipToken || null,
284
- ompSessionOwnership: serializeOmpSessionOwnership(task.ompSessionOwnership || null),
285
- });
286
- }
287
- });
288
-
289
- insertMany(tasks);
290
- }
291
-
292
- /**
293
- * For API compatibility - just runs the modifier synchronously
294
- * SQLite WAL handles concurrency, no lock needed
295
- * @param {Function} modifier
296
- * @returns {any}
297
- */
298
- export function withTasksLock(modifier) {
299
- const tasks = loadTasks();
300
- const result = modifier(tasks);
301
- saveTasks(tasks);
302
- return result;
303
- }
304
-
305
247
  /**
306
248
  * Get a single task by id
307
249
  * @param {string} id
@@ -487,6 +429,59 @@ export function removeTask(id) {
487
429
  getDb().prepare('DELETE FROM tasks WHERE id = ?').run(id);
488
430
  }
489
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
+
490
485
  export function generateId() {
491
486
  return generateName('task');
492
487
  }