@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
|
@@ -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
|
+
}
|