@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.
- package/cli/index.js +135 -72
- package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/omp.js +37 -11
- package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
- package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
- package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
- package/lib/agent-cli-provider/provider-registry.js +7 -1
- package/lib/agent-cli-provider/provider-registry.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +2 -0
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/agent-lifecycle.js +66 -2
- package/src/agent/agent-task-executor.js +72 -3
- package/src/agent/provider-session.js +125 -2
- package/src/agent-cli-provider/adapters/omp.ts +41 -11
- package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
- package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
- package/src/agent-cli-provider/provider-registry.ts +7 -1
- package/src/agent-cli-provider/types.ts +4 -0
- package/src/omp-blob-root.js +110 -0
- package/src/omp-config-overlay.js +9 -1
- package/src/omp-execution-fingerprint.js +62 -0
- package/src/omp-session-limits.js +41 -0
- package/src/omp-session-partition.js +424 -0
- package/src/omp-session-verifier.js +740 -0
- package/task-lib/commands/clean.js +92 -35
- package/task-lib/commands/kill.js +21 -0
- package/task-lib/commands/resume.js +62 -0
- package/task-lib/commands/run.js +80 -0
- package/task-lib/omp-session-cleanup.js +197 -0
- package/task-lib/omp-session-ownership-schema.js +268 -0
- package/task-lib/omp-session-ownership.js +367 -0
- package/task-lib/omp-storage-root.js +35 -0
- package/task-lib/rpc-watcher.js +368 -2
- package/task-lib/runner.js +243 -5
- package/task-lib/store.js +106 -76
package/task-lib/runner.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|