@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.
- package/cli/index.js +102 -74
- package/package.json +4 -2
- package/src/agent/provider-session.js +18 -4
- package/src/hosted-target/bounds.ts +6 -0
- package/src/hosted-target/errors.ts +90 -0
- package/src/hosted-target/index.ts +44 -0
- package/src/hosted-target/response-validation.ts +86 -0
- package/src/hosted-target/retry.ts +46 -0
- package/src/hosted-target/target-adapter.ts +10 -0
- package/src/hosted-target/types.ts +58 -0
- package/src/hosted-target/zero-cloud-v1-adapter.ts +386 -0
- package/src/omp-session-limits.js +25 -1
- package/src/omp-session-partition.js +159 -32
- package/src/omp-session-verifier.js +251 -87
- package/task-lib/commands/clean.js +80 -46
- package/task-lib/commands/kill.js +21 -0
- package/task-lib/commands/resume.js +20 -0
- package/task-lib/commands/run.js +17 -2
- package/task-lib/omp-session-cleanup.js +46 -9
- package/task-lib/omp-session-ownership-schema.js +21 -15
- package/task-lib/omp-session-ownership.js +66 -31
- package/task-lib/rpc-watcher.js +51 -15
- package/task-lib/runner.js +51 -4
- package/task-lib/store.js +67 -72
package/task-lib/rpc-watcher.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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) !==
|
|
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
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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;
|
package/task-lib/runner.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|