@the-open-engine/zeroshot 6.24.0 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/cli/index.js +37 -2
  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 +111 -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 +17 -0
  30. package/src/omp-session-partition.js +297 -0
  31. package/src/omp-session-verifier.js +576 -0
  32. package/task-lib/commands/clean.js +23 -0
  33. package/task-lib/commands/resume.js +42 -0
  34. package/task-lib/commands/run.js +65 -0
  35. package/task-lib/omp-session-cleanup.js +160 -0
  36. package/task-lib/omp-session-ownership-schema.js +262 -0
  37. package/task-lib/omp-session-ownership.js +332 -0
  38. package/task-lib/omp-storage-root.js +35 -0
  39. package/task-lib/rpc-watcher.js +332 -2
  40. package/task-lib/runner.js +195 -4
  41. package/task-lib/store.js +42 -7
@@ -0,0 +1,332 @@
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. That keeps
20
+ // exactly one *committed* owner — and keeps "committed" meaning what it means everywhere else,
21
+ // this turn's own success boundary has passed — instead of publishing a half-finished
22
+ // continuation as resumable.
23
+ //
24
+ // It does NOT keep exactly one row *referencing* the partition. A resumed row is inserted (and
25
+ // therefore already names the partition) before its transfer runs, so two competing resumes of the
26
+ // same committed session put three rows on one partition: the prior owner plus both candidates.
27
+ // Only one transfer can win; the loser fails closed and is retired to `cleanup-required` holding a
28
+ // record that still names the partition but carries no lineage of its own. Anything that acts on a
29
+ // partition — cleanup above all — must therefore fence on *every* row that references it
30
+ // (`findAuthoritativeOwnersForPartition`), never on the committed rows alone and never on the
31
+ // assumption that its own row is the only claimant.
32
+ import { basename } from 'path';
33
+ import { statSync } from 'fs';
34
+ import { getTask, getTaskStoreDatabase } from './store.js';
35
+ import {
36
+ buildProvisionalOwnership,
37
+ computeExecutionFingerprint,
38
+ serializeOmpSessionOwnership,
39
+ validateOmpSessionOwnership,
40
+ validateOwnedByTask,
41
+ } from './omp-session-ownership-schema.js';
42
+
43
+ export { computeExecutionFingerprint };
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({
53
+ partitionId,
54
+ storageRoot,
55
+ canonicalWorkspace,
56
+ owner,
57
+ }) {
58
+ return buildProvisionalOwnership({
59
+ partitionId,
60
+ storageRoot,
61
+ storageRootIdentity: statIdentity(storageRoot),
62
+ canonicalWorkspace,
63
+ owner,
64
+ });
65
+ }
66
+
67
+ /** Read a row's ownership record, fenced to that row: a well-formed record whose `owner.taskId` is
68
+ * some other task is not this row's ownership and is never returned. */
69
+ export function readOwnership(taskId) {
70
+ return validateOwnedByTask(getTask(taskId)?.ompSessionOwnership ?? null, taskId);
71
+ }
72
+
73
+ /**
74
+ * Compare-and-swap the whole record. `expected` must be the record as currently persisted; a
75
+ * concurrent writer that has already changed the row makes this a no-op returning false.
76
+ */
77
+ function casOwnership(taskId, expected, next) {
78
+ const validatedNext = validateOwnedByTask(next, taskId);
79
+ if (!validatedNext) return false;
80
+ const expectedJson = serializeOmpSessionOwnership(expected);
81
+ const database = getTaskStoreDatabase();
82
+ const result = database
83
+ .prepare(
84
+ `UPDATE tasks SET omp_session_ownership = ?, updated_at = ?
85
+ WHERE id = ? AND omp_session_ownership = ?`
86
+ )
87
+ .run(
88
+ serializeOmpSessionOwnership(validatedNext),
89
+ new Date().toISOString(),
90
+ taskId,
91
+ expectedJson
92
+ );
93
+ return result.changes === 1;
94
+ }
95
+
96
+ /** Shape the observed materialization evidence. Returns null (never throws) if a stat fails. */
97
+ function buildObservedEvidence(
98
+ current,
99
+ {
100
+ sessionId,
101
+ sessionFilePath,
102
+ partitionIdentity,
103
+ sessionFileIdentity,
104
+ artifactManifestDigest,
105
+ executionFingerprint,
106
+ selectedProvider,
107
+ selectedModel,
108
+ }
109
+ ) {
110
+ try {
111
+ return {
112
+ // The verifier hands back descriptor-pinned identities; fall back to a stat only when a
113
+ // caller (a test double, or a path that never ran the verifier) did not supply them.
114
+ partitionIdentity: partitionIdentity ?? statIdentity(current.partitionPath),
115
+ session: {
116
+ sessionId,
117
+ fileName: basename(sessionFilePath),
118
+ fileIdentity: sessionFileIdentity ?? statIdentity(sessionFilePath),
119
+ artifactManifestDigest,
120
+ executionFingerprint,
121
+ selectedProvider,
122
+ selectedModel,
123
+ },
124
+ };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Persist owner-fenced verified materialization evidence WITHOUT advancing state. Used by the
132
+ * detached RPC watcher for cluster-agent owners: the watcher verifies the terminal session file
133
+ * itself (two-phase file contract) but must never decide "committed" on its own — that decision
134
+ * belongs to the parent agent process's post-hook success boundary (see commitRecordedOwnership).
135
+ * Fails closed (returns false, never throws) when the record is missing, is not this task's, has
136
+ * already left `provisional`, or the evidence does not validate.
137
+ */
138
+ export function recordVerifiedMaterialization({ taskId, ...evidence }) {
139
+ const current = readOwnership(taskId);
140
+ if (!current || current.state !== 'provisional') return false;
141
+ const observed = buildObservedEvidence(current, evidence);
142
+ if (!observed) return false;
143
+ return casOwnership(taskId, current, { ...current, state: 'provisional', ...observed });
144
+ }
145
+
146
+ /**
147
+ * Commit a provisional record once the terminal boundary for this task's owner kind has actually
148
+ * succeeded (standalone: the watcher's own output validation; cluster-agent: logical/schema/
149
+ * onComplete hook success). Fails closed (returns false, never throws); the caller must treat a
150
+ * false return as "did not commit" and mark cleanup-required instead.
151
+ */
152
+ export function commitOwnership({ taskId, ...evidence }) {
153
+ const current = readOwnership(taskId);
154
+ if (!current || current.state !== 'provisional') return false;
155
+ const observed = buildObservedEvidence(current, evidence);
156
+ if (!observed) return false;
157
+ return casOwnership(taskId, current, { ...current, state: 'committed', ...observed });
158
+ }
159
+
160
+ /**
161
+ * Commit using evidence the watcher already recorded via recordVerifiedMaterialization — never
162
+ * re-verifies the partition. This is the ONLY path that may advance a cluster-agent owner to
163
+ * `committed`, and only from the post-hook success boundary in src/agent/agent-lifecycle.js:
164
+ * committing earlier would let a later turn resume a turn whose logical/schema output or
165
+ * onComplete hook subsequently failed. Returns false when no verified evidence exists yet.
166
+ */
167
+ export function commitRecordedOwnership(taskId) {
168
+ const current = readOwnership(taskId);
169
+ if (!current || current.state !== 'provisional') return false;
170
+ if (!current.partitionIdentity || !current.session) return false;
171
+ return casOwnership(taskId, current, { ...current, state: 'committed' });
172
+ }
173
+
174
+ /**
175
+ * Mark a still-provisional record cleanup-required on any failed, cancelled, or uncertain terminal
176
+ * boundary. No-op once a record has left `provisional`: a `committed` record is never downgraded,
177
+ * because commit is strictly the last action of an already-successful turn — and a resumed turn is
178
+ * provisional right up to that same boundary (see transferOmpSessionOwnership), so retiring a
179
+ * failed continuation needs no exception here.
180
+ */
181
+ export function markCleanupRequired(taskId) {
182
+ const current = readOwnership(taskId);
183
+ if (!current || current.state !== 'provisional') return current;
184
+ const updated = { ...current, state: 'cleanup-required' };
185
+ return casOwnership(taskId, current, updated) ? updated : readOwnership(taskId);
186
+ }
187
+
188
+ /**
189
+ * The ownership states that constitute a live claim on a partition.
190
+ *
191
+ * `provisional` is authoritative because it is the state a turn occupies while it is *using* the
192
+ * partition — a fresh turn writing into it, and (post-transfer) a resumed turn continuing it right
193
+ * up to its own success boundary. `committed` is authoritative because the session is resumable.
194
+ *
195
+ * `cleanup-required` is deliberately NOT authoritative: it is the state of a turn that has already
196
+ * been retired and makes no further claim. Treating it as one would deadlock cleanup whenever two
197
+ * retired rows name the same partition (the third-owner residue below) — each would refuse forever
198
+ * on account of the other, and the partition could never be reclaimed by anybody.
199
+ */
200
+ export const AUTHORITATIVE_OWNERSHIP_STATES = Object.freeze(['provisional', 'committed']);
201
+
202
+ const AUTHORITATIVE_STATES = new Set(AUTHORITATIVE_OWNERSHIP_STATES);
203
+
204
+ /** Every row other than `excludeTaskId` whose *own* valid record names this partition, as
205
+ * `{taskId, state}`. Rows whose stored JSON is unparseable, invalid, or owned by a different task
206
+ * id are not that row's ownership and are skipped. */
207
+ function ownersForPartition(partitionId, excludeTaskId, database) {
208
+ const rows = database
209
+ .prepare(
210
+ `SELECT id, omp_session_ownership FROM tasks
211
+ WHERE omp_session_ownership IS NOT NULL
212
+ AND json_extract(omp_session_ownership, '$.partitionId') = ?`
213
+ )
214
+ .all(partitionId);
215
+ const owners = [];
216
+ for (const row of rows) {
217
+ if (row.id === excludeTaskId) continue;
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(row.omp_session_ownership);
221
+ } catch {
222
+ continue;
223
+ }
224
+ const record = validateOwnedByTask(parsed, row.id);
225
+ if (record) owners.push({ taskId: row.id, state: record.state });
226
+ }
227
+ return owners;
228
+ }
229
+
230
+ /** Rows other than `excludeTaskId` that still hold a committed record for this partition — i.e.
231
+ * the rows for which this partition is a *resumable* session. */
232
+ export function findCommittedOwnersForPartition(
233
+ partitionId,
234
+ excludeTaskId = null,
235
+ database = getTaskStoreDatabase()
236
+ ) {
237
+ return ownersForPartition(partitionId, excludeTaskId, database)
238
+ .filter((owner) => owner.state === 'committed')
239
+ .map((owner) => owner.taskId);
240
+ }
241
+
242
+ /**
243
+ * Rows other than `excludeTaskId` holding an authoritative (`provisional` or `committed`) claim on
244
+ * this partition, as `{taskId, state}`.
245
+ *
246
+ * This is the owner fence cleanup runs on, and it is strictly wider than the committed owners:
247
+ * after a resume's atomic transfer the winning row is `provisional` — it carries the whole
248
+ * inherited lineage and is actively continuing the session, while *no* row is committed. A losing
249
+ * competing resume, retired to `cleanup-required`, still names the same partition and holds no
250
+ * lineage of its own, so a committed-only fence would see nothing and let it delete the winner's
251
+ * live partition out from under it.
252
+ *
253
+ * `database` is injectable so a caller can run this inside its own write transaction (see
254
+ * task-lib/omp-session-cleanup.js) rather than racing its own check.
255
+ */
256
+ export function findAuthoritativeOwnersForPartition(
257
+ partitionId,
258
+ excludeTaskId = null,
259
+ database = getTaskStoreDatabase()
260
+ ) {
261
+ return ownersForPartition(partitionId, excludeTaskId, database).filter((owner) =>
262
+ AUTHORITATIVE_STATES.has(owner.state)
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Atomically move a committed partition's ownership from `fromTaskId` to `toTaskId`, before the
268
+ * resumed turn's prompt is written.
269
+ *
270
+ * Both sides are fenced on their exact current JSON inside one transaction, so the outcome is all
271
+ * or nothing: either the prior owner's record is cleared *and* the resumed row carries the
272
+ * inherited lineage, or nothing changed and the caller must fail the turn closed. There is never a
273
+ * window in which two rows are committed owners of the same partition, nor one in which the
274
+ * partition has no owner row at all.
275
+ *
276
+ * Returns the transferred record, or null when the transfer did not apply (prior owner already
277
+ * moved, resumed row already advanced, lineage mismatch).
278
+ */
279
+ export function transferOmpSessionOwnership({ fromTaskId, toTaskId }) {
280
+ if (!fromTaskId || !toTaskId || fromTaskId === toTaskId) return null;
281
+ const prior = readOwnership(fromTaskId);
282
+ const incoming = readOwnership(toTaskId);
283
+ if (!prior || prior.state !== 'committed' || !prior.session || !prior.partitionIdentity) {
284
+ return null;
285
+ }
286
+ if (!incoming || incoming.state !== 'provisional') return null;
287
+ if (
288
+ incoming.partitionId !== prior.partitionId ||
289
+ incoming.partitionPath !== prior.partitionPath ||
290
+ incoming.storageRoot !== prior.storageRoot
291
+ ) {
292
+ return null;
293
+ }
294
+
295
+ const transferred = {
296
+ ...incoming,
297
+ state: 'provisional',
298
+ partitionIdentity: prior.partitionIdentity,
299
+ session: prior.session,
300
+ };
301
+ if (!validateOwnedByTask(transferred, toTaskId)) return null;
302
+
303
+ const database = getTaskStoreDatabase();
304
+ const now = new Date().toISOString();
305
+ const priorJson = serializeOmpSessionOwnership(prior);
306
+ const incomingJson = serializeOmpSessionOwnership(incoming);
307
+ const transferredJson = serializeOmpSessionOwnership(transferred);
308
+
309
+ const apply = database.transaction(() => {
310
+ const released = database
311
+ .prepare(
312
+ `UPDATE tasks SET omp_session_ownership = NULL, updated_at = ?
313
+ WHERE id = ? AND omp_session_ownership = ?`
314
+ )
315
+ .run(now, fromTaskId, priorJson);
316
+ if (released.changes !== 1) throw new Error('prior-owner-moved');
317
+ const claimed = database
318
+ .prepare(
319
+ `UPDATE tasks SET omp_session_ownership = ?, updated_at = ?
320
+ WHERE id = ? AND omp_session_ownership = ?`
321
+ )
322
+ .run(transferredJson, now, toTaskId, incomingJson);
323
+ if (claimed.changes !== 1) throw new Error('resumed-owner-moved');
324
+ });
325
+
326
+ try {
327
+ apply();
328
+ } catch {
329
+ return null;
330
+ }
331
+ return validateOmpSessionOwnership(transferred);
332
+ }
@@ -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
+ }