@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
@@ -23,6 +23,11 @@ const { loadSettings } = require('../../lib/settings');
23
23
  const { findPlatformMismatchReason } = require('./validation-platform');
24
24
  const { calculateRateLimitDelay, isRateLimitError } = require('./rate-limit-backoff');
25
25
  const { updateAgentProviderSession } = require('./provider-session');
26
+ const { rebuildProviderSessionAfterCommit } = require('./agent-task-executor');
27
+ const {
28
+ commitRecordedOwnership,
29
+ markCleanupRequired,
30
+ } = require('../../task-lib/omp-session-ownership.js');
26
31
 
27
32
  const DEFAULT_VALIDATOR_IMAGE = 'zeroshot-cluster-base';
28
33
 
@@ -445,6 +450,51 @@ function publishTaskStarted(agent, triggeringMessage) {
445
450
  });
446
451
  }
447
452
 
453
+ function resolveAgentProviderName(agent) {
454
+ return normalizeProviderName(
455
+ agent._resolveProvider ? agent._resolveProvider() : getDefaultProviderId()
456
+ );
457
+ }
458
+
459
+ /**
460
+ * Advance the OMP ownership record to `committed` and rebuild the provider-session snapshot from
461
+ * the row that commit produced.
462
+ *
463
+ * Ordering matters and is the whole point of this function. The detached RPC watcher verifies the
464
+ * materialized session but deliberately leaves a cluster-agent owner `provisional`, because this
465
+ * post-hook boundary — logical/schema output validated, onComplete hook succeeded — is the first
466
+ * moment the turn is durable. `result.providerSession` was therefore computed against a
467
+ * provisional row and is null for OMP by construction; publishing it would make every cluster
468
+ * session permanently non-reusable. So: commit, check that the commit actually applied, re-read
469
+ * the row, rebuild, and only then hand the snapshot to the agent and to TASK_COMPLETED.
470
+ *
471
+ * A commit that does not apply (no verified evidence recorded — a materialization the watcher
472
+ * could not verify, or a concurrent writer that already moved the row) means this turn has no
473
+ * durably resumable session: the partition is retired and the next turn starts fresh.
474
+ */
475
+ function finalizeProviderSessionAfterCommit(agent, result) {
476
+ const providerName = resolveAgentProviderName(agent);
477
+ if (providerName !== 'omp') return result.providerSession;
478
+
479
+ if (!commitRecordedOwnership(result.taskId)) {
480
+ markCleanupRequired(result.taskId);
481
+ result.providerSession = null;
482
+ return null;
483
+ }
484
+ const rebuilt = rebuildProviderSessionAfterCommit({
485
+ agent,
486
+ providerName,
487
+ taskId: result.taskId,
488
+ logicalSuccess: true,
489
+ });
490
+ // A committed row that cannot be snapshotted (the agent moved workspace, isolation is on, the
491
+ // row's tuple failed re-validation) is not resumable by this agent, but it is still a valid
492
+ // session owned by a live task row — so it is left committed and reclaimed with that row rather
493
+ // than retired here.
494
+ result.providerSession = rebuilt;
495
+ return rebuilt;
496
+ }
497
+
448
498
  function attachResultMetadata(agent, result) {
449
499
  // Add task ID to result for debugging and hooks
450
500
  result.taskId = result.taskId || agent.currentTaskId;
@@ -593,6 +643,7 @@ async function runTaskAttempt(agent, triggeringMessage) {
593
643
  result = await agent._spawnClaudeTask(context);
594
644
  } catch (error) {
595
645
  updateAgentProviderSession(agent, null);
646
+ markCleanupRequired(error.taskId || agent.currentTaskId);
596
647
  throw error;
597
648
  }
598
649
  attachResultMetadata(agent, result);
@@ -600,6 +651,7 @@ async function runTaskAttempt(agent, triggeringMessage) {
600
651
  // Check if task execution failed
601
652
  if (!result.success) {
602
653
  updateAgentProviderSession(agent, null);
654
+ markCleanupRequired(result.taskId);
603
655
  const error = new Error(result.error || 'Task execution failed');
604
656
  error.code = result.code || result.errorType || null;
605
657
  error.taskId = result.taskId || null;
@@ -610,21 +662,29 @@ async function runTaskAttempt(agent, triggeringMessage) {
610
662
  const fallbackReason = await maybeRetryValidatorInDocker(agent, result);
611
663
  if (fallbackReason) {
612
664
  updateAgentProviderSession(agent, null);
665
+ markCleanupRequired(result.taskId);
613
666
  throw new Error(
614
667
  `Validator platform mismatch detected (${fallbackReason}). Retrying in Docker isolation.`
615
668
  );
616
669
  }
617
670
 
618
671
  // The hook publishes the logical output of the turn. Until it succeeds, neither
619
- // TASK_COMPLETED nor its provider continuation boundary is durable.
672
+ // TASK_COMPLETED nor its provider continuation boundary is durable — an OMP cluster-agent
673
+ // owner's ownership record must stay unresumable until this succeeds too (see
674
+ // task-lib/rpc-watcher.js finalizeOmpOwnership, which only records evidence and defers the
675
+ // commit decision to this exact boundary).
620
676
  try {
621
677
  await executeOnCompleteHookWithRetry(agent, triggeringMessage, result);
622
678
  } catch (error) {
623
679
  updateAgentProviderSession(agent, null);
680
+ markCleanupRequired(result.taskId);
624
681
  throw error;
625
682
  }
626
683
 
627
- updateAgentProviderSession(agent, result.providerSession);
684
+ // Checked commit, then snapshot. See finalizeProviderSessionAfterCommit: the snapshot inside
685
+ // `result` was built while the ownership row was still provisional, so it must be rebuilt from
686
+ // the committed row before it is stored on the agent or published with TASK_COMPLETED.
687
+ updateAgentProviderSession(agent, finalizeProviderSessionAfterCommit(agent, result));
628
688
  agent.lastGuidanceAppliedId = agent.currentGuidanceSequence;
629
689
 
630
690
  // Set state to idle BEFORE publishing lifecycle event
@@ -1416,6 +1476,10 @@ module.exports = {
1416
1476
  handleMessage,
1417
1477
  executeTriggerAction,
1418
1478
  executeTask,
1479
+ // Exported for tests/unit/omp-provider-session.test.js: the commit-then-snapshot ordering it
1480
+ // enforces is the difference between a reusable OMP cluster session and one that is silently
1481
+ // never resumable, so it is covered directly rather than only through a full task attempt.
1482
+ finalizeProviderSessionAfterCommit,
1419
1483
  startLivenessCheck,
1420
1484
  stopLivenessCheck,
1421
1485
  };
@@ -17,6 +17,7 @@ const { getNestedExecutionRegistry, TaskExecutionHandle } = require('./task-exec
17
17
  const os = require('os');
18
18
  const { parseProviderChunk, getProvider } = require('../providers');
19
19
  const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
20
+ const { OMP_SESSIONLESS_ENV } = require('../../task-lib/omp-storage-root.js');
20
21
  const { loadSettings } = require('../../lib/settings.js');
21
22
  const { getDefaultProviderId } = require('../../lib/provider-names');
22
23
  const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
@@ -46,6 +47,7 @@ const {
46
47
  } = require('../task-spawn-cleanup-ownership');
47
48
  const {
48
49
  providerSessionFromCompletedTask,
50
+ resolveAgentProviderSession,
49
51
  resolveAgentResumeSessionId,
50
52
  validateCompletedResumeIdentity,
51
53
  } = require('./provider-session');
@@ -722,14 +724,48 @@ function buildTaskRunArgs({
722
724
  args.push(mcpArg);
723
725
  }
724
726
 
725
- const resumeSessionId = resolveAgentResumeSessionId(agent, providerName);
726
- if (resumeSessionId) {
727
- args.push('--resume', resumeSessionId);
727
+ if (providerName === 'omp') {
728
+ const ompResumeArg = buildOmpResumeArg(agent, providerName);
729
+ if (ompResumeArg) args.push('--omp-resume', ompResumeArg);
730
+ } else {
731
+ const resumeSessionId = resolveAgentResumeSessionId(agent, providerName);
732
+ if (resumeSessionId) {
733
+ args.push('--resume', resumeSessionId);
734
+ }
728
735
  }
729
736
 
730
737
  return args;
731
738
  }
732
739
 
740
+ /**
741
+ * OMP never accepts a bare `--resume <sessionId>` (see failClosedUnsupportedSessionControl in
742
+ * adapters/omp.ts): the child `zeroshot task run` process needs the *complete* committed tuple so
743
+ * it can re-check it against the prior owner's persisted row and re-verify identity/manifest/
744
+ * fingerprint drift before ever spawning OMP.
745
+ *
746
+ * `priorOwnerTaskId` is what turns this from an assertion into a cross-check: the child resolves
747
+ * that row and requires every field below to match it exactly, so a stale or tampered argv
748
+ * descriptor is a conflict that fails closed instead of a claim that is taken at face value.
749
+ * Nothing here is secret — it is the same non-secret shape as task.ompSessionOwnership.session,
750
+ * and it deliberately carries no storage-root or partition path.
751
+ */
752
+ function buildOmpResumeArg(agent, providerName) {
753
+ const session = resolveAgentProviderSession(agent, providerName);
754
+ const ompSession = session?.ompSession;
755
+ if (!session || !ompSession) return null;
756
+ return JSON.stringify({
757
+ priorOwnerTaskId: session.taskId,
758
+ partitionId: ompSession.partitionId,
759
+ sessionFileName: ompSession.sessionFileName,
760
+ expectedSessionId: session.sessionId,
761
+ expectedSessionFileIdentity: ompSession.sessionFileIdentity,
762
+ expectedArtifactManifestDigest: ompSession.artifactManifestDigest,
763
+ expectedExecutionFingerprint: ompSession.executionFingerprint,
764
+ expectedSelectedProvider: ompSession.selectedProvider,
765
+ expectedSelectedModel: ompSession.selectedModel,
766
+ });
767
+ }
768
+
733
769
  /**
734
770
  * Build the `--mcp-config` args for a task-run invocation, or [] when they don't apply.
735
771
  *
@@ -805,6 +841,7 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
805
841
 
806
842
  if (clusterId) {
807
843
  spawnEnv.ZEROSHOT_CLUSTER_ID = clusterId;
844
+ spawnEnv.ZEROSHOT_AGENT_ID = agent.id;
808
845
  const cmdproofRoot = path.join(os.homedir(), '.zeroshot', 'cmdproof', clusterId);
809
846
  if (!spawnEnv.CMDPROOF_CACHE_DIR) {
810
847
  spawnEnv.CMDPROOF_CACHE_DIR = path.join(cmdproofRoot, 'cache');
@@ -812,6 +849,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
812
849
  if (!spawnEnv.CMDPROOF_KEY_DIR) {
813
850
  spawnEnv.CMDPROOF_KEY_DIR = path.join(cmdproofRoot, 'keys');
814
851
  }
852
+ // OMP session partitions live under the owning cluster's storageDir (never TASKS_DIR — see
853
+ // task-lib/omp-storage-root.js), forwarded to the spawned `zeroshot task run` child since it
854
+ // has no other way to learn which orchestrator instance owns this cluster.
855
+ if (providerName === 'omp') {
856
+ spawnEnv.ZEROSHOT_OMP_STORAGE_ROOT =
857
+ agent.cluster?.storageDir || path.join(os.homedir(), '.zeroshot');
858
+ }
815
859
  }
816
860
 
817
861
  const commandProofs = Array.isArray(agent.config?.commandProofs)
@@ -1456,6 +1500,25 @@ async function buildCompletionResult({
1456
1500
  };
1457
1501
  }
1458
1502
 
1503
+ /**
1504
+ * Rebuild the provider-session snapshot from a *freshly re-read* task row.
1505
+ *
1506
+ * The snapshot inside a completion result is necessarily built while the OMP ownership row is
1507
+ * still `provisional` — the detached watcher records verified materialization evidence but defers
1508
+ * the commit decision to the agent's post-hook success boundary — so for provider `omp` it is
1509
+ * stale by construction and always null. agent-lifecycle.js calls this *after* a checked commit
1510
+ * and before publishing TASK_COMPLETED, which is the only point at which a durable, resumable
1511
+ * `ompSession` exists to snapshot.
1512
+ */
1513
+ function rebuildProviderSessionAfterCommit({ agent, providerName, taskId, logicalSuccess = true }) {
1514
+ return providerSessionFromCompletedTask({
1515
+ agent,
1516
+ providerName,
1517
+ taskInfo: getTask(taskId),
1518
+ logicalSuccess,
1519
+ });
1520
+ }
1521
+
1459
1522
  function finalizeLogFollow(agent, state) {
1460
1523
  if (state.pollInterval) {
1461
1524
  clearInterval(state.pollInterval);
@@ -1886,6 +1949,11 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
1886
1949
  // only authoritative bridge back to the detached task row in the container.
1887
1950
  const isolatedEnv = {
1888
1951
  ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
1952
+ // Docker is fresh-only for OMP (issue #866): the container filesystem is ephemeral, so a
1953
+ // session partition allocated inside it could never be resumed and its ownership row would be
1954
+ // unreclaimable once the container is removed. This marker makes the in-container
1955
+ // `zeroshot task run` skip partition allocation entirely and launch `--no-session`.
1956
+ ...(providerName === 'omp' ? { [OMP_SESSIONLESS_ENV]: '1' } : {}),
1889
1957
  [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
1890
1958
  };
1891
1959
 
@@ -3003,5 +3071,6 @@ module.exports = {
3003
3071
  parseResultOutput,
3004
3072
  buildCompletionResult,
3005
3073
  buildTaskRunArgs,
3074
+ rebuildProviderSessionAfterCommit,
3006
3075
  killTask,
3007
3076
  };
@@ -3,6 +3,7 @@ const path = require('path');
3
3
 
4
4
  const { normalizeProviderName, providerSupportsCapability } = require('../../lib/provider-names');
5
5
  const { tryCanonicalMessageSequence } = require('../ledger-sequence');
6
+ const { validateOwnedByTask } = require('../../task-lib/omp-session-ownership-schema.js');
6
7
 
7
8
  const DURABLE_SESSION_BOUNDARY_EVENTS = new Set([
8
9
  'TASK_STARTED',
@@ -44,6 +45,77 @@ function promptIdentity(value) {
44
45
  return `sha256:${crypto.createHash('sha256').update(String(value)).digest('hex')}`;
45
46
  }
46
47
 
48
+ const DECIMAL_STRING = /^(0|[1-9][0-9]*)$/;
49
+ const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
50
+ const SESSION_FILE_NAME = /^[^/\\]+\.jsonl$/;
51
+ const { PARTITION_ID_PATTERN } = require('../omp-session-partition');
52
+
53
+ // Exactly the field set issue #866 fixes for this snapshot — no more, no less. The record is
54
+ // closed in both directions so a stale snapshot written by a different Zeroshot version, or one
55
+ // carrying smuggled extra state, is rejected rather than partially trusted.
56
+ const OMP_SESSION_KEYS = new Set([
57
+ 'schemaVersion',
58
+ 'partitionId',
59
+ 'sessionFileName',
60
+ 'sessionFileIdentity',
61
+ 'artifactManifestDigest',
62
+ 'executionFingerprint',
63
+ 'selectedProvider',
64
+ 'selectedModel',
65
+ ]);
66
+ const IDENTITY_KEYS = new Set(['device', 'inode']);
67
+
68
+ function normalizeIdentity(value) {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
70
+ if (!Object.keys(value).every((key) => IDENTITY_KEYS.has(key))) return null;
71
+ if (!DECIMAL_STRING.test(String(value.device)) || !DECIMAL_STRING.test(String(value.inode))) {
72
+ return null;
73
+ }
74
+ return { device: String(value.device), inode: String(value.inode) };
75
+ }
76
+
77
+ /**
78
+ * The optional providerSession.ompSession field (issue #866): required in addition to the
79
+ * generic tuple above for provider 'omp', absent for every other provider. Every digest here is
80
+ * sha256:<64-lower-hex>; every device/inode is a canonical unsigned decimal string. Never carries
81
+ * storage-root or partition paths — those stay in task.ompSessionOwnership, not the agent
82
+ * snapshot; the partition is re-derived from the owner row at resume time.
83
+ */
84
+ function normalizeOmpSession(value) {
85
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
86
+ return null;
87
+ }
88
+ if (!Object.keys(value).every((key) => OMP_SESSION_KEYS.has(key))) return null;
89
+ const sessionFileIdentity = normalizeIdentity(value.sessionFileIdentity);
90
+ if (
91
+ value.schemaVersion !== 1 ||
92
+ !normalizeNonEmptyString(value.partitionId) ||
93
+ !PARTITION_ID_PATTERN.test(value.partitionId) ||
94
+ !normalizeNonEmptyString(value.sessionFileName) ||
95
+ !SESSION_FILE_NAME.test(value.sessionFileName) ||
96
+ value.sessionFileName === '.jsonl' ||
97
+ !sessionFileIdentity ||
98
+ typeof value.artifactManifestDigest !== 'string' ||
99
+ !SHA256_DIGEST.test(value.artifactManifestDigest) ||
100
+ typeof value.executionFingerprint !== 'string' ||
101
+ !SHA256_DIGEST.test(value.executionFingerprint) ||
102
+ !normalizeNonEmptyString(value.selectedProvider) ||
103
+ !normalizeNonEmptyString(value.selectedModel)
104
+ ) {
105
+ return null;
106
+ }
107
+ return {
108
+ schemaVersion: 1,
109
+ partitionId: value.partitionId,
110
+ sessionFileName: value.sessionFileName,
111
+ sessionFileIdentity,
112
+ artifactManifestDigest: value.artifactManifestDigest,
113
+ executionFingerprint: value.executionFingerprint,
114
+ selectedProvider: value.selectedProvider,
115
+ selectedModel: value.selectedModel,
116
+ };
117
+ }
118
+
47
119
  function supportsSessionResume(providerName) {
48
120
  try {
49
121
  return providerSupportsCapability(providerName, 'sessionResume');
@@ -68,6 +140,8 @@ function normalizeProviderSession(value) {
68
140
  const contextSequence = normalizeCursor(value.contextSequence);
69
141
  const guidanceSequence = normalizeNullableCursor(value.guidanceSequence);
70
142
  const normalizedPromptIdentity = normalizePromptIdentity(value.promptIdentity);
143
+ const hasOmpSession = Object.hasOwn(value, 'ompSession');
144
+ const normalizedOmpSession = hasOmpSession ? normalizeOmpSession(value.ompSession) : null;
71
145
 
72
146
  if (
73
147
  !provider ||
@@ -84,7 +158,8 @@ function normalizeProviderSession(value) {
84
158
  (value.guidanceSequence !== null && guidanceSequence === null) ||
85
159
  !Object.hasOwn(value, 'promptIdentity') ||
86
160
  normalizedPromptIdentity === undefined ||
87
- !supportsSessionResume(provider)
161
+ !supportsSessionResume(provider) ||
162
+ (provider === 'omp' ? normalizedOmpSession === null : hasOmpSession)
88
163
  ) {
89
164
  return null;
90
165
  }
@@ -100,6 +175,7 @@ function normalizeProviderSession(value) {
100
175
  contextSequence,
101
176
  guidanceSequence,
102
177
  promptIdentity: normalizedPromptIdentity,
178
+ ...(provider === 'omp' ? { ompSession: normalizedOmpSession } : {}),
103
179
  };
104
180
  }
105
181
 
@@ -169,6 +245,29 @@ function validateCompletedResumeIdentity(taskInfo) {
169
245
  : 'Provider continuation did not confirm the requested session identity';
170
246
  }
171
247
 
248
+ /** taskInfo.ompSessionOwnership is the authoritative, watcher-committed continuation evidence for
249
+ * provider 'omp'; a provisional/cleanup-required/missing record means this task never durably
250
+ * proved a resumable session, regardless of what the generic sessionId/resumeIdentityVerified
251
+ * columns say (rpc-watcher.js never populates those — they're the stdout-parsing watchers' path).
252
+ * The record is re-fenced to this exact task row: an ownership object naming a different owner is
253
+ * not this task's continuation evidence, however well-formed it is. */
254
+ function ompSessionFromCompletedTask(taskInfo) {
255
+ const ownership = validateOwnedByTask(taskInfo?.ompSessionOwnership ?? null, taskInfo?.id);
256
+ if (!ownership || ownership.state !== 'committed' || !ownership.session) {
257
+ return null;
258
+ }
259
+ return {
260
+ schemaVersion: 1,
261
+ partitionId: ownership.partitionId,
262
+ sessionFileName: ownership.session.fileName,
263
+ sessionFileIdentity: ownership.session.fileIdentity,
264
+ artifactManifestDigest: ownership.session.artifactManifestDigest,
265
+ executionFingerprint: ownership.session.executionFingerprint,
266
+ selectedProvider: ownership.session.selectedProvider,
267
+ selectedModel: ownership.session.selectedModel,
268
+ };
269
+ }
270
+
172
271
  function providerSessionFromCompletedTask({
173
272
  agent,
174
273
  providerName,
@@ -192,7 +291,16 @@ function providerSessionFromCompletedTask({
192
291
  return null;
193
292
  }
194
293
 
195
- const sessionId = normalizeNonEmptyString(taskInfo.sessionId);
294
+ const isOmp = provider === 'omp';
295
+ const ompOwnership = isOmp
296
+ ? validateOwnedByTask(taskInfo.ompSessionOwnership ?? null, taskInfo.id)
297
+ : null;
298
+ const ompSession = isOmp ? ompSessionFromCompletedTask(taskInfo) : null;
299
+ // rpc-watcher.js never populates the generic sessionId column; the OMP-observed session ID
300
+ // committed alongside ompSession is the one authoritative identity for this provider.
301
+ const sessionId = isOmp
302
+ ? normalizeNonEmptyString(ompOwnership?.state === 'committed' ? ompOwnership.session?.sessionId : null)
303
+ : normalizeNonEmptyString(taskInfo.sessionId);
196
304
  const taskId = normalizeNonEmptyString(taskInfo.id);
197
305
  const generation = agent?.iteration;
198
306
  const agentId = normalizeNonEmptyString(agent?.id);
@@ -207,6 +315,7 @@ function providerSessionFromCompletedTask({
207
315
  contextSequence: agent?.currentContextSequence,
208
316
  guidanceSequence: agent?.currentGuidanceSequence ?? null,
209
317
  promptIdentity: agent?.currentPromptIdentity ?? null,
318
+ ...(isOmp ? { ompSession } : {}),
210
319
  });
211
320
  }
212
321
 
@@ -4,6 +4,7 @@ import { appendJsonSchemaPrompt } from '../schema';
4
4
  import { isRecord, unknownToMessage } from '../json';
5
5
  import { OMP_REMEDIATION, OMP_SUPPORTED_VERSION } from '../omp-release';
6
6
  import { parseNormalizedOmpRpcEventLine } from '../omp-rpc-events';
7
+ import type { OmpSessionLaunch } from '../omp-rpc-session';
7
8
  import {
8
9
  InvalidProviderModelError,
9
10
  type BuildProviderCommandOptions,
@@ -180,8 +181,13 @@ function detectCliFeatures(helpText?: string | null, versionText?: string | null
180
181
  };
181
182
  }
182
183
 
184
+ function resolveOmpSessionLaunch(options: BuildProviderCommandOptions): OmpSessionLaunch {
185
+ return options.ompSession ?? { kind: 'none' };
186
+ }
187
+
183
188
  function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
184
189
  const features = optionFeatures(options);
190
+ const session = resolveOmpSessionLaunch(options);
185
191
  const required: ReadonlyArray<readonly [boolean | undefined, string]> = [
186
192
  [features.versionMatches, `exact OMP version ${OMP_SUPPORTED_VERSION}`],
187
193
  [features.supportsRpcMode, '"rpc" mode'],
@@ -190,6 +196,10 @@ function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
190
196
  [features.supportsApprovalMode, '--approval-mode'],
191
197
  [features.supportsNoTitle, '--no-title'],
192
198
  [features.supportsNoSession, '--no-session'],
199
+ ...(session.kind === 'none'
200
+ ? []
201
+ : ([[features.supportsSessionDir, '--session-dir']] as const)),
202
+ ...(session.kind === 'resume' ? ([[features.supportsResume, '--resume']] as const) : []),
193
203
  ];
194
204
  const missing = required.filter(([supported]) => supported === false).map(([, label]) => label);
195
205
  if (missing.length === 0) return;
@@ -203,16 +213,35 @@ function assertRequiredOmpFeatures(options: BuildProviderCommandOptions): void {
203
213
  }
204
214
 
205
215
  function failClosedUnsupportedSessionControl(options: BuildProviderCommandOptions): void {
206
- const hasResumeSessionId = options.resumeSessionId !== undefined;
207
- if (!hasResumeSessionId && !options.continueSession) return;
208
- const field = hasResumeSessionId ? 'options.resumeSessionId' : 'options.continueSession';
209
- throw contractError({
210
- code: 'invalid-field',
211
- field,
212
- exitCode: 2,
213
- message:
214
- 'OMP RPC lane runs sessionless (--no-session) in this slice; resume/continue session control is capability-gated off (sessionResume: false).',
215
- });
216
+ if (options.continueSession) {
217
+ throw contractError({
218
+ code: 'invalid-field',
219
+ field: 'options.continueSession',
220
+ exitCode: 2,
221
+ message: 'OMP RPC lane never supports --continue; continuation is always an explicit verified --resume partition.',
222
+ });
223
+ }
224
+ const hasVerifiedResume = resolveOmpSessionLaunch(options).kind === 'resume';
225
+ if (options.resumeSessionId !== undefined && !hasVerifiedResume) {
226
+ throw contractError({
227
+ code: 'invalid-field',
228
+ field: 'options.resumeSessionId',
229
+ exitCode: 2,
230
+ message:
231
+ 'OMP RPC lane requires a verified session partition (options.ompSession.kind === "resume") to resume; a bare session ID cannot be trusted.',
232
+ });
233
+ }
234
+ }
235
+
236
+ function sessionArgs(session: OmpSessionLaunch): readonly string[] {
237
+ switch (session.kind) {
238
+ case 'none':
239
+ return ['--no-session'];
240
+ case 'fresh':
241
+ return ['--session-dir', session.partition.path];
242
+ case 'resume':
243
+ return ['--session-dir', session.partition.path, '--resume', session.file.path];
244
+ }
216
245
  }
217
246
 
218
247
  function rejectMcpConfig(options: BuildProviderCommandOptions): void {
@@ -257,8 +286,9 @@ function buildCommand(_context: string, options: BuildProviderCommandOptions = {
257
286
  const modelSelector = resolveModelSelector(options);
258
287
  const warnings = collectWarnings(options);
259
288
  const overlay = createOmpConfigOverlay();
289
+ const session = resolveOmpSessionLaunch(options);
260
290
 
261
- const args: string[] = ['--mode', 'rpc', '--no-session', '--model', modelSelector];
291
+ const args: string[] = ['--mode', 'rpc', ...sessionArgs(session), '--model', modelSelector];
262
292
  if (options.modelSpec?.reasoningEffort) {
263
293
  args.push('--thinking', options.modelSpec.reasoningEffort);
264
294
  }
@@ -464,6 +464,20 @@ export function runOmpRpcTask(
464
464
  // (agent_end / a delayed prompt_result), not by this function returning.
465
465
  }
466
466
 
467
+ // Present on both the get_state response's `data` and a `session_info_update` event frame per
468
+ // docs/rpc.md; either may carry only a subset, so callers merge this onto prior evidence
469
+ // rather than replacing it wholesale.
470
+ function sessionFieldsFromRecord(
471
+ record: Record<string, unknown>
472
+ ): Partial<Pick<OmpRpcSessionEvidence, 'sessionId' | 'sessionFile'>> {
473
+ const sessionId = getString(record, 'sessionId');
474
+ const sessionFile = getString(record, 'sessionFile');
475
+ return {
476
+ ...(sessionId !== null ? { sessionId } : {}),
477
+ ...(sessionFile !== null ? { sessionFile } : {}),
478
+ };
479
+ }
480
+
467
481
  function sessionEvidenceFromState(
468
482
  stateResponse: Record<string, unknown> | null
469
483
  ): Omit<OmpRpcSessionEvidence, 'phase'> {
@@ -471,15 +485,27 @@ export function runOmpRpcTask(
471
485
  const data = getRecord(stateResponse, 'data');
472
486
  const model = data ? getRecord(data, 'model') : null;
473
487
  return {
474
- sessionId: null,
475
- sessionFile: null,
488
+ ...UNKNOWN_SESSION_EVIDENCE,
489
+ ...(data ? sessionFieldsFromRecord(data) : {}),
476
490
  selectedProvider: (model ? getString(model, 'provider') : null) ?? '',
477
491
  selectedModel: (model ? getString(model, 'id') : null) ?? '',
478
492
  thinkingLevel: (data ? getString(data, 'thinkingLevel') : null) ?? '',
479
493
  };
480
494
  }
481
495
 
482
- function dispatchFrame(frame: OmpRpcInboundFrame): void {
496
+ // session_info_update is a builtin slash-command side channel (docs/rpc.md) that can carry a
497
+ // later-observed sessionId/sessionFile than the initial get_state snapshot. Returning the
498
+ // hooks.onSession() promise (rather than fire-and-forget) lets a persistence failure surface
499
+ // through the same enqueue()/state.chain .catch() -> failPermanently() path as every other
500
+ // dispatch failure, instead of being silently swallowed.
501
+ function handleSessionInfoUpdate(frame: OmpRpcInboundFrame): Promise<void> | void {
502
+ const updates = sessionFieldsFromRecord(frame);
503
+ if (Object.keys(updates).length === 0) return;
504
+ state.sessionEvidence = { ...state.sessionEvidence, ...updates };
505
+ return hooks.onSession({ ...state.sessionEvidence, phase: 'ready' });
506
+ }
507
+
508
+ function dispatchFrame(frame: OmpRpcInboundFrame): void | Promise<void> {
483
509
  if (state.terminal) return; // Frames after the terminal frame are dropped.
484
510
  if (!state.readyReceived) {
485
511
  dispatchReadyFrame(frame);
@@ -513,6 +539,8 @@ export function runOmpRpcTask(
513
539
  case 'agent_end':
514
540
  handleAgentEnd();
515
541
  return;
542
+ case 'session_info_update':
543
+ return handleSessionInfoUpdate(frame);
516
544
  default:
517
545
  if (state.promptSent) emitNormalized(frame);
518
546
  }
@@ -1,6 +1,6 @@
1
- // Session-launch types for the OMP RPC v2 driver. This slice launches sessionless
2
- // (`--no-session`) only; `fresh`/`resume` are typed for Subissue 4's session-flag activation but
3
- // are unreachable from any caller in this slice.
1
+ // Session-launch types for the OMP RPC v2 driver. `none` keeps the Docker-only sessionless launch
2
+ // (`--no-session`); `fresh`/`resume` carry a verified partition (and, for resume, a verified
3
+ // session file) allocated and checked by the JS task-lib layer — never a raw, unverified path.
4
4
 
5
5
  export interface VerifiedOmpPartition {
6
6
  readonly path: string;
@@ -486,6 +486,12 @@ export const providerRegistry = [
486
486
  // Written out explicitly rather than spread from STANDARD_CAPABILITIES, which defaults
487
487
  // dockerIsolation to true; OMP's Docker path is env/broker-only and sessionless (see
488
488
  // AGENTS.md OMP Docker section) rather than the standard credential-mount + resume shape.
489
+ // sessionResume is true as of issue #866: verified UUID partitions, two-phase file
490
+ // verification, and the owner-fenced ownership FSM (task-lib/omp-session-ownership.js) are
491
+ // live end to end for host, worktree, detached cluster-agent, and standalone manual resume.
492
+ // The two are independent: an isolated (Docker) OMP task allocates no session partition at all
493
+ // and launches `--no-session`, so `sessionResume: true` never implies a resumable container
494
+ // turn (task-lib/runner.js#resolveOmpSessionPlan, OMP_SESSIONLESS_ENV).
489
495
  capabilities: {
490
496
  dockerIsolation: true,
491
497
  worktreeIsolation: true,
@@ -494,7 +500,7 @@ export const providerRegistry = [
494
500
  streamJson: true,
495
501
  thinkingMode: true,
496
502
  reasoningEffort: true,
497
- sessionResume: false,
503
+ sessionResume: true,
498
504
  webSearch: false,
499
505
  },
500
506
  docs: {
@@ -12,6 +12,7 @@ import type {
12
12
  StructuredOutputProviderRegistryEntry,
13
13
  UnstructuredOutputProviderRegistryEntry,
14
14
  } from './provider-registry';
15
+ import type { OmpSessionLaunch } from './omp-rpc-session';
15
16
 
16
17
  export type ProviderId = (typeof providerIds)[number];
17
18
  export type ProviderAlias = (typeof providerAliases)[number];
@@ -329,6 +330,9 @@ export interface BuildProviderCommandOptions {
329
330
  readonly autoApprove?: boolean;
330
331
  readonly resumeSessionId?: string;
331
332
  readonly continueSession?: boolean;
333
+ // OMP-only: a verified session launch (none/fresh/resume) built from a checked partition —
334
+ // never a raw session id string. See src/agent-cli-provider/omp-rpc-session.ts.
335
+ readonly ompSession?: OmpSessionLaunch;
332
336
  readonly webSearch?: boolean;
333
337
  readonly claudeSettingsFile?: string;
334
338
  readonly cliFeatures?: CliFeatureOverrides;