@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
@@ -0,0 +1,268 @@
1
+ // Pure (no DB, no partition I/O) validation/canonicalization for the closed
2
+ // `task.ompSessionOwnership` JSON shape defined by issue #866. Kept dependency-free of
3
+ // task-lib/store.js so store.js can import this module for parse/serialize without a cycle; the
4
+ // DB-touching transitions (provisional -> committed / cleanup-required, owner transfer) live in
5
+ // task-lib/omp-session-ownership.js.
6
+ //
7
+ // The schema is *closed* in both directions: an unknown key anywhere in the object (top level,
8
+ // `owner`, `session`, or either identity) rejects the whole record, and every known key is
9
+ // re-derived into a canonical form on the way out. A record that does not validate is never
10
+ // partially trusted — callers fail closed to a fresh context.
11
+ import { isAbsolute, resolve as resolvePath } from 'path';
12
+ import { createRequire } from 'module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { PARTITION_ID_PATTERN, partitionPathFor } = require('../src/omp-session-partition.js');
16
+
17
+ export const OMP_OWNERSHIP_SCHEMA_VERSION = 1;
18
+ export const OMP_OWNERSHIP_STATES = Object.freeze(['provisional', 'committed', 'cleanup-required']);
19
+
20
+ const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u;
21
+ const DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/u;
22
+ const SESSION_FILE_NAME_PATTERN = /^[^/\\]+\.jsonl$/u;
23
+ const STATES = new Set(OMP_OWNERSHIP_STATES);
24
+ const OWNER_KINDS = new Set(['cluster-agent', 'standalone']);
25
+
26
+ const TOP_LEVEL_KEYS = new Set([
27
+ 'schemaVersion',
28
+ 'state',
29
+ 'partitionId',
30
+ 'storageRoot',
31
+ 'partitionPath',
32
+ 'ownerUid',
33
+ 'storageRootIdentity',
34
+ 'partitionIdentity',
35
+ 'canonicalWorkspace',
36
+ 'owner',
37
+ 'session',
38
+ ]);
39
+ const OWNER_KEYS = new Set(['kind', 'clusterId', 'agentId', 'taskId']);
40
+ const SESSION_KEYS = new Set([
41
+ 'sessionId',
42
+ 'fileName',
43
+ 'fileIdentity',
44
+ 'artifactManifestDigest',
45
+ 'executionFingerprint',
46
+ 'selectedProvider',
47
+ 'selectedModel',
48
+ ]);
49
+ const IDENTITY_KEYS = new Set(['device', 'inode']);
50
+
51
+ function hasOnlyKeys(value, allowed) {
52
+ return Object.keys(value).every((key) => allowed.has(key));
53
+ }
54
+
55
+ function isPlainObject(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
57
+ }
58
+
59
+ function isNonEmptyString(value) {
60
+ return typeof value === 'string' && value.length > 0;
61
+ }
62
+
63
+ function isDecimalString(value) {
64
+ return isNonEmptyString(value) && DECIMAL_PATTERN.test(value);
65
+ }
66
+
67
+ function isDigest(value) {
68
+ return isNonEmptyString(value) && SHA256_PATTERN.test(value);
69
+ }
70
+
71
+ /** A canonical absolute path: already fully resolved, so a record can never smuggle in a relative,
72
+ * `..`-bearing, or trailing-separator path that would resolve differently at cleanup time. */
73
+ function isCanonicalAbsolutePath(value) {
74
+ return isNonEmptyString(value) && isAbsolute(value) && resolvePath(value) === value;
75
+ }
76
+
77
+ function normalizeIdentity(value) {
78
+ if (!isPlainObject(value) || !hasOnlyKeys(value, IDENTITY_KEYS)) return null;
79
+ if (!isDecimalString(value.device) || !isDecimalString(value.inode)) return null;
80
+ return { device: value.device, inode: value.inode };
81
+ }
82
+
83
+ export function canonicalOwnerUid() {
84
+ return typeof process.getuid === 'function' ? String(process.getuid()) : '0';
85
+ }
86
+
87
+ function normalizeOwner(owner) {
88
+ if (!isPlainObject(owner) || !hasOnlyKeys(owner, OWNER_KEYS)) return null;
89
+ if (!OWNER_KINDS.has(owner.kind)) return null;
90
+ if (!isNonEmptyString(owner.taskId)) return null;
91
+ if (owner.kind === 'cluster-agent') {
92
+ if (!isNonEmptyString(owner.clusterId) || !isNonEmptyString(owner.agentId)) return null;
93
+ } else if (owner.clusterId !== null || owner.agentId !== null) {
94
+ return null;
95
+ }
96
+ return {
97
+ kind: owner.kind,
98
+ clusterId: owner.clusterId,
99
+ agentId: owner.agentId,
100
+ taskId: owner.taskId,
101
+ };
102
+ }
103
+
104
+ function normalizeSession(session) {
105
+ if (!isPlainObject(session) || !hasOnlyKeys(session, SESSION_KEYS)) return null;
106
+ const fileIdentity = normalizeIdentity(session.fileIdentity);
107
+ if (
108
+ !isNonEmptyString(session.sessionId) ||
109
+ !isNonEmptyString(session.fileName) ||
110
+ !SESSION_FILE_NAME_PATTERN.test(session.fileName) ||
111
+ session.fileName === '.jsonl' ||
112
+ !fileIdentity ||
113
+ !isDigest(session.artifactManifestDigest) ||
114
+ !isDigest(session.executionFingerprint) ||
115
+ !isNonEmptyString(session.selectedProvider) ||
116
+ !isNonEmptyString(session.selectedModel)
117
+ ) {
118
+ return null;
119
+ }
120
+ return {
121
+ sessionId: session.sessionId,
122
+ fileName: session.fileName,
123
+ fileIdentity,
124
+ artifactManifestDigest: session.artifactManifestDigest,
125
+ executionFingerprint: session.executionFingerprint,
126
+ selectedProvider: session.selectedProvider,
127
+ selectedModel: session.selectedModel,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Validate and canonicalize an arbitrary value as a closed `task.ompSessionOwnership` object.
133
+ * Returns null on any structural violation.
134
+ */
135
+ export function validateOmpSessionOwnership(value) {
136
+ if (!isPlainObject(value) || !hasOnlyKeys(value, TOP_LEVEL_KEYS)) return null;
137
+ if (value.schemaVersion !== OMP_OWNERSHIP_SCHEMA_VERSION) return null;
138
+ if (!STATES.has(value.state)) return null;
139
+ if (!isNonEmptyString(value.partitionId) || !PARTITION_ID_PATTERN.test(value.partitionId)) {
140
+ return null;
141
+ }
142
+ if (!isCanonicalAbsolutePath(value.storageRoot)) return null;
143
+ if (!isCanonicalAbsolutePath(value.partitionPath)) return null;
144
+ if (!isCanonicalAbsolutePath(value.canonicalWorkspace)) return null;
145
+ // The partition path is fully determined by storageRoot + partitionId. Re-deriving it (instead
146
+ // of trusting the stored string) is what stops a tampered row from pointing cleanup or a resume
147
+ // at an arbitrary directory that merely *looks* canonical.
148
+ let derivedPartitionPath;
149
+ try {
150
+ derivedPartitionPath = partitionPathFor(value.storageRoot, value.partitionId);
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (derivedPartitionPath !== value.partitionPath) return null;
155
+ if (!isDecimalString(value.ownerUid)) return null;
156
+
157
+ const storageRootIdentity = normalizeIdentity(value.storageRootIdentity);
158
+ if (!storageRootIdentity) return null;
159
+
160
+ const owner = normalizeOwner(value.owner);
161
+ if (!owner) return null;
162
+
163
+ if (!Object.hasOwn(value, 'partitionIdentity') || !Object.hasOwn(value, 'session')) return null;
164
+ const hasPartitionIdentity = value.partitionIdentity !== null;
165
+ const hasSession = value.session !== null;
166
+ // No partially populated pairs, in any state: an observation of the materialized session is
167
+ // either complete (both the partition identity and the full session tuple) or absent.
168
+ if (hasPartitionIdentity !== hasSession) return null;
169
+ const partitionIdentity = hasPartitionIdentity
170
+ ? normalizeIdentity(value.partitionIdentity)
171
+ : null;
172
+ const session = hasSession ? normalizeSession(value.session) : null;
173
+ if (hasPartitionIdentity && (!partitionIdentity || !session)) return null;
174
+ // `committed` is the only state that asserts a resumable session, so it is the only state that
175
+ // requires the observation to be present.
176
+ if (value.state === 'committed' && !session) return null;
177
+
178
+ return {
179
+ schemaVersion: OMP_OWNERSHIP_SCHEMA_VERSION,
180
+ state: value.state,
181
+ partitionId: value.partitionId,
182
+ storageRoot: value.storageRoot,
183
+ partitionPath: value.partitionPath,
184
+ ownerUid: value.ownerUid,
185
+ storageRootIdentity,
186
+ partitionIdentity,
187
+ canonicalWorkspace: value.canonicalWorkspace,
188
+ owner,
189
+ session,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Validate a record *and* fence it to the task row it was read from: an ownership record whose
195
+ * `owner.taskId` is not this row's id is not this row's ownership, however well-formed it is.
196
+ */
197
+ export function validateOwnedByTask(value, taskId) {
198
+ const validated = validateOmpSessionOwnership(value);
199
+ if (!validated) return null;
200
+ if (!isNonEmptyString(taskId) || validated.owner.taskId !== taskId) return null;
201
+ return validated;
202
+ }
203
+
204
+ /** Build the initial provisional ownership record. Pure — the directory need not exist yet. */
205
+ export function buildProvisionalOwnership({
206
+ partitionId,
207
+ storageRoot,
208
+ storageRootIdentity,
209
+ canonicalWorkspace,
210
+ owner,
211
+ }) {
212
+ const canonicalStorageRoot = resolvePath(storageRoot);
213
+ const record = {
214
+ schemaVersion: OMP_OWNERSHIP_SCHEMA_VERSION,
215
+ state: 'provisional',
216
+ partitionId,
217
+ storageRoot: canonicalStorageRoot,
218
+ partitionPath: partitionPathFor(canonicalStorageRoot, partitionId),
219
+ ownerUid: canonicalOwnerUid(),
220
+ storageRootIdentity,
221
+ partitionIdentity: null,
222
+ canonicalWorkspace: resolvePath(canonicalWorkspace),
223
+ owner,
224
+ session: null,
225
+ };
226
+ const validated = validateOmpSessionOwnership(record);
227
+ if (!validated) {
228
+ throw new Error('buildProvisionalOwnership produced an invalid ownership record.');
229
+ }
230
+ return validated;
231
+ }
232
+
233
+ export function parseOmpSessionOwnership(raw) {
234
+ if (typeof raw !== 'string' || raw === '') return null;
235
+ let parsed;
236
+ try {
237
+ parsed = JSON.parse(raw);
238
+ } catch {
239
+ return null;
240
+ }
241
+ return validateOmpSessionOwnership(parsed);
242
+ }
243
+
244
+ /**
245
+ * Closed raw-column inspection seam for a stored `omp_session_ownership` value.
246
+ *
247
+ * `parseOmpSessionOwnership` collapses *both* "SQL NULL" and "non-null but unreadable" to `null`,
248
+ * and those two mean opposite things to cleanup: SQL NULL is exact truth that there is nothing to
249
+ * clean, while unreadable bytes are evidence that a partition may exist whose owner record we can
250
+ * no longer interpret. Deleting such a row would orphan that partition permanently, so every
251
+ * cleanup surface needs to tell them apart.
252
+ *
253
+ * The seam is deliberately closed: it reports only `{present, valid}` and never hands the raw text
254
+ * back, so nothing downstream can be tempted to parse, canonicalize, or act on malformed JSON.
255
+ */
256
+ export function inspectStoredOmpSessionOwnership(raw) {
257
+ if (raw === null || raw === undefined) return { present: false, valid: false };
258
+ return { present: true, valid: parseOmpSessionOwnership(raw) !== null };
259
+ }
260
+
261
+ export function serializeOmpSessionOwnership(value) {
262
+ if (!value) return null;
263
+ const validated = validateOmpSessionOwnership(value);
264
+ if (!validated) {
265
+ throw new Error('Refusing to persist an invalid ompSessionOwnership record.');
266
+ }
267
+ return JSON.stringify(validated);
268
+ }
@@ -0,0 +1,367 @@
1
+ // Owner-fenced transitions for task.ompSessionOwnership (schema v5).
2
+ //
3
+ // Every transition is a SQL compare-and-swap against the *exact* JSON currently persisted for that
4
+ // row, so a duplicate or re-entrant completion call (two crash-recovery paths racing, a retried
5
+ // hook, a watcher and its parent both reacting to the same terminal frame) can never clobber a
6
+ // state a concurrent writer already advanced past. `store.js` always writes this column through
7
+ // `serializeOmpSessionOwnership`, whose output is canonical for a given record, which is what makes
8
+ // full-value CAS byte-stable.
9
+ //
10
+ // State machine, identical for fresh and resumed turns:
11
+ //
12
+ // (row inserted) provisional
13
+ // materialization provisional + observation recordVerifiedMaterialization / commitOwnership
14
+ // success boundary committed commitOwnership / commitRecordedOwnership
15
+ // any other boundary cleanup-required markCleanupRequired
16
+ //
17
+ // A resumed turn additionally performs `transferOmpSessionOwnership` before its prompt is written:
18
+ // in one transaction the prior committed owner's record is cleared and its lineage (partition
19
+ // identity + session tuple) is moved onto the resumed task's still-`provisional` row.
20
+ //
21
+ // That means a partition has at most one *committed* owner, and for the whole span of a resumed
22
+ // turn it has NONE: the authoritative live claimant is `provisional` by design, because
23
+ // "committed" means this row's own success boundary has already passed. Publishing a half-finished
24
+ // continuation as resumable is exactly what that ordering prevents.
25
+ //
26
+ // It also does NOT keep exactly one row *referencing* the partition. A resumed row is inserted (and
27
+ // therefore already names the partition) before its transfer runs, so two competing resumes of the
28
+ // same committed session put three rows on one partition: the prior owner plus both candidates.
29
+ // Only one transfer can win; the loser fails closed and is retired to `cleanup-required` holding a
30
+ // record that still names the partition but carries no lineage of its own. Anything that acts on a
31
+ // partition — cleanup above all — must therefore fence on *every* row that references it
32
+ // (`findAuthoritativeOwnersForPartition`), never on the committed rows alone and never on the
33
+ // assumption that its own row is the only claimant.
34
+ import { basename } from 'path';
35
+ import { statSync } from 'fs';
36
+ import { getTask, getTaskStoreDatabase } from './store.js';
37
+ import {
38
+ buildProvisionalOwnership,
39
+ parseOmpSessionOwnership,
40
+ serializeOmpSessionOwnership,
41
+ validateOmpSessionOwnership,
42
+ validateOwnedByTask,
43
+ } from './omp-session-ownership-schema.js';
44
+
45
+ function statIdentity(targetPath) {
46
+ const stat = statSync(targetPath);
47
+ return { device: String(stat.dev), inode: String(stat.ino) };
48
+ }
49
+
50
+ /** Pure builder for the initial provisional record; embed the result in the task row passed to
51
+ * addTask() so the SQL row is durable before the partition directory is created on disk. */
52
+ export function writeProvisionalOwnership({ partitionId, storageRoot, canonicalWorkspace, owner }) {
53
+ return buildProvisionalOwnership({
54
+ partitionId,
55
+ storageRoot,
56
+ storageRootIdentity: statIdentity(storageRoot),
57
+ canonicalWorkspace,
58
+ owner,
59
+ });
60
+ }
61
+
62
+ /** Read a row's ownership record, fenced to that row: a well-formed record whose `owner.taskId` is
63
+ * some other task is not this row's ownership and is never returned. */
64
+ export function readOwnership(taskId) {
65
+ return validateOwnedByTask(getTask(taskId)?.ompSessionOwnership ?? null, taskId);
66
+ }
67
+
68
+ /**
69
+ * Compare-and-swap the whole record. `expected` must be the record as currently persisted; a
70
+ * concurrent writer that has already changed the row makes this a no-op returning false.
71
+ */
72
+ function casOwnership(taskId, expected, next) {
73
+ const validatedNext = validateOwnedByTask(next, taskId);
74
+ if (!validatedNext) return false;
75
+ const expectedJson = serializeOmpSessionOwnership(expected);
76
+ const database = getTaskStoreDatabase();
77
+ const result = database
78
+ .prepare(
79
+ `UPDATE tasks SET omp_session_ownership = ?, updated_at = ?
80
+ WHERE id = ? AND omp_session_ownership = ?`
81
+ )
82
+ .run(
83
+ serializeOmpSessionOwnership(validatedNext),
84
+ new Date().toISOString(),
85
+ taskId,
86
+ expectedJson
87
+ );
88
+ return result.changes === 1;
89
+ }
90
+
91
+ /** Shape the observed materialization evidence. Returns null (never throws) if a stat fails. */
92
+ function buildObservedEvidence(
93
+ current,
94
+ {
95
+ sessionId,
96
+ sessionFilePath,
97
+ partitionIdentity,
98
+ sessionFileIdentity,
99
+ artifactManifestDigest,
100
+ executionFingerprint,
101
+ selectedProvider,
102
+ selectedModel,
103
+ }
104
+ ) {
105
+ try {
106
+ return {
107
+ // The verifier hands back descriptor-pinned identities; fall back to a stat only when a
108
+ // caller (a test double, or a path that never ran the verifier) did not supply them.
109
+ partitionIdentity: partitionIdentity ?? statIdentity(current.partitionPath),
110
+ session: {
111
+ sessionId,
112
+ fileName: basename(sessionFilePath),
113
+ fileIdentity: sessionFileIdentity ?? statIdentity(sessionFilePath),
114
+ artifactManifestDigest,
115
+ executionFingerprint,
116
+ selectedProvider,
117
+ selectedModel,
118
+ },
119
+ };
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Persist owner-fenced verified materialization evidence WITHOUT advancing state. Used by the
127
+ * detached RPC watcher for cluster-agent owners: the watcher verifies the terminal session file
128
+ * itself (two-phase file contract) but must never decide "committed" on its own — that decision
129
+ * belongs to the parent agent process's post-hook success boundary (see commitRecordedOwnership).
130
+ * Fails closed (returns false, never throws) when the record is missing, is not this task's, has
131
+ * already left `provisional`, or the evidence does not validate.
132
+ */
133
+ export function recordVerifiedMaterialization({ taskId, ...evidence }) {
134
+ const current = readOwnership(taskId);
135
+ if (!current || current.state !== 'provisional') return false;
136
+ const observed = buildObservedEvidence(current, evidence);
137
+ if (!observed) return false;
138
+ return casOwnership(taskId, current, { ...current, state: 'provisional', ...observed });
139
+ }
140
+
141
+ /**
142
+ * Commit a provisional record once the terminal boundary for this task's owner kind has actually
143
+ * succeeded (standalone: the watcher's own output validation; cluster-agent: logical/schema/
144
+ * onComplete hook success). Fails closed (returns false, never throws); the caller must treat a
145
+ * false return as "did not commit" and mark cleanup-required instead.
146
+ */
147
+ export function commitOwnership({ taskId, ...evidence }) {
148
+ const current = readOwnership(taskId);
149
+ if (!current || current.state !== 'provisional') return false;
150
+ const observed = buildObservedEvidence(current, evidence);
151
+ if (!observed) return false;
152
+ return casOwnership(taskId, current, { ...current, state: 'committed', ...observed });
153
+ }
154
+
155
+ /**
156
+ * Commit using evidence the watcher already recorded via recordVerifiedMaterialization — never
157
+ * re-verifies the partition. This is the ONLY path that may advance a cluster-agent owner to
158
+ * `committed`, and only from the post-hook success boundary in src/agent/agent-lifecycle.js:
159
+ * committing earlier would let a later turn resume a turn whose logical/schema output or
160
+ * onComplete hook subsequently failed. Returns false when no verified evidence exists yet.
161
+ */
162
+ export function commitRecordedOwnership(taskId) {
163
+ const current = readOwnership(taskId);
164
+ if (!current || current.state !== 'provisional') return false;
165
+ if (!current.partitionIdentity || !current.session) return false;
166
+ return casOwnership(taskId, current, { ...current, state: 'committed' });
167
+ }
168
+
169
+ /**
170
+ * Mark a still-provisional record cleanup-required on any failed, cancelled, or uncertain terminal
171
+ * boundary. No-op once a record has left `provisional`: a `committed` record is never downgraded,
172
+ * because commit is strictly the last action of an already-successful turn — and a resumed turn is
173
+ * provisional right up to that same boundary (see transferOmpSessionOwnership), so retiring a
174
+ * failed continuation needs no exception here.
175
+ */
176
+ export function markCleanupRequired(taskId) {
177
+ const current = readOwnership(taskId);
178
+ if (!current || current.state !== 'provisional') return current;
179
+ const updated = { ...current, state: 'cleanup-required' };
180
+ return casOwnership(taskId, current, updated) ? updated : readOwnership(taskId);
181
+ }
182
+
183
+ /**
184
+ * Retire a task's OMP ownership from a *confirmed durable task boundary* — a failed, cancelled,
185
+ * stale, or killed transition — without ever letting that retirement break the boundary itself.
186
+ *
187
+ * Every caller here is already committing a terminal task-status write (task-lib/runner.js's
188
+ * row-before-directory failure, `zeroshot task kill`, `zeroshot clear`, `zeroshot kill-all`), so
189
+ * this must not throw: an unreachable or locked task store must not turn "the task is killed" into
190
+ * an unhandled rejection. It is idempotent and safe to re-enter — `markCleanupRequired` no-ops once
191
+ * the record has left `provisional`, so a retried kill or a crash-recovery replay converges.
192
+ *
193
+ * The decision is derived from the boundary, never from whether the partition directory happens to
194
+ * exist: a fresh partition can be mid-materialization at exactly this moment, and file presence
195
+ * would answer the wrong question.
196
+ *
197
+ * @param {string} taskId
198
+ * @param {(error: Error) => void} [onError] receives an unexpected store failure for logging
199
+ * @returns {boolean} true when the retirement ran without error (including no-op cases)
200
+ */
201
+ export function retireOmpOwnershipAtTerminalBoundary(taskId, onError) {
202
+ if (typeof taskId !== 'string' || taskId.length === 0) return true;
203
+ try {
204
+ markCleanupRequired(taskId);
205
+ return true;
206
+ } catch (error) {
207
+ onError?.(error instanceof Error ? error : new Error(String(error)));
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * The ownership states that constitute a live claim on a partition.
214
+ *
215
+ * `provisional` is authoritative because it is the state a turn occupies while it is *using* the
216
+ * partition — a fresh turn writing into it, and (post-transfer) a resumed turn continuing it right
217
+ * up to its own success boundary. `committed` is authoritative because the session is resumable.
218
+ *
219
+ * `cleanup-required` is deliberately NOT authoritative: it is the state of a turn that has already
220
+ * been retired and makes no further claim. Treating it as one would deadlock cleanup whenever two
221
+ * retired rows name the same partition (the third-owner residue below) — each would refuse forever
222
+ * on account of the other, and the partition could never be reclaimed by anybody.
223
+ */
224
+ export const AUTHORITATIVE_OWNERSHIP_STATES = Object.freeze(['provisional', 'committed']);
225
+
226
+ const AUTHORITATIVE_STATES = new Set(AUTHORITATIVE_OWNERSHIP_STATES);
227
+
228
+ /** Every row other than `excludeTaskId` whose own valid record names this partition, plus every
229
+ * unreadable or invalid non-null row as global unknown authoritative evidence. A malformed record
230
+ * cannot safely prove which partition it names, so cleanup must assume it may name the partition
231
+ * being considered; skipping it could orphan the only directory that damaged row still points at.
232
+ *
233
+ * The partition filter is applied in JS after validation rather than in SQL. SQLite's
234
+ * `json_extract()` raises "malformed JSON" for a column whose bytes are not valid JSON. Reading the
235
+ * column as opaque text both avoids that failure and lets cleanup represent corruption explicitly
236
+ * instead of treating unknown evidence as absence. */
237
+ function ownersForPartition(partitionId, excludeTaskId, database) {
238
+ const rows = database
239
+ .prepare(
240
+ `SELECT id, omp_session_ownership AS record FROM tasks
241
+ WHERE omp_session_ownership IS NOT NULL`
242
+ )
243
+ .all();
244
+ const owners = [];
245
+ for (const row of rows) {
246
+ if (row.id === excludeTaskId) continue;
247
+ const record = parseOmpSessionOwnership(row.record);
248
+ const owned = record && validateOwnedByTask(record, row.id);
249
+ if (!owned) {
250
+ owners.push({ taskId: row.id, state: null, unknown: true });
251
+ continue;
252
+ }
253
+ if (owned.partitionId === partitionId) {
254
+ owners.push({ taskId: row.id, state: owned.state });
255
+ }
256
+ }
257
+ return owners;
258
+ }
259
+
260
+ /** Rows other than `excludeTaskId` that still hold a committed record for this partition — i.e.
261
+ * the rows for which this partition is a *resumable* session. */
262
+ export function findCommittedOwnersForPartition(
263
+ partitionId,
264
+ excludeTaskId = null,
265
+ database = getTaskStoreDatabase()
266
+ ) {
267
+ return ownersForPartition(partitionId, excludeTaskId, database)
268
+ .filter((owner) => owner.state === 'committed')
269
+ .map((owner) => owner.taskId);
270
+ }
271
+
272
+ /**
273
+ * Rows other than `excludeTaskId` holding an authoritative (`provisional` or `committed`) claim on
274
+ * this partition, as `{taskId, state}`.
275
+ *
276
+ * This is the owner fence cleanup runs on, and it is strictly wider than the committed owners:
277
+ * after a resume's atomic transfer the winning row is `provisional` — it carries the whole
278
+ * inherited lineage and is actively continuing the session, while *no* row is committed. A losing
279
+ * competing resume, retired to `cleanup-required`, still names the same partition and holds no
280
+ * lineage of its own, so a committed-only fence would see nothing and let it delete the winner's
281
+ * live partition out from under it.
282
+ *
283
+ * `database` is injectable so a caller can run this inside its own write transaction (see
284
+ * task-lib/omp-session-cleanup.js) rather than racing its own check.
285
+ */
286
+ export function findAuthoritativeOwnersForPartition(
287
+ partitionId,
288
+ excludeTaskId = null,
289
+ database = getTaskStoreDatabase()
290
+ ) {
291
+ return ownersForPartition(partitionId, excludeTaskId, database).filter(
292
+ (owner) => owner.unknown || AUTHORITATIVE_STATES.has(owner.state)
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Atomically move a committed partition's ownership from `fromTaskId` to `toTaskId`, before the
298
+ * resumed turn's prompt is written.
299
+ *
300
+ * Both sides are fenced on their exact current JSON inside one transaction, so the outcome is all
301
+ * or nothing: either the prior owner's record is cleared *and* the resumed row carries the
302
+ * inherited lineage, or nothing changed and the caller must fail the turn closed. There is never a
303
+ * window in which two rows are *committed* owners of the same partition.
304
+ *
305
+ * There is, however, a long window in which *no* row is committed: from the instant this transfer
306
+ * applies until the resumed turn reaches its own success boundary, the authoritative claimant is
307
+ * this `provisional` row. That is the intended steady state of a resumed turn, not a gap — which
308
+ * is why every partition fence is over the authoritative states (see
309
+ * findAuthoritativeOwnersForPartition) and not over the committed rows alone.
310
+ *
311
+ * Returns the transferred record, or null when the transfer did not apply (prior owner already
312
+ * moved, resumed row already advanced, lineage mismatch).
313
+ */
314
+ export function transferOmpSessionOwnership({ fromTaskId, toTaskId }) {
315
+ if (!fromTaskId || !toTaskId || fromTaskId === toTaskId) return null;
316
+ const prior = readOwnership(fromTaskId);
317
+ const incoming = readOwnership(toTaskId);
318
+ if (!prior || prior.state !== 'committed' || !prior.session || !prior.partitionIdentity) {
319
+ return null;
320
+ }
321
+ if (!incoming || incoming.state !== 'provisional') return null;
322
+ if (
323
+ incoming.partitionId !== prior.partitionId ||
324
+ incoming.partitionPath !== prior.partitionPath ||
325
+ incoming.storageRoot !== prior.storageRoot
326
+ ) {
327
+ return null;
328
+ }
329
+
330
+ const transferred = {
331
+ ...incoming,
332
+ state: 'provisional',
333
+ partitionIdentity: prior.partitionIdentity,
334
+ session: prior.session,
335
+ };
336
+ if (!validateOwnedByTask(transferred, toTaskId)) return null;
337
+
338
+ const database = getTaskStoreDatabase();
339
+ const now = new Date().toISOString();
340
+ const priorJson = serializeOmpSessionOwnership(prior);
341
+ const incomingJson = serializeOmpSessionOwnership(incoming);
342
+ const transferredJson = serializeOmpSessionOwnership(transferred);
343
+
344
+ const apply = database.transaction(() => {
345
+ const released = database
346
+ .prepare(
347
+ `UPDATE tasks SET omp_session_ownership = NULL, updated_at = ?
348
+ WHERE id = ? AND omp_session_ownership = ?`
349
+ )
350
+ .run(now, fromTaskId, priorJson);
351
+ if (released.changes !== 1) throw new Error('prior-owner-moved');
352
+ const claimed = database
353
+ .prepare(
354
+ `UPDATE tasks SET omp_session_ownership = ?, updated_at = ?
355
+ WHERE id = ? AND omp_session_ownership = ?`
356
+ )
357
+ .run(transferredJson, now, toTaskId, incomingJson);
358
+ if (claimed.changes !== 1) throw new Error('resumed-owner-moved');
359
+ });
360
+
361
+ try {
362
+ apply();
363
+ } catch {
364
+ return null;
365
+ }
366
+ return validateOmpSessionOwnership(transferred);
367
+ }
@@ -0,0 +1,35 @@
1
+ // Where OMP session partitions live on disk: the owning cluster's storageDir for cluster-agent
2
+ // tasks (passed down via env since agent-task-executor.js spawns this CLI as a child process), or
3
+ // the standalone TASKS_DIR otherwise. Never derived from prompt text or cwd.
4
+ import { TASKS_DIR } from './config.js';
5
+
6
+ export const OMP_STORAGE_ROOT_ENV = 'ZEROSHOT_OMP_STORAGE_ROOT';
7
+ export const OMP_OWNER_CLUSTER_ID_ENV = 'ZEROSHOT_CLUSTER_ID';
8
+ export const OMP_OWNER_AGENT_ID_ENV = 'ZEROSHOT_AGENT_ID';
9
+ /**
10
+ * Set by the agent for a Docker-isolated OMP run. Issue #866 keeps Docker fresh-only, and the
11
+ * container is the reason: its filesystem is ephemeral, so a partition allocated inside it could
12
+ * never be resumed and an ownership row pointing at it would be unreclaimable the moment the
13
+ * container is removed. A task carrying this marker allocates no partition and persists no
14
+ * ownership row — the adapter launches `--no-session`.
15
+ */
16
+ export const OMP_SESSIONLESS_ENV = 'ZEROSHOT_OMP_SESSIONLESS';
17
+
18
+ /** True when this task must run without any session partition at all. */
19
+ export function isOmpSessionlessRun(options = {}) {
20
+ return options.sessionless === true || process.env[OMP_SESSIONLESS_ENV] === '1';
21
+ }
22
+
23
+ export function resolveOmpStorageRoot(options = {}) {
24
+ return options.storageRoot || process.env[OMP_STORAGE_ROOT_ENV] || TASKS_DIR;
25
+ }
26
+
27
+ /** cluster-agent when both a cluster and agent id are known, standalone otherwise. */
28
+ export function resolveOmpOwnerKind(options = {}) {
29
+ const clusterId = options.clusterId || process.env[OMP_OWNER_CLUSTER_ID_ENV] || null;
30
+ const agentId = options.agentId || process.env[OMP_OWNER_AGENT_ID_ENV] || null;
31
+ if (clusterId && agentId) {
32
+ return { kind: 'cluster-agent', clusterId, agentId };
33
+ }
34
+ return { kind: 'standalone', clusterId: null, agentId: null };
35
+ }