effect-agent 0.1.0-beta.134 → 0.1.0-beta.136

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 (63) hide show
  1. package/dist/{Admin-CUja0lmO.d.mts → Admin-Fx85KT-W.d.mts} +42 -4
  2. package/dist/{DurableAgentRuntime-DH2baLGe.mjs → DurableAgentRuntime-Bso5grIH.mjs} +50 -12
  3. package/dist/DurableAgentRuntime-Bso5grIH.mjs.map +1 -0
  4. package/dist/{EventSource-DoiZF3xv.d.mts → EventSource-DLT8oDK8.d.mts} +2 -2
  5. package/dist/{PreparedInputAdmission-B1bzyIuV.d.mts → PreparedInputAdmission-CKFgG6Em.d.mts} +3 -3
  6. package/dist/{RunJournal-BXrq0VIK.mjs → RunJournal-Gd83HYqG.mjs} +50 -27
  7. package/dist/RunJournal-Gd83HYqG.mjs.map +1 -0
  8. package/dist/{Subscription-m_yNfNeN.d.mts → Subscription-CCmHgOYM.d.mts} +2 -2
  9. package/dist/{SubscriptionInput-ON3-b8Sl.d.mts → SubscriptionInput-BFJMhu4K.d.mts} +2 -2
  10. package/dist/durable/Admin.d.mts +1 -1
  11. package/dist/durable/AgentRegistration.d.mts +1 -1
  12. package/dist/durable/Certification.d.mts +1 -1
  13. package/dist/durable/CommittedActivity.d.mts +1 -1
  14. package/dist/durable/DurableAgentRuntime.d.mts +1 -1
  15. package/dist/durable/DurableAgentRuntime.mjs +1 -1
  16. package/dist/durable/EventSource.d.mts +1 -1
  17. package/dist/durable/GitHubWorkflowSource.d.mts +3 -3
  18. package/dist/durable/MessageDelivery.d.mts +41 -41
  19. package/dist/durable/MessageDeliveryStoreConformance.d.mts +1 -1
  20. package/dist/durable/PersistentHistory.d.mts +1 -1
  21. package/dist/durable/PersistentHistory.mjs +1 -1
  22. package/dist/durable/PreparedInputAdmission.d.mts +1 -1
  23. package/dist/durable/Recovery.d.mts +1 -1
  24. package/dist/durable/RunJournal.d.mts +1 -1
  25. package/dist/durable/RunJournal.mjs +1 -1
  26. package/dist/durable/Scheduling.d.mts +1 -1
  27. package/dist/durable/SqlStorageV2Upgrade.d.mts +1 -1
  28. package/dist/durable/SqlSubscriptionStore.d.mts +1 -1
  29. package/dist/durable/SqlThreadNativeReads.d.mts +1 -1
  30. package/dist/durable/SqlThreadNativeReads.mjs +1 -1
  31. package/dist/durable/SubmissionLedger.d.mts +2 -2
  32. package/dist/durable/SubmissionLedger.mjs +29 -2
  33. package/dist/durable/SubmissionLedger.mjs.map +1 -1
  34. package/dist/durable/SubmissionLedgerConformance.d.mts +1 -1
  35. package/dist/durable/SubmissionLedgerConformance.mjs +69 -2
  36. package/dist/durable/SubmissionLedgerConformance.mjs.map +1 -1
  37. package/dist/durable/SubmissionStatus.d.mts +1 -1
  38. package/dist/durable/Subscription.d.mts +1 -1
  39. package/dist/durable/SubscriptionInput.d.mts +1 -1
  40. package/dist/durable/SubscriptionStoreConformance.d.mts +1 -1
  41. package/dist/durable/SubscriptionTools.d.mts +2 -2
  42. package/dist/durable/SubscriptionTransition.d.mts +1 -1
  43. package/dist/durable/Subscriptions.d.mts +5 -5
  44. package/dist/durable/ThreadContextHistory.d.mts +1 -1
  45. package/dist/durable/ThreadInvariants.d.mts +1 -1
  46. package/dist/durable/ThreadInvariants.mjs +1 -1
  47. package/dist/durable/ThreadProjectionMaintenance.d.mts +1 -1
  48. package/dist/durable/ThreadStore.d.mts +1 -1
  49. package/dist/durable/ThreadStore.mjs +1 -1
  50. package/dist/durable/ThreadStoreConformance.d.mts +1 -1
  51. package/dist/durable/ThreadStoreConformance.mjs +1 -1
  52. package/dist/durable/WorkerHost.d.mts +1 -1
  53. package/dist/index.d.mts +5 -5
  54. package/dist/index.mjs +2 -2
  55. package/package.json +1 -1
  56. package/src/durable/DurableAgentRuntime.ts +94 -10
  57. package/src/durable/RunJournal.ts +120 -56
  58. package/src/durable/SubmissionLedger.ts +32 -0
  59. package/src/durable/SubmissionLedgerConformance.ts +120 -0
  60. package/src/durable/internal/journal-checkpoint.ts +1 -1
  61. package/src/durable/internal/journal-metadata.ts +10 -3
  62. package/dist/DurableAgentRuntime-DH2baLGe.mjs.map +0 -1
  63. package/dist/RunJournal-BXrq0VIK.mjs.map +0 -1
@@ -555,6 +555,9 @@ const PROMPT_TRANSPARENT_TAGS: ReadonlySet<string> = new Set([
555
555
  * are never canonical settlements or evidence for compaction, recovery or accounting. Real
556
556
  * results replace them at the original declaration, including results appended after another Run.
557
557
  * Prior user intent and assistant text remain visible; prior system instructions do not.
558
+ * Each Run retains the history preceding its start and its own continuation. Other Runs'
559
+ * subsequent Turns cannot replace its input. Compaction covers only its creator's Run view;
560
+ * a projection without an owner retains interleaved exchanges from the complete Thread history.
558
561
  */
559
562
  /** @internal Lightweight canonical boundaries collected without retaining record payloads. */
560
563
  export interface JournalBoundary {
@@ -598,13 +601,11 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
598
601
  committedTurns: seed?.committedTurns ?? 0,
599
602
  };
600
603
 
601
- // RUN-026 pre-scan: the widest VALID compaction bounds govern the fold. A
602
- // valid record covers strictly below its own sequence and never splits a response from its
603
- // settled tool results. Pruning and rollovers may cover complete owner-Run batches;
604
- // a summarize record must carry its summary. Invalid records
605
- // are ignored fail-safe — the full history stays authoritative. Ties on
606
- // coversThrough resolve to the record appended later (higher sequence),
607
- // matching at-most-once replay intent.
604
+ // RUN-026 pre-scan: valid compactions retire only their creator's Run view. A bound
605
+ // stays below its own sequence and cannot split a visible response from its settled results.
606
+ // Pruning and rollovers may cover complete owner-Run batches; a summarize record carries
607
+ // its summary. Invalid records leave full history authoritative. A wider containing view
608
+ // replaces earlier coverage, with equal bounds preferring the later record.
608
609
  // One span per settled record, paired with its declaring response the same
609
610
  // way the fold pairs them: a settled belongs to the most recent
610
611
  // ModelResponseRecorded of its Run. A bound inside (response, settled)
@@ -634,9 +635,39 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
634
635
  toolExecutionEvidence,
635
636
  settledToolCallRecordIds,
636
637
  settledById,
637
- compactions,
638
638
  } = metadata;
639
639
 
640
+ const declarationByResultSequence = new Map(settledSpans.map(({ from, to }) => [to, from]));
641
+
642
+ const isInRunView = (
643
+ sequence: number,
644
+ payload: { readonly _tag: string; readonly runId?: RunId | undefined },
645
+ runId: RunId | undefined,
646
+ ): boolean => {
647
+ if (runId === undefined || payload.runId === undefined || payload.runId === runId) return true;
648
+ const first = firstSequenceByRun.get(runId) ?? Number.POSITIVE_INFINITY;
649
+
650
+ return (
651
+ sequence < first ||
652
+ (payload._tag === "ToolCallSettled" &&
653
+ (declarationByResultSequence.get(sequence) ?? Number.POSITIVE_INFINITY) < first)
654
+ );
655
+ };
656
+
657
+ const compactions = metadata.compactions.filter(({ sequence, payload }) =>
658
+ isInRunView(sequence, payload, ownerRunId),
659
+ );
660
+
661
+ const recordsForRun = records.pipe(
662
+ Stream.filter(({ sequence, record: { payload } }) =>
663
+ isInRunView(sequence, payload, ownerRunId),
664
+ ),
665
+ );
666
+
667
+ const isCovered = (envelope: CanonicalRecordEnvelope, compaction: CompactionCreated): boolean =>
668
+ envelope.sequence <= compaction.coversThrough &&
669
+ isInRunView(envelope.sequence, envelope.record.payload, compaction.runId);
670
+
640
671
  const settledCoverage = compactions.reduce(
641
672
  (through, { payload }) =>
642
673
  payload.kind !== "summarize" ? Math.max(through, payload.coversThrough) : through,
@@ -671,7 +702,7 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
671
702
  : yield* decodePromptMessages(seed.protectedContext);
672
703
 
673
704
  if (settledCoverage > 0) {
674
- yield* Stream.runForEach(records, (envelope) =>
705
+ yield* Stream.runForEach(recordsForRun, (envelope) =>
675
706
  Effect.gen(function* () {
676
707
  const payload = envelope.record.payload;
677
708
 
@@ -727,21 +758,54 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
727
758
  incompleteResponseSequences.some(
728
759
  (response) =>
729
760
  response.sequence <= coversThrough &&
761
+ isInRunView(
762
+ response.sequence,
763
+ { _tag: "ModelResponseRecorded", runId: response.runId },
764
+ runId,
765
+ ) &&
730
766
  !isTerminalPriorRun(response.runId, runId, ownSequence),
731
767
  )
732
768
  )
733
769
  return false;
734
770
  for (const span of settledSpans) {
735
- if (span.from <= coversThrough && coversThrough < span.to) return false;
771
+ if (
772
+ span.from <= coversThrough &&
773
+ coversThrough < span.to &&
774
+ isInRunView(span.from, { _tag: "ModelResponseRecorded", runId: span.runId }, runId)
775
+ )
776
+ return false;
736
777
  }
737
778
 
738
779
  return true;
739
780
  };
740
781
 
741
- let summarizeBound = 0;
742
- let replacement: CompactionCreated | undefined;
743
- let summarizeSequence = -1;
744
- let clearBound = 0;
782
+ type CompactionView = (typeof compactions)[number];
783
+
784
+ let replacements: ReadonlyArray<CompactionView> = [];
785
+ let clearings: ReadonlyArray<CompactionView> = [];
786
+
787
+ const retainCompaction = (views: ReadonlyArray<CompactionView>, next: CompactionView) => {
788
+ // Independent Run views overlap without containing one another. Retire an overlay only
789
+ // when the replacement actually contains its view, not merely a larger sequence number.
790
+ if (
791
+ views.some(
792
+ (prior) =>
793
+ prior.payload.coversThrough > next.payload.coversThrough &&
794
+ isInRunView(next.sequence, next.payload, prior.payload.runId),
795
+ )
796
+ )
797
+ return views;
798
+
799
+ return [
800
+ ...views.filter(
801
+ (prior) =>
802
+ next.payload.coversThrough < prior.payload.coversThrough ||
803
+ !isInRunView(prior.sequence, prior.payload, next.payload.runId),
804
+ ),
805
+ next,
806
+ ];
807
+ };
808
+
745
809
  let latestWindowId: string | undefined = seed?.contextWindowId;
746
810
  let latestWindowSequence = seed?.throughSequence ?? -1;
747
811
  let rolloverCoveredThrough = 0;
@@ -756,53 +820,48 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
756
820
  rolloverCoveredThrough = Math.max(rolloverCoveredThrough, payload.coversThrough);
757
821
  if (payload.kind === "summarize" || payload.kind === "rollover") {
758
822
  if (payload.kind === "summarize" && payload.summary === undefined) continue;
759
- if (
760
- payload.coversThrough > summarizeBound ||
761
- (payload.coversThrough === summarizeBound && sequence > summarizeSequence)
762
- ) {
763
- summarizeBound = payload.coversThrough;
764
- summarizeSequence = sequence;
765
- replacement = payload;
766
- }
767
- } else if (payload.coversThrough > clearBound) {
768
- clearBound = payload.coversThrough;
769
- }
823
+ replacements = retainCompaction(replacements, { payload, sequence });
824
+ } else clearings = retainCompaction(clearings, { payload, sequence });
770
825
  }
771
826
  let summaryEmitted = false;
772
827
 
773
- const retainedPrefix =
774
- replacement?.kind === "rollover" &&
775
- replacement.runId === ownerRunId &&
776
- ownerPrefixSequence <= summarizeBound
777
- ? (protectedContext?.content ?? [])
778
- : [];
828
+ const retainedPrefix = replacements.some(
829
+ ({ payload }) =>
830
+ payload.kind === "rollover" &&
831
+ payload.runId === ownerRunId &&
832
+ ownerPrefixSequence <= payload.coversThrough,
833
+ )
834
+ ? (protectedContext?.content ?? [])
835
+ : [];
779
836
 
780
- const replacementLength = replacement === undefined ? 0 : retainedPrefix.length + 1;
837
+ const replacementLength = retainedPrefix.length + replacements.length;
781
838
 
782
839
  if (seed?.frontier !== undefined)
783
840
  onBoundary?.({ ...seed.frontier, promptLength: replacementLength });
784
841
 
785
842
  const emitSummary = () => {
786
- if (summaryEmitted || replacement === undefined) return;
843
+ if (summaryEmitted || replacements.length === 0) return;
787
844
  summaryEmitted = true;
845
+ state.all.push(...retainedPrefix);
846
+ for (const { payload: replacement } of replacements) {
847
+ const message =
848
+ replacement.kind === "rollover"
849
+ ? contextWindowMessage(
850
+ contextWindowId(replacement.runId, replacement.turn),
851
+ replacement.handoff,
852
+ )
853
+ : Prompt.makeMessage("user", {
854
+ content: [
855
+ Prompt.makePart("text", {
856
+ text: `${COMPACTION_SUMMARY_PREFIX}${replacement.summary}`,
857
+ }),
858
+ ],
859
+ });
788
860
 
789
- const message =
790
- replacement.kind === "rollover"
791
- ? contextWindowMessage(
792
- contextWindowId(replacement.runId, replacement.turn),
793
- replacement.handoff,
794
- )
795
- : Prompt.makeMessage("user", {
796
- content: [
797
- Prompt.makePart("text", {
798
- text: `${COMPACTION_SUMMARY_PREFIX}${replacement.summary}`,
799
- }),
800
- ],
801
- });
802
-
803
- state.all.push(...retainedPrefix, message);
804
- if (replacement.kind !== "rollover" || replacement.runId !== ownerRunId)
805
- state.before.push(message);
861
+ state.all.push(message);
862
+ if (replacement.kind !== "rollover" || replacement.runId !== ownerRunId)
863
+ state.before.push(message);
864
+ }
806
865
  };
807
866
 
808
867
  const modelUsage: Array<ModelCallUsage> = [];
@@ -986,7 +1045,7 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
986
1045
  };
987
1046
  });
988
1047
 
989
- yield* Stream.runForEach(records, (envelope) =>
1048
+ yield* Stream.runForEach(recordsForRun, (envelope) =>
990
1049
  Effect.gen(function* () {
991
1050
  const payload = envelope.record.payload;
992
1051
 
@@ -1020,8 +1079,8 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
1020
1079
  (payload._tag === "ModelResponseRecorded" || payload._tag === "ToolCallSettled")
1021
1080
  )
1022
1081
  return;
1023
- if (envelope.sequence <= summarizeBound) {
1024
- // Projecting an earlier Run after a later summary still accounts for its covered responses.
1082
+ if (replacements.some(({ payload }) => isCovered(envelope, payload))) {
1083
+ // Retiring Prompt payloads does not retire the owning Run's policy or usage accounting.
1025
1084
  if (payload._tag === "ModelResponseRecorded" && payload.runId === ownerRunId) {
1026
1085
  const messages = yield* decodePromptMessages(payload.messages);
1027
1086
 
@@ -1032,7 +1091,7 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
1032
1091
  onBoundary?.({
1033
1092
  sequence: envelope.sequence,
1034
1093
  tag: payload._tag,
1035
- promptLength: replacementLength,
1094
+ promptLength: Math.max(replacementLength, state.all.length),
1036
1095
  ...(isTerminalPriorRun(payload.runId, ownerRunId, Number.POSITIVE_INFINITY)
1037
1096
  ? { terminalPriorRun: true }
1038
1097
  : {}),
@@ -1059,7 +1118,9 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
1059
1118
  slot.parts[slot.partIndex] = Prompt.makePart("tool-result", {
1060
1119
  id: payload.toolCallId,
1061
1120
  name: payload.toolName,
1062
- result: envelope.sequence <= clearBound ? CLEARED_TOOL_RESULT : payload.result,
1121
+ result: clearings.some(({ payload }) => isCovered(envelope, payload))
1122
+ ? CLEARED_TOOL_RESULT
1123
+ : payload.result,
1063
1124
  isFailure: payload.isFailure,
1064
1125
  providerExecuted: false,
1065
1126
  });
@@ -1086,7 +1147,10 @@ export const projectRunJournalStream = Effect.fn("RunJournal.projectRunJournalSt
1086
1147
  ) {
1087
1148
  state = yield* flushTools(state);
1088
1149
  }
1089
- state.pendingTools.push({ record: payload, cleared: envelope.sequence <= clearBound });
1150
+ state.pendingTools.push({
1151
+ record: payload,
1152
+ cleared: clearings.some(({ payload }) => isCovered(envelope, payload)),
1153
+ });
1090
1154
  state = {
1091
1155
  ...state,
1092
1156
  pendingToolsForRun: payload.runId === ownerRunId,
@@ -85,6 +85,21 @@ export const SubmissionState = Schema.Literals([
85
85
 
86
86
  export type SubmissionState = typeof SubmissionState.Type;
87
87
 
88
+ /**
89
+ * Opt-in host policy for handling the next runnable same-Agent input in its own Run at a complete Turn
90
+ * boundary. This never joins inputs or changes their principals, grants, or receipts. The
91
+ * runtime selects the ordered candidate; policy cannot select an arbitrary Submission.
92
+ * Approval waits, child waits and incomplete Tool batches are not handoff boundaries. Hosts
93
+ * retaining independent replies must also leave those inputs unclaimed by `claimJoining`.
94
+ * In-flight model requests and Tool batches run to their existing safe boundary.
95
+ */
96
+ export const SubmissionScheduling = Context.Reference<{
97
+ readonly yieldTo?: (request: {
98
+ readonly active: SubmissionSnapshot;
99
+ readonly next: SubmissionSnapshot;
100
+ }) => Effect.Effect<boolean, LedgerError>;
101
+ }>("@effect-agent/thread/SubmissionScheduling", { defaultValue: () => ({}) });
102
+
88
103
  /**
89
104
  * Default ownership lease duration (D5). The lease is a liveness hint that makes an abandoned
90
105
  * claim reclaimable; correctness never depends on it because every canonical append is fenced by
@@ -289,9 +304,21 @@ export const AdmissionResolution = Schema.Union([
289
304
 
290
305
  export type AdmissionResolution = typeof AdmissionResolution.Type;
291
306
 
307
+ /**
308
+ * A trusted runtime's advisory handoff after committing a complete Turn and closing its
309
+ * Attempt. The epoch fences the observation; an intervening claim invalidates it. Deferred
310
+ * Runs retain their identities and obligations. Losing this hint resumes ordinary FIFO.
311
+ */
312
+ export class ClaimHandoff extends Schema.Class<ClaimHandoff>("@effect-agent/thread/ClaimHandoff")({
313
+ producerEpoch: ProducerEpoch,
314
+ deferredSubmissionIds: Schema.NonEmptyArray(SubmissionId),
315
+ submissionId: SubmissionId,
316
+ }) {}
317
+
292
318
  export class ClaimRequest extends Schema.Class<ClaimRequest>("@effect-agent/thread/ClaimRequest")({
293
319
  threadId: ThreadId,
294
320
  producerId: ProducerId,
321
+ handoff: Schema.optionalKey(ClaimHandoff),
295
322
  }) {}
296
323
 
297
324
  /**
@@ -1019,6 +1046,11 @@ export type SubmissionLedgerFailure =
1019
1046
  * `attemptId` and `ownershipToken`, and starts the ownership lease (D5: adapters default to
1020
1047
  * `DEFAULT_OWNERSHIP_LEASE_DURATION`, configurable). An expired lease makes the head
1021
1048
  * claimable; expiry alone never revokes correctness, only permission to assume liveness.
1049
+ * An optional trusted-runtime `handoff` can defer only the named `input-applied` prefix,
1050
+ * with no abort intent, to claim the exact next runnable Submission. It is checked atomically
1051
+ * against the current producer epoch and the same whole-Thread ownership fence. Admission
1052
+ * gaps, approval/child waits and joined work cannot be bypassed. Hints are not durable facts;
1053
+ * recovery resumes the same retained Runs and can select another complete-Turn handoff.
1022
1054
  * - `renewOwnership` — extends the lease while the token still owns the lane; fails with
1023
1055
  * `OwnershipLost` once superseded. Renewal after lease expiry succeeds if no other claim has
1024
1056
  * taken the lane in between.
@@ -45,6 +45,7 @@ import {
45
45
  ChildSettledNotification,
46
46
  ClaimJoiningRequest,
47
47
  ClaimRequest,
48
+ ClaimHandoff,
48
49
  IdempotencyKey,
49
50
  JoinedToHost,
50
51
  LedgerError,
@@ -4770,6 +4771,124 @@ const assignmentSettlement = conformanceCase(
4770
4771
  }),
4771
4772
  );
4772
4773
 
4774
+ // Native turn-boundary yielding retained the active FIFO head:
4775
+ // https://github.com/danieljvdm/effect-agent/commit/2259fc05eec3bfac2a92a8d055953f3482e54735
4776
+ const cooperativeHandoff = conformanceCase(
4777
+ "hands off complete Turns without bypassing ownership, ordering, cancellation or approval",
4778
+ ({ ensure, expectSome }) =>
4779
+ Effect.gen(function* () {
4780
+ const ledger = yield* SubmissionLedger;
4781
+
4782
+ for (const scenario of [
4783
+ "ready",
4784
+ "live",
4785
+ "stale",
4786
+ "abort",
4787
+ "approval",
4788
+ "gap",
4789
+ "ready-prefix",
4790
+ "unapplied",
4791
+ "foreign",
4792
+ ] as const) {
4793
+ const threadId = decodeThreadId(`ledger-handoff-${scenario}`);
4794
+ const first = yield* admitReady(threadId, "first", { work: "old" });
4795
+ const claim = yield* expectSome("original claim", yield* claimLane(threadId, PRODUCER_A));
4796
+
4797
+ if (scenario !== "unapplied")
4798
+ yield* ledger.markInputApplied(
4799
+ MarkInputAppliedRequest.make({
4800
+ submissionId: first.submissionId,
4801
+ ownershipToken: claim.ownershipToken,
4802
+ recordId: submissionInputRecordId(first.submissionId),
4803
+ sequence: decodeSequence(1),
4804
+ }),
4805
+ );
4806
+ if (scenario === "gap")
4807
+ yield* ledger.admit(yield* admissionRequest(threadId, "gap", { work: "not-ready" }));
4808
+ if (scenario === "ready-prefix") yield* admitReady(threadId, "prefix", { work: "earlier" });
4809
+ const next = yield* admitReady(threadId, "next", { work: "human" });
4810
+
4811
+ const handoff = ClaimHandoff.make({
4812
+ producerEpoch: claim.producerEpoch,
4813
+ deferredSubmissionIds: [first.submissionId],
4814
+ submissionId: scenario === "foreign" ? decodeSubmissionId("foreign") : next.submissionId,
4815
+ });
4816
+
4817
+ if (scenario === "approval") {
4818
+ yield* ledger.suspend(
4819
+ SuspendRequest.make({
4820
+ submissionId: first.submissionId,
4821
+ ownershipToken: claim.ownershipToken,
4822
+ reason: ApprovalPendingSuspension.make({
4823
+ toolCallIds: [decodeToolCallId("purchase")],
4824
+ }),
4825
+ }),
4826
+ );
4827
+ } else if (scenario !== "live") {
4828
+ yield* ledger.releaseOwnership(
4829
+ ReleaseOwnershipRequest.make({
4830
+ submissionId: first.submissionId,
4831
+ ownershipToken: claim.ownershipToken,
4832
+ }),
4833
+ );
4834
+ }
4835
+ if (scenario === "abort")
4836
+ yield* ledger.requestAbort(
4837
+ AbortCommand.make({
4838
+ submissionId: first.submissionId,
4839
+ author: "human",
4840
+ reason: "stop",
4841
+ }),
4842
+ );
4843
+ if (scenario === "stale") {
4844
+ const intervening = yield* expectSome(
4845
+ "intervening claim",
4846
+ yield* claimLane(threadId, PRODUCER_B),
4847
+ );
4848
+
4849
+ yield* ledger.releaseOwnership(
4850
+ ReleaseOwnershipRequest.make({
4851
+ submissionId: first.submissionId,
4852
+ ownershipToken: intervening.ownershipToken,
4853
+ }),
4854
+ );
4855
+ }
4856
+
4857
+ const selected = yield* ledger.claim(
4858
+ ClaimRequest.make({ threadId, producerId: PRODUCER_B, handoff }),
4859
+ );
4860
+
4861
+ if (scenario !== "ready") {
4862
+ yield* ensure(Option.isNone(selected), `Handoff must preserve the ${scenario} boundary`);
4863
+ continue;
4864
+ }
4865
+ const nextClaim = yield* expectSome("ordered handoff claim", selected);
4866
+
4867
+ yield* ensure(
4868
+ nextClaim.submissionId === next.submissionId &&
4869
+ nextClaim.producerEpoch > claim.producerEpoch,
4870
+ "Handoff must claim the next Submission with a new epoch",
4871
+ );
4872
+ yield* settleClaimed(next, nextClaim.ownershipToken);
4873
+
4874
+ const resumed = yield* expectSome(
4875
+ "original Run resumes",
4876
+ yield* claimLane(threadId, PRODUCER_A),
4877
+ );
4878
+
4879
+ yield* ensure(
4880
+ resumed.submissionId === first.submissionId,
4881
+ "The deferred Submission must retain its original obligation",
4882
+ );
4883
+ yield* ensure(
4884
+ (yield* recoverySnapshot(first.submissionId)).submission.receiptId === first.receiptId &&
4885
+ (yield* recoverySnapshot(next.submissionId)).submission.receiptId === next.receiptId,
4886
+ "Handoff must preserve both receipt identities",
4887
+ );
4888
+ }
4889
+ }),
4890
+ );
4891
+
4773
4892
  /**
4774
4893
  * The shared, adapter-parameterized SubmissionLedger contract suite (STORE-010). Every durable
4775
4894
  * ledger adapter test suite must execute each case against its own ledger provisioning, inside
@@ -4788,6 +4907,7 @@ export const submissionLedgerConformanceCases: ReadonlyArray<SubmissionLedgerCon
4788
4907
  admissionTupleBoundaries,
4789
4908
  concurrentAdmissionFifo,
4790
4909
  fifoHeadClaim,
4910
+ cooperativeHandoff,
4791
4911
  leaseExpiryReclaim,
4792
4912
  releaseMakesHeadClaimable,
4793
4913
  inputAppliedIdempotency,
@@ -56,7 +56,7 @@ export class RecoveryCheckpointContents extends Schema.Class<RecoveryCheckpointC
56
56
  digest: Digest,
57
57
  }) {}
58
58
 
59
- export const RECOVERY_ENGINE_VERSION = "effect-agent/recovery@1";
59
+ export const RECOVERY_ENGINE_VERSION = "effect-agent/recovery@2";
60
60
 
61
61
  /**
62
62
  * Late evidence can invalidate an old compaction. Such histories use full canonical replay;
@@ -14,7 +14,11 @@ export interface JournalMetadata {
14
14
  readonly firstSequenceByRun: ReadonlyMap<string, number>;
15
15
  readonly lastResponseSequenceByRun: ReadonlyMap<string, number>;
16
16
  readonly terminalSequenceByRun: ReadonlyMap<string, number>;
17
- readonly settledSpans: ReadonlyArray<{ readonly from: number; readonly to: number }>;
17
+ readonly settledSpans: ReadonlyArray<{
18
+ readonly from: number;
19
+ readonly to: number;
20
+ readonly runId: RunId;
21
+ }>;
18
22
  readonly toolExecutionEvidence: ReadonlySet<string>;
19
23
  readonly settledToolCallRecordIds: ReadonlySet<string>;
20
24
  readonly settledById: ReadonlyMap<
@@ -45,7 +49,10 @@ export const makeJournalMetadata = (
45
49
  if (seed?.firstSequence !== undefined) firstSequenceByRun.set(seed.runId, seed.firstSequence);
46
50
  const lastResponseSequenceByRun = new Map<string, number>();
47
51
  const terminalSequenceByRun = new Map<string, number>();
48
- const settledSpans: Array<{ readonly from: number; readonly to: number }> = [];
52
+
53
+ const settledSpans: Array<{ readonly from: number; readonly to: number; readonly runId: RunId }> =
54
+ [];
55
+
49
56
  const toolExecutionEvidence = new Set<string>();
50
57
  const settledToolCallRecordIds = new Set<string>();
51
58
 
@@ -98,7 +105,7 @@ export const makeJournalMetadata = (
98
105
  const from = lastResponseSequenceByRun.get(payload.runId);
99
106
 
100
107
  if (from !== undefined && from < envelope.sequence)
101
- settledSpans.push({ from, to: envelope.sequence });
108
+ settledSpans.push({ from, to: envelope.sequence, runId: payload.runId });
102
109
  }
103
110
  },
104
111
  snapshot: (): JournalMetadata => ({