@effect-agent/storage-memory 0.1.0-beta.13 → 0.1.0-beta.131

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 (34) hide show
  1. package/dist/MemoryMessageDeliveryStore.d.mts +15 -0
  2. package/dist/MemoryMessageDeliveryStore.mjs +190 -0
  3. package/dist/MemoryMessageDeliveryStore.mjs.map +1 -0
  4. package/dist/MemoryScheduleStore.d.mts +10 -0
  5. package/dist/MemoryScheduleStore.mjs +169 -0
  6. package/dist/MemoryScheduleStore.mjs.map +1 -0
  7. package/dist/MemorySemanticIndex.d.mts +21 -0
  8. package/dist/MemorySemanticIndex.mjs +222 -0
  9. package/dist/MemorySemanticIndex.mjs.map +1 -0
  10. package/dist/MemorySubmissionLedger.d.mts +24 -0
  11. package/dist/MemorySubmissionLedger.mjs +1154 -0
  12. package/dist/MemorySubmissionLedger.mjs.map +1 -0
  13. package/dist/MemorySubscriptionStore.d.mts +9 -0
  14. package/dist/MemorySubscriptionStore.mjs +849 -0
  15. package/dist/MemorySubscriptionStore.mjs.map +1 -0
  16. package/dist/MemoryThreadStore.d.mts +17 -0
  17. package/dist/MemoryThreadStore.mjs +462 -0
  18. package/dist/MemoryThreadStore.mjs.map +1 -0
  19. package/dist/index.d.mts +7 -30
  20. package/dist/index.mjs +7 -1298
  21. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  22. package/package.json +1 -45
  23. package/src/MemoryMessageDeliveryStore.ts +373 -0
  24. package/src/MemoryScheduleStore.ts +328 -0
  25. package/src/MemorySemanticIndex.ts +357 -0
  26. package/src/{memory-ledger.ts → MemorySubmissionLedger.ts} +695 -114
  27. package/src/MemorySubscriptionStore.ts +1549 -0
  28. package/src/MemoryThreadStore.ts +927 -0
  29. package/src/index.ts +6 -2
  30. package/dist/index.mjs.map +0 -1
  31. package/dist/testing.d.mts +0 -2
  32. package/dist/testing.mjs +0 -2
  33. package/src/memory-storage.ts +0 -614
  34. package/src/testing.ts +0 -10
@@ -1,19 +1,51 @@
1
1
  import {
2
+ Clock,
3
+ Cause,
4
+ Exit,
5
+ Fiber,
6
+ DateTime,
7
+ Duration,
8
+ Effect,
9
+ Layer,
10
+ Option,
11
+ Ref,
12
+ Schema,
13
+ Stream,
14
+ } from "effect";
15
+ import {
16
+ type ToolCallId,
2
17
  AttemptId,
3
18
  ReceiptId,
4
19
  SubmissionId,
5
- ToolCallId,
6
20
  type AgentId,
7
- type ConversationId,
21
+ type ThreadId,
8
22
  type SettlementId,
9
- } from "@effect-agent/core";
23
+ } from "effect-agent/identifiers";
24
+ import { InputMessage } from "effect-agent/messaging";
10
25
  import {
26
+ PersistedJson,
27
+ WorkerAdmission,
28
+ ProducerEpoch,
29
+ type DefinitionDigests,
30
+ type DeploymentId,
31
+ type Digest,
32
+ type ProducerId,
33
+ type RecordEnvelope,
34
+ type SettlementOutcome,
35
+ } from "effect-agent/records";
36
+ import {
37
+ type ParentLinkage,
11
38
  AbortCommand,
39
+ WorkerStopCommand,
40
+ WorkerLedgerState,
41
+ workerTerminalFromRecord,
12
42
  AdmissionAdmitted,
13
43
  AdmissionConflict,
44
+ AdmissionPolicyError,
14
45
  AdmissionIndeterminate,
15
46
  AdmissionNotAdmitted,
16
47
  AdmissionRequest,
48
+ SubmissionAdmissionFence,
17
49
  AdmissionResult,
18
50
  ApprovalConflict,
19
51
  ApprovalDecisionCommand,
@@ -43,8 +75,6 @@ import {
43
75
  OwnershipRenewal,
44
76
  OwnershipSnapshot,
45
77
  OwnershipToken,
46
- ParentLinkage,
47
- ProducerEpoch,
48
78
  QueueSequence,
49
79
  RecoverySnapshot,
50
80
  RecoverySnapshotRequest,
@@ -59,11 +89,14 @@ import {
59
89
  SettlementFinalization,
60
90
  SettlementReservation,
61
91
  SettlementReservationSnapshot,
92
+ settlementFailureFromRecord,
62
93
  AbortIntent,
94
+ AbortIntentRequest,
63
95
  SubmissionLedger,
64
96
  SubmissionLookup,
65
97
  SubmissionLookupByKey,
66
98
  SubmissionSnapshot,
99
+ SubmissionWorkItem,
67
100
  SuspendRequest,
68
101
  SuspensionSnapshot,
69
102
  UnknownResolution,
@@ -73,20 +106,12 @@ import {
73
106
  type ChildReservationId,
74
107
  type ChildReservationStatus,
75
108
  type ChildSettledOutcome,
76
- type DefinitionDigests,
77
- type DeploymentId,
78
- type Digest,
79
109
  type IdempotencyKey,
80
- type PersistedJson,
81
110
  type Principal,
82
- type ProducerId,
83
- type RecordEnvelope,
84
- type SettlementOutcome,
85
111
  type SubmissionState,
86
112
  type SuspensionOutcome,
87
113
  type SuspensionReason,
88
- } from "@effect-agent/session";
89
- import { Clock, DateTime, Duration, Effect, Layer, Option, Ref, Schema, Stream } from "effect";
114
+ } from "effect-agent/submission-ledger";
90
115
 
91
116
  const MAX_SUBMISSIONS = 65_536;
92
117
 
@@ -110,7 +135,7 @@ const STATE_RANK: Record<SubmissionState, number> = {
110
135
 
111
136
  interface SubmissionRow {
112
137
  readonly submissionId: SubmissionId;
113
- readonly conversationId: ConversationId;
138
+ readonly threadId: ThreadId;
114
139
  readonly queueSequence: QueueSequence;
115
140
  readonly principal: Principal;
116
141
  readonly idempotencyKey: IdempotencyKey;
@@ -126,6 +151,10 @@ interface SubmissionRow {
126
151
  readonly readyAtMillis: number | undefined;
127
152
  /** Immutable child-side lineage recorded at admission (spec §12 step 5). */
128
153
  readonly parentLinkage: ParentLinkage | undefined;
154
+ readonly admissionGroup?: string;
155
+ readonly admissionFence?: AdmissionRequest["admissionFence"];
156
+ readonly workerAdmissionJson?: string;
157
+ readonly messageAdmissionJson?: string;
129
158
  }
130
159
 
131
160
  interface StoredOwnership {
@@ -156,8 +185,6 @@ interface StoredUnknownMark {
156
185
 
157
186
  interface StoredUnknownResolution {
158
187
  readonly intent: UnknownResolutionIntent;
159
- /** Canonical JSON of the Schema-encoded resolution, for divergent re-resolution detection. */
160
- readonly resolutionJson: string;
161
188
  }
162
189
 
163
190
  interface StoredSubmission {
@@ -176,13 +203,13 @@ interface StoredSubmission {
176
203
 
177
204
  /**
178
205
  * States in which `claim` never grants the head: the lane is host-owned (`joining`/`joined`)
179
- * or durably blocked (`suspended`/`unknown`) rather than worker-claimable.
206
+ * or durably suspended rather than worker-claimable. Unknown heads are checked against abort
207
+ * intent separately: abort authorizes cleanup and settlement, never ordinary Tool replay.
180
208
  */
181
209
  const BLOCKED_HEAD_STATES: ReadonlySet<SubmissionState> = new Set([
182
210
  "joining",
183
211
  "joined",
184
212
  "suspended",
185
- "unknown",
186
213
  ]);
187
214
 
188
215
  interface LaneState {
@@ -198,12 +225,8 @@ interface StoredChildReservation {
198
225
  readonly childSubmissionId: SubmissionId | undefined;
199
226
  readonly status: ChildReservationStatus;
200
227
  readonly allocation: PersistedJson;
201
- /** Canonical JSON of the allocation, for divergent-replay detection. */
202
- readonly allocationJson: string;
203
228
  readonly allocationDigest: Digest;
204
229
  readonly accounting: PersistedJson | undefined;
205
- /** Canonical JSON of the frozen accounting decision, for divergent-freeze detection. */
206
- readonly accountingJson: string | undefined;
207
230
  readonly reservedAtMillis: number;
208
231
  readonly releaseBeganAtMillis: number | undefined;
209
232
  readonly releasedAtMillis: number | undefined;
@@ -212,9 +235,12 @@ interface StoredChildReservation {
212
235
  interface LedgerState {
213
236
  readonly submissions: ReadonlyMap<SubmissionId, StoredSubmission>;
214
237
  readonly admissionIndex: ReadonlyMap<string, SubmissionId>;
215
- readonly lanes: ReadonlyMap<ConversationId, LaneState>;
238
+ readonly lanes: ReadonlyMap<ThreadId, LaneState>;
216
239
  readonly childReservations: ReadonlyMap<ChildReservationId, StoredChildReservation>;
217
240
  readonly mintCounter: number;
241
+ readonly latestByThread: ReadonlyMap<ThreadId, SubmissionId>;
242
+ readonly activeByThread: ReadonlyMap<ThreadId, ReadonlySet<SubmissionId>>;
243
+ readonly stoppedWorkers: ReadonlyMap<ThreadId, WorkerLedgerState["terminal"]>;
218
244
  }
219
245
 
220
246
  type Decision<A, E> =
@@ -247,19 +273,21 @@ const decodeAttemptId = Schema.decodeSync(AttemptId);
247
273
  const decodeOwnershipToken = Schema.decodeSync(OwnershipToken);
248
274
  const decodeQueueSequence = Schema.decodeSync(QueueSequence);
249
275
  const decodeProducerEpoch = Schema.decodeSync(ProducerEpoch);
276
+ const equivalentPersistedJson = Schema.toEquivalence(PersistedJson);
277
+ const equivalentUnknownResolution = Schema.toEquivalence(UnknownResolution);
250
278
 
251
279
  const utc = (millis: number): DateTime.Utc => DateTime.toUtc(DateTime.makeUnsafe(millis));
252
280
 
253
281
  const admissionKey = (
254
- conversationId: ConversationId,
282
+ threadId: ThreadId,
255
283
  principal: Principal,
256
284
  idempotencyKey: IdempotencyKey,
257
- ): string => `${conversationId}\u001f${principal}\u001f${idempotencyKey}`;
285
+ ): string => JSON.stringify([threadId, principal, idempotencyKey]);
258
286
 
259
287
  const toSnapshot = (row: SubmissionRow): SubmissionSnapshot =>
260
288
  SubmissionSnapshot.make({
261
289
  submissionId: row.submissionId,
262
- conversationId: row.conversationId,
290
+ threadId: row.threadId,
263
291
  queueSequence: row.queueSequence,
264
292
  principal: row.principal,
265
293
  idempotencyKey: row.idempotencyKey,
@@ -271,6 +299,22 @@ const toSnapshot = (row: SubmissionRow): SubmissionSnapshot =>
271
299
  receiptId: row.receiptId,
272
300
  state: row.state,
273
301
  createdAt: utc(row.createdAtMillis),
302
+ ...(row.admissionGroup === undefined ? {} : { admissionGroup: row.admissionGroup }),
303
+ ...(row.admissionFence === undefined ? {} : { admissionFence: row.admissionFence }),
304
+ ...(row.workerAdmissionJson === undefined
305
+ ? {}
306
+ : {
307
+ workerAdmission: Schema.decodeSync(Schema.fromJsonString(WorkerAdmission))(
308
+ row.workerAdmissionJson,
309
+ ),
310
+ }),
311
+ ...(row.messageAdmissionJson === undefined
312
+ ? {}
313
+ : {
314
+ messageAdmission: Schema.decodeSync(Schema.fromJsonString(InputMessage))(
315
+ row.messageAdmissionJson,
316
+ ),
317
+ }),
274
318
  ...(row.settledOutcome === undefined ? {} : { settledOutcome: row.settledOutcome }),
275
319
  ...(row.readyAtMillis === undefined ? {} : { readyAt: utc(row.readyAtMillis) }),
276
320
  ...(row.parentLinkage === undefined ? {} : { parentLinkage: row.parentLinkage }),
@@ -304,23 +348,37 @@ const sameParentLinkage = (
304
348
  left.parentSubmissionId === right.parentSubmissionId &&
305
349
  left.parentToolCallId === right.parentToolCallId;
306
350
 
307
- const laneEpoch = (state: LedgerState, conversationId: ConversationId): number =>
308
- state.lanes.get(conversationId)?.producerEpoch ?? 0;
351
+ const laneEpoch = (state: LedgerState, threadId: ThreadId): number =>
352
+ state.lanes.get(threadId)?.producerEpoch ?? 0;
309
353
 
310
354
  const ownershipLost = (state: LedgerState, stored: StoredSubmission): OwnershipLost =>
311
355
  OwnershipLost.make({
312
356
  submissionId: stored.row.submissionId,
313
- actualEpoch: decodeProducerEpoch(laneEpoch(state, stored.row.conversationId)),
357
+ actualEpoch: decodeProducerEpoch(laneEpoch(state, stored.row.threadId)),
314
358
  });
315
359
 
316
- /** The presented token owns the lane only while it matches the live ownership record. */
317
- const ownsLane = (stored: StoredSubmission, ownershipToken: OwnershipToken): boolean =>
318
- stored.ownership !== undefined && stored.ownership.ownershipToken === ownershipToken;
360
+ /** A retained row token cannot outlive a newer owner of the same Thread. */
361
+ const ownsLane = (
362
+ state: LedgerState,
363
+ stored: StoredSubmission,
364
+ ownershipToken: OwnershipToken,
365
+ ): boolean =>
366
+ stored.ownership !== undefined &&
367
+ stored.ownership.ownershipToken === ownershipToken &&
368
+ stored.ownership.producerEpoch === laneEpoch(state, stored.row.threadId);
369
+
370
+ const withSubmission = (state: LedgerState, stored: StoredSubmission): LedgerState => {
371
+ const active = new Set(state.activeByThread.get(stored.row.threadId));
319
372
 
320
- const withSubmission = (state: LedgerState, stored: StoredSubmission): LedgerState => ({
321
- ...state,
322
- submissions: new Map(state.submissions).set(stored.row.submissionId, stored),
323
- });
373
+ if (stored.row.state === "settled") active.delete(stored.row.submissionId);
374
+ else active.add(stored.row.submissionId);
375
+
376
+ return {
377
+ ...state,
378
+ submissions: new Map(state.submissions).set(stored.row.submissionId, stored),
379
+ activeByThread: new Map(state.activeByThread).set(stored.row.threadId, active),
380
+ };
381
+ };
324
382
 
325
383
  const withChildReservation = (
326
384
  state: LedgerState,
@@ -330,21 +388,25 @@ const withChildReservation = (
330
388
  childReservations: new Map(state.childReservations).set(reservation.reservationId, reservation),
331
389
  });
332
390
 
333
- const findHead = (
334
- state: LedgerState,
335
- conversationId: ConversationId,
336
- ): StoredSubmission | undefined => {
391
+ const findHead = (state: LedgerState, threadId: ThreadId): StoredSubmission | undefined => {
337
392
  let head: StoredSubmission | undefined;
393
+
338
394
  for (const stored of state.submissions.values()) {
339
- if (stored.row.conversationId !== conversationId || stored.row.state === "settled") continue;
395
+ if (
396
+ stored.row.threadId !== threadId ||
397
+ stored.row.state === "settled" ||
398
+ (stored.row.state === "unknown" && stored.abortIntent === undefined)
399
+ )
400
+ continue;
340
401
  if (head === undefined || stored.row.queueSequence < head.row.queueSequence) head = stored;
341
402
  }
403
+
342
404
  return head;
343
405
  };
344
406
 
345
407
  /**
346
408
  * Reference in-memory SubmissionLedger. It implements the full port contract — atomic idempotent
347
- * admission, FIFO-head claims, producer-epoch fencing, Clock-driven ownership leases, idempotent
409
+ * admission, eligible FIFO claims, producer-epoch fencing, Clock-driven ownership leases, idempotent
348
410
  * settlement reservation/finalization, and durable abort intent — with every transition applied
349
411
  * as one atomic `Ref.modify`, but its state does not survive the process (`non-durable`).
350
412
  *
@@ -354,8 +416,8 @@ const findHead = (
354
416
  * deterministically; no wall clock is consulted.
355
417
  * - The ownership lease is pinned to `DEFAULT_OWNERSHIP_LEASE_DURATION` (D5); durable adapters
356
418
  * own the configuration seam.
357
- * - A live lease blocks claims from other producers only: the same `producerId` may reclaim its
358
- * own live lease (restart recovery), which supersedes and fences the prior Attempt's token.
419
+ * - Any live lease in the Thread blocks every new claim, including the same `producerId`.
420
+ * Unresolved unknown work is skipped only after ownership is released or expires.
359
421
  * - Claiming advances `ready` to `running` and otherwise preserves the recorded state, so
360
422
  * progress markers from an earlier Attempt survive a reclaim.
361
423
  * - `renewOwnership` keeps the token stable (the port allows rotation); a replayed admission
@@ -384,7 +446,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
384
446
  lanes: new Map(),
385
447
  childReservations: new Map(),
386
448
  mintCounter: 0,
449
+ stoppedWorkers: new Map(),
450
+ latestByThread: new Map(),
451
+ activeByThread: new Map(),
387
452
  });
453
+
454
+ const admissionFence = yield* SubmissionAdmissionFence;
388
455
  const leaseMillis = Duration.toMillis(DEFAULT_OWNERSHIP_LEASE_DURATION);
389
456
 
390
457
  const capabilities = Effect.succeed(LedgerCapabilities.make({ durability: "non-durable" }));
@@ -393,23 +460,45 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
393
460
  (unvalidated) =>
394
461
  Effect.gen(function* () {
395
462
  const request = yield* validate(AdmissionRequest, "admit", unvalidated);
463
+
464
+ const workerAdmissionJson =
465
+ request.workerAdmission === undefined
466
+ ? undefined
467
+ : yield* Schema.encodeEffect(Schema.fromJsonString(WorkerAdmission))(
468
+ request.workerAdmission,
469
+ ).pipe(
470
+ Effect.mapError(() => ledgerError("admit", "Invalid worker admission metadata")),
471
+ );
472
+
473
+ const messageAdmissionJson =
474
+ request.messageAdmission === undefined
475
+ ? undefined
476
+ : yield* Schema.encodeEffect(Schema.fromJsonString(InputMessage))(
477
+ request.messageAdmission,
478
+ ).pipe(
479
+ Effect.mapError(() => ledgerError("admit", "Invalid message admission metadata")),
480
+ );
481
+
396
482
  const nowMillis = yield* Clock.currentTimeMillis;
483
+ const services = yield* Effect.context<never>();
484
+
397
485
  const decision = yield* Ref.modify(
398
486
  state,
399
487
  (
400
488
  current,
401
489
  ): readonly [
402
- Decision<AdmissionResult, AdmissionConflict | LedgerError>,
490
+ (
491
+ | Decision<AdmissionResult, AdmissionConflict | AdmissionPolicyError | LedgerError>
492
+ | { readonly _tag: "cause"; readonly cause: Cause.Cause<AdmissionPolicyError> }
493
+ ),
403
494
  LedgerState,
404
495
  ] => {
405
- const key = admissionKey(
406
- request.conversationId,
407
- request.principal,
408
- request.idempotencyKey,
409
- );
496
+ const key = admissionKey(request.threadId, request.principal, request.idempotencyKey);
410
497
  const existingId = current.admissionIndex.get(key);
498
+
411
499
  if (existingId !== undefined) {
412
500
  const existing = current.submissions.get(existingId);
501
+
413
502
  if (existing === undefined) {
414
503
  return [
415
504
  failure(
@@ -422,12 +511,33 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
422
511
  // (or its absence): linkage is immutable lineage (spec §12 step 5, SUB-016).
423
512
  if (
424
513
  existing.row.inputDigest !== request.inputDigest ||
425
- !sameParentLinkage(existing.row.parentLinkage, request.parentLinkage)
514
+ !sameParentLinkage(existing.row.parentLinkage, request.parentLinkage) ||
515
+ existing.row.admissionGroup !== request.admissionGroup ||
516
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission))(
517
+ existing.row.workerAdmissionJson === undefined
518
+ ? undefined
519
+ : Schema.decodeSync(Schema.fromJsonString(WorkerAdmission))(
520
+ existing.row.workerAdmissionJson,
521
+ ),
522
+ request.workerAdmission,
523
+ ) ||
524
+ !Schema.toEquivalence(Schema.optional(InputMessage))(
525
+ existing.row.messageAdmissionJson === undefined
526
+ ? undefined
527
+ : Schema.decodeSync(Schema.fromJsonString(InputMessage))(
528
+ existing.row.messageAdmissionJson,
529
+ ),
530
+ request.messageAdmission,
531
+ ) ||
532
+ !Schema.toEquivalence(Schema.optional(Schema.Json))(
533
+ existing.row.admissionFence,
534
+ request.admissionFence,
535
+ )
426
536
  ) {
427
537
  return [
428
538
  failure(
429
539
  AdmissionConflict.make({
430
- conversationId: request.conversationId,
540
+ threadId: request.threadId,
431
541
  principal: request.principal,
432
542
  idempotencyKey: request.idempotencyKey,
433
543
  existingInputDigest: existing.row.inputDigest,
@@ -437,6 +547,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
437
547
  current,
438
548
  ];
439
549
  }
550
+
440
551
  return [
441
552
  success(
442
553
  AdmissionResult.make({
@@ -450,6 +561,64 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
450
561
  current,
451
562
  ];
452
563
  }
564
+
565
+ if (current.stoppedWorkers.has(request.threadId))
566
+ return [
567
+ failure(AdmissionPolicyError.make({ reason: "refused", code: "worker-stopped" })),
568
+ current,
569
+ ];
570
+
571
+ // A Thread's first admission fixes its worker origin before canonical materialization.
572
+ const first = [...current.submissions.values()].find(
573
+ ({ row }) => row.threadId === request.threadId,
574
+ );
575
+
576
+ if (first !== undefined) {
577
+ const previous =
578
+ first.row.workerAdmissionJson === undefined
579
+ ? undefined
580
+ : Schema.decodeSync(Schema.fromJsonString(WorkerAdmission))(
581
+ first.row.workerAdmissionJson,
582
+ );
583
+
584
+ if (
585
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission.fields.origin))(
586
+ previous?.origin,
587
+ request.workerAdmission?.origin,
588
+ )
589
+ )
590
+ return [
591
+ failure(
592
+ AdmissionPolicyError.make({
593
+ reason: "refused",
594
+ code: "worker-origin-conflict",
595
+ }),
596
+ ),
597
+ current,
598
+ ];
599
+ }
600
+ // A memory policy and the ledger mutation share one synchronous critical section.
601
+ // An asynchronous policy cannot fence this Ref and therefore fails closed.
602
+ const checked = Effect.runSyncExitWith(services)(admissionFence.check(request));
603
+
604
+ if (Exit.isFailure(checked)) {
605
+ return [{ _tag: "cause", cause: checked.cause }, current];
606
+ }
607
+ if (
608
+ request.admissionGroup !== undefined &&
609
+ [...current.submissions.values()].some(
610
+ ({ row }) =>
611
+ row.threadId === request.threadId &&
612
+ row.admissionGroup === request.admissionGroup &&
613
+ row.state !== "settled",
614
+ )
615
+ )
616
+ return [
617
+ failure(
618
+ AdmissionPolicyError.make({ reason: "occupied", code: "admission-group" }),
619
+ ),
620
+ current,
621
+ ];
453
622
  if (current.submissions.size >= MAX_SUBMISSIONS) {
454
623
  return [
455
624
  failure(
@@ -458,14 +627,17 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
458
627
  current,
459
628
  ];
460
629
  }
461
- const lane = current.lanes.get(request.conversationId) ?? {
630
+
631
+ const lane = current.lanes.get(request.threadId) ?? {
462
632
  nextQueueSequence: 1,
463
633
  producerEpoch: 0,
464
634
  };
635
+
465
636
  const mintCounter = current.mintCounter + 1;
637
+
466
638
  const row: SubmissionRow = {
467
639
  submissionId: decodeSubmissionId(`submission-memory-${mintCounter}`),
468
- conversationId: request.conversationId,
640
+ threadId: request.threadId,
469
641
  queueSequence: decodeQueueSequence(lane.nextQueueSequence),
470
642
  principal: request.principal,
471
643
  idempotencyKey: request.idempotencyKey,
@@ -480,7 +652,16 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
480
652
  createdAtMillis: nowMillis,
481
653
  readyAtMillis: undefined,
482
654
  parentLinkage: request.parentLinkage,
655
+ ...(workerAdmissionJson === undefined ? {} : { workerAdmissionJson }),
656
+ ...(messageAdmissionJson === undefined ? {} : { messageAdmissionJson }),
657
+ ...(request.admissionGroup === undefined
658
+ ? {}
659
+ : { admissionGroup: request.admissionGroup }),
660
+ ...(request.admissionFence === undefined
661
+ ? {}
662
+ : { admissionFence: request.admissionFence }),
483
663
  };
664
+
484
665
  const submissions = new Map(current.submissions).set(row.submissionId, {
485
666
  row,
486
667
  ownership: undefined,
@@ -493,11 +674,14 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
493
674
  approvalDecisions: new Map<ToolCallId, ApprovalDecisionIntent>(),
494
675
  unknownResolutions: new Map<ToolCallId, StoredUnknownResolution>(),
495
676
  });
677
+
496
678
  const admissionIndex = new Map(current.admissionIndex).set(key, row.submissionId);
497
- const lanes = new Map(current.lanes).set(request.conversationId, {
679
+
680
+ const lanes = new Map(current.lanes).set(request.threadId, {
498
681
  nextQueueSequence: lane.nextQueueSequence + 1,
499
682
  producerEpoch: lane.producerEpoch,
500
683
  });
684
+
501
685
  return [
502
686
  success(
503
687
  AdmissionResult.make({
@@ -508,13 +692,47 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
508
692
  replayed: false,
509
693
  }),
510
694
  ),
511
- { ...current, submissions, admissionIndex, lanes, mintCounter },
695
+ {
696
+ ...current,
697
+ submissions,
698
+ admissionIndex,
699
+ lanes,
700
+ mintCounter,
701
+ latestByThread: new Map(current.latestByThread).set(
702
+ request.threadId,
703
+ row.submissionId,
704
+ ),
705
+ activeByThread: new Map(current.activeByThread).set(
706
+ request.threadId,
707
+ new Set([
708
+ ...(current.activeByThread.get(request.threadId) ?? []),
709
+ row.submissionId,
710
+ ]),
711
+ ),
712
+ },
512
713
  ];
513
714
  },
514
715
  );
716
+
515
717
  if (decision._tag === "failure") return yield* decision.error;
718
+ if (decision._tag === "cause") {
719
+ for (const reason of decision.cause.reasons) {
720
+ if (Cause.isDieReason(reason) && Cause.isAsyncFiberError(reason.defect)) {
721
+ yield* Fiber.interrupt(reason.defect.fiber);
722
+
723
+ return yield* AdmissionPolicyError.make({
724
+ reason: "unavailable",
725
+ code: "synchronous-memory-policy-required",
726
+ });
727
+ }
728
+ }
729
+
730
+ return yield* Effect.failCause(decision.cause);
731
+ }
732
+
516
733
  return decision.value;
517
734
  }),
735
+ Effect.uninterruptible,
518
736
  );
519
737
 
520
738
  const markReady: SubmissionLedger["Service"]["markReady"] = Effect.fn(
@@ -523,10 +741,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
523
741
  Effect.gen(function* () {
524
742
  const request = yield* validate(MarkReadyRequest, "markReady", unvalidated);
525
743
  const nowMillis = yield* Clock.currentTimeMillis;
744
+
526
745
  const decision = yield* Ref.modify(
527
746
  state,
528
747
  (current): readonly [Decision<void, LedgerError>, LedgerState] => {
529
748
  const stored = current.submissions.get(request.submissionId);
749
+
530
750
  if (stored === undefined) {
531
751
  return [
532
752
  failure(ledgerError("markReady", `Unknown Submission ${request.submissionId}`)),
@@ -534,6 +754,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
534
754
  ];
535
755
  }
536
756
  if (stored.row.state !== "admitted") return [success(undefined), current];
757
+
537
758
  return [
538
759
  success(undefined),
539
760
  withSubmission(current, {
@@ -543,6 +764,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
543
764
  ];
544
765
  },
545
766
  );
767
+
546
768
  if (decision._tag === "failure") return yield* decision.error;
547
769
  }),
548
770
  );
@@ -553,14 +775,17 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
553
775
  Effect.gen(function* () {
554
776
  const request = yield* validate(SubmissionLookup, "lookup", unvalidated);
555
777
  const current = yield* Ref.get(state);
778
+
556
779
  const submissionId =
557
780
  request._tag === "SubmissionLookupById"
558
781
  ? request.submissionId
559
782
  : current.admissionIndex.get(
560
- admissionKey(request.conversationId, request.principal, request.idempotencyKey),
783
+ admissionKey(request.threadId, request.principal, request.idempotencyKey),
561
784
  );
785
+
562
786
  const stored =
563
787
  submissionId === undefined ? undefined : current.submissions.get(submissionId);
788
+
564
789
  return stored === undefined ? Option.none() : Option.some(toSnapshot(stored.row));
565
790
  }),
566
791
  );
@@ -570,20 +795,25 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
570
795
  )((unvalidated) =>
571
796
  Effect.gen(function* () {
572
797
  const request = yield* validate(SubmissionLookupByKey, "resolveAdmission", unvalidated);
798
+
573
799
  // Test-only fault seam: lets suites exercise the Indeterminate classification that a
574
800
  // single strongly consistent store never produces on its own (SUB-031, P6 honesty).
575
801
  if (options.resolveAdmissionFault !== undefined) {
576
802
  const fault = yield* options.resolveAdmissionFault;
803
+
577
804
  if (Option.isSome(fault)) {
578
805
  return AdmissionIndeterminate.make({ reason: fault.value });
579
806
  }
580
807
  }
581
808
  const current = yield* Ref.get(state);
809
+
582
810
  const submissionId = current.admissionIndex.get(
583
- admissionKey(request.conversationId, request.principal, request.idempotencyKey),
811
+ admissionKey(request.threadId, request.principal, request.idempotencyKey),
584
812
  );
813
+
585
814
  const stored =
586
815
  submissionId === undefined ? undefined : current.submissions.get(submissionId);
816
+
587
817
  return stored === undefined
588
818
  ? AdmissionNotAdmitted.make()
589
819
  : AdmissionAdmitted.make({ submission: toSnapshot(stored.row) });
@@ -595,30 +825,34 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
595
825
  Effect.gen(function* () {
596
826
  const request = yield* validate(ClaimRequest, "claim", unvalidated);
597
827
  const nowMillis = yield* Clock.currentTimeMillis;
828
+
598
829
  const decision = yield* Ref.modify(
599
830
  state,
600
831
  (current): readonly [Decision<Option.Option<Claim>, LedgerError>, LedgerState] => {
601
- const head = findHead(current, request.conversationId);
832
+ for (const stored of current.submissions.values()) {
833
+ if (
834
+ stored.row.threadId === request.threadId &&
835
+ stored.ownership !== undefined &&
836
+ stored.ownership.leaseExpiresAtMillis > nowMillis
837
+ ) {
838
+ return [success(Option.none()), current];
839
+ }
840
+ }
841
+ const head = findHead(current, request.threadId);
842
+
602
843
  if (head === undefined) return [success(Option.none()), current];
603
- // A joining/joined head is host-owned and a suspended/unknown head is durably
604
- // blocked; the lane produces no claim and later ready work is never skipped.
605
844
  if (BLOCKED_HEAD_STATES.has(head.row.state)) return [success(Option.none()), current];
606
- if (
607
- head.ownership !== undefined &&
608
- head.ownership.leaseExpiresAtMillis > nowMillis &&
609
- head.ownership.ownerProducerId !== request.producerId
610
- ) {
611
- return [success(Option.none()), current];
612
- }
613
- const lane = current.lanes.get(request.conversationId);
845
+ const lane = current.lanes.get(request.threadId);
846
+
614
847
  if (lane === undefined) {
615
848
  return [
616
- failure(ledgerError("claim", "Claimable head without a Conversation lane")),
849
+ failure(ledgerError("claim", "Claimable head without a Thread lane")),
617
850
  current,
618
851
  ];
619
852
  }
620
853
  const producerEpoch = decodeProducerEpoch(lane.producerEpoch + 1);
621
854
  const mintCounter = current.mintCounter + 1;
855
+
622
856
  const ownership: StoredOwnership = {
623
857
  attemptId: decodeAttemptId(`attempt-memory-${mintCounter}`),
624
858
  ownershipToken: decodeOwnershipToken(`ownership-memory-${mintCounter}`),
@@ -626,13 +860,17 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
626
860
  ownerProducerId: request.producerId,
627
861
  leaseExpiresAtMillis: nowMillis + leaseMillis,
628
862
  };
863
+
629
864
  const row: SubmissionRow =
630
865
  head.row.state === "ready" ? { ...head.row, state: "running" } : head.row;
866
+
631
867
  const next = withSubmission(current, { ...head, row, ownership });
632
- const lanes = new Map(next.lanes).set(request.conversationId, {
868
+
869
+ const lanes = new Map(next.lanes).set(request.threadId, {
633
870
  nextQueueSequence: lane.nextQueueSequence,
634
871
  producerEpoch: lane.producerEpoch + 1,
635
872
  });
873
+
636
874
  return [
637
875
  success(
638
876
  Option.some(
@@ -650,7 +888,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
650
888
  ];
651
889
  },
652
890
  );
891
+
653
892
  if (decision._tag === "failure") return yield* decision.error;
893
+
654
894
  return decision.value;
655
895
  }),
656
896
  );
@@ -661,12 +901,14 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
661
901
  Effect.gen(function* () {
662
902
  const request = yield* validate(RenewOwnershipRequest, "renewOwnership", unvalidated);
663
903
  const nowMillis = yield* Clock.currentTimeMillis;
904
+
664
905
  const decision = yield* Ref.modify(
665
906
  state,
666
907
  (
667
908
  current,
668
909
  ): readonly [Decision<OwnershipRenewal, OwnershipLost | LedgerError>, LedgerState] => {
669
910
  const stored = current.submissions.get(request.submissionId);
911
+
670
912
  if (stored === undefined) {
671
913
  return [
672
914
  failure(
@@ -675,13 +917,18 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
675
917
  current,
676
918
  ];
677
919
  }
678
- if (stored.ownership === undefined || !ownsLane(stored, request.ownershipToken)) {
920
+ if (
921
+ stored.ownership === undefined ||
922
+ !ownsLane(current, stored, request.ownershipToken)
923
+ ) {
679
924
  return [failure(ownershipLost(current, stored)), current];
680
925
  }
926
+
681
927
  const ownership: StoredOwnership = {
682
928
  ...stored.ownership,
683
929
  leaseExpiresAtMillis: nowMillis + leaseMillis,
684
930
  };
931
+
685
932
  return [
686
933
  success(
687
934
  OwnershipRenewal.make({
@@ -693,7 +940,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
693
940
  ];
694
941
  },
695
942
  );
943
+
696
944
  if (decision._tag === "failure") return yield* decision.error;
945
+
697
946
  return decision.value;
698
947
  }),
699
948
  );
@@ -703,10 +952,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
703
952
  )((unvalidated) =>
704
953
  Effect.gen(function* () {
705
954
  const request = yield* validate(ReleaseOwnershipRequest, "releaseOwnership", unvalidated);
955
+
706
956
  const decision = yield* Ref.modify(
707
957
  state,
708
958
  (current): readonly [Decision<void, OwnershipLost | LedgerError>, LedgerState] => {
709
959
  const stored = current.submissions.get(request.submissionId);
960
+
710
961
  if (stored === undefined) {
711
962
  return [
712
963
  failure(
@@ -715,15 +966,17 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
715
966
  current,
716
967
  ];
717
968
  }
718
- if (!ownsLane(stored, request.ownershipToken)) {
969
+ if (!ownsLane(current, stored, request.ownershipToken)) {
719
970
  return [failure(ownershipLost(current, stored)), current];
720
971
  }
972
+
721
973
  return [
722
974
  success(undefined),
723
975
  withSubmission(current, { ...stored, ownership: undefined }),
724
976
  ];
725
977
  },
726
978
  );
979
+
727
980
  if (decision._tag === "failure") return yield* decision.error;
728
981
  }),
729
982
  );
@@ -733,10 +986,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
733
986
  )((unvalidated) =>
734
987
  Effect.gen(function* () {
735
988
  const request = yield* validate(MarkInputAppliedRequest, "markInputApplied", unvalidated);
989
+
736
990
  const decision = yield* Ref.modify(
737
991
  state,
738
992
  (current): readonly [Decision<void, OwnershipLost | LedgerError>, LedgerState] => {
739
993
  const stored = current.submissions.get(request.submissionId);
994
+
740
995
  if (stored === undefined) {
741
996
  return [
742
997
  failure(
@@ -745,23 +1000,46 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
745
1000
  current,
746
1001
  ];
747
1002
  }
748
- if (!ownsLane(stored, request.ownershipToken)) {
1003
+ if (!ownsLane(current, stored, request.ownershipToken)) {
749
1004
  return [failure(ownershipLost(current, stored)), current];
750
1005
  }
1006
+
1007
+ if (stored.inputApplied !== undefined) {
1008
+ if (
1009
+ stored.inputApplied.recordId === request.recordId &&
1010
+ stored.inputApplied.sequence === request.sequence
1011
+ ) {
1012
+ return [success(undefined), current];
1013
+ }
1014
+
1015
+ return [
1016
+ failure(
1017
+ ledgerError(
1018
+ "markInputApplied",
1019
+ `A different canonical input marker is already recorded for Submission ${request.submissionId}`,
1020
+ ),
1021
+ ),
1022
+ current,
1023
+ ];
1024
+ }
1025
+
751
1026
  const marker = InputAppliedMarker.make({
752
1027
  recordId: request.recordId,
753
1028
  sequence: request.sequence,
754
1029
  });
1030
+
755
1031
  const row: SubmissionRow =
756
1032
  STATE_RANK[stored.row.state] < STATE_RANK["input-applied"]
757
1033
  ? { ...stored.row, state: "input-applied" }
758
1034
  : stored.row;
1035
+
759
1036
  return [
760
1037
  success(undefined),
761
1038
  withSubmission(current, { ...stored, row, inputApplied: marker }),
762
1039
  ];
763
1040
  },
764
1041
  );
1042
+
765
1043
  if (decision._tag === "failure") return yield* decision.error;
766
1044
  }),
767
1045
  );
@@ -771,6 +1049,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
771
1049
  )((unvalidated) =>
772
1050
  Effect.gen(function* () {
773
1051
  const request = yield* validate(SettlementReservation, "reserveSettlement", unvalidated);
1052
+
774
1053
  const decision = yield* Ref.modify(
775
1054
  state,
776
1055
  (
@@ -780,6 +1059,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
780
1059
  LedgerState,
781
1060
  ] => {
782
1061
  const stored = current.submissions.get(request.submissionId);
1062
+
783
1063
  if (stored === undefined) {
784
1064
  return [
785
1065
  failure(
@@ -788,11 +1068,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
788
1068
  current,
789
1069
  ];
790
1070
  }
1071
+
791
1072
  // A `joined` Submission settles WITH its host (plan §2.5) and its lane is never
792
1073
  // worker-claimable, so no ownership token can exist for it: the recorded host
793
1074
  // linkage authorizes the reservation and the presented token is not consulted.
794
1075
  const joinedSettlement =
795
1076
  stored.row.state === "joined" && stored.joinedHostSubmissionId !== undefined;
1077
+
796
1078
  // P7 §7(c): an aborted, never-claimed, still-queued Submission likewise has no
797
1079
  // live ownership to fence against — its durable abort intent authorizes exactly
798
1080
  // its ABORTED settlement (`terminalizing` is the same pass's crash replay). Every
@@ -802,14 +1084,16 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
802
1084
  stored.abortIntent !== undefined &&
803
1085
  stored.ownership === undefined &&
804
1086
  (stored.row.state === "ready" || stored.row.state === "terminalizing");
1087
+
805
1088
  if (
806
1089
  !joinedSettlement &&
807
1090
  !queuedAbortSettlement &&
808
- !ownsLane(stored, request.ownershipToken)
1091
+ !ownsLane(current, stored, request.ownershipToken)
809
1092
  ) {
810
1093
  return [failure(ownershipLost(current, stored)), current];
811
1094
  }
812
1095
  const existing = stored.reservation;
1096
+
813
1097
  if (existing !== undefined) {
814
1098
  if (
815
1099
  existing.settlementId !== request.settlementId ||
@@ -826,6 +1110,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
826
1110
  current,
827
1111
  ];
828
1112
  }
1113
+
829
1114
  return [
830
1115
  success(
831
1116
  ReservedSettlement.make({
@@ -840,6 +1125,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
840
1125
  current,
841
1126
  ];
842
1127
  }
1128
+
843
1129
  const reservation: StoredReservation = {
844
1130
  settlementId: request.settlementId,
845
1131
  outcome: request.outcome,
@@ -847,10 +1133,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
847
1133
  recordDigest: request.recordDigest,
848
1134
  finalizedAtMillis: undefined,
849
1135
  };
1136
+
850
1137
  const row: SubmissionRow =
851
1138
  STATE_RANK[stored.row.state] < STATE_RANK.terminalizing
852
1139
  ? { ...stored.row, state: "terminalizing" }
853
1140
  : stored.row;
1141
+
854
1142
  return [
855
1143
  success(
856
1144
  ReservedSettlement.make({
@@ -866,7 +1154,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
866
1154
  ];
867
1155
  },
868
1156
  );
1157
+
869
1158
  if (decision._tag === "failure") return yield* decision.error;
1159
+
870
1160
  return decision.value;
871
1161
  }),
872
1162
  );
@@ -877,12 +1167,14 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
877
1167
  Effect.gen(function* () {
878
1168
  const request = yield* validate(SettlementFinalization, "finalizeSettlement", unvalidated);
879
1169
  const nowMillis = yield* Clock.currentTimeMillis;
1170
+
880
1171
  const decision = yield* Ref.modify(
881
1172
  state,
882
1173
  (
883
1174
  current,
884
1175
  ): readonly [Decision<Settlement, SettlementConflict | LedgerError>, LedgerState] => {
885
1176
  const stored = current.submissions.get(request.submissionId);
1177
+
886
1178
  if (stored === undefined) {
887
1179
  return [
888
1180
  failure(
@@ -892,6 +1184,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
892
1184
  ];
893
1185
  }
894
1186
  const reservation = stored.reservation;
1187
+
895
1188
  if (reservation === undefined) {
896
1189
  return [
897
1190
  failure(
@@ -903,6 +1196,19 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
903
1196
  current,
904
1197
  ];
905
1198
  }
1199
+ const settlementFailure = settlementFailureFromRecord(reservation.record);
1200
+
1201
+ if ((reservation.outcome === "failed") !== (settlementFailure !== undefined)) {
1202
+ return [
1203
+ failure(
1204
+ ledgerError(
1205
+ "finalizeSettlement",
1206
+ `Settlement reservation for Submission ${request.submissionId} has contradictory failure evidence`,
1207
+ ),
1208
+ ),
1209
+ current,
1210
+ ];
1211
+ }
906
1212
  if (reservation.settlementId !== request.settlementId) {
907
1213
  return [
908
1214
  failure(
@@ -922,18 +1228,77 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
922
1228
  settlementId: reservation.settlementId,
923
1229
  receiptId: stored.row.receiptId,
924
1230
  outcome: reservation.outcome,
1231
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
925
1232
  settledAt: utc(reservation.finalizedAtMillis),
926
1233
  }),
927
1234
  ),
928
1235
  current,
929
1236
  ];
930
1237
  }
931
- const next = withSubmission(current, {
1238
+
1239
+ const terminal =
1240
+ stored.row.workerAdmissionJson === undefined
1241
+ ? undefined
1242
+ : workerTerminalFromRecord(toSnapshot(stored.row), reservation.record);
1243
+
1244
+ const latestId = current.latestByThread.get(stored.row.threadId);
1245
+ const latest = latestId === undefined ? undefined : current.submissions.get(latestId);
1246
+
1247
+ const pendingNewer =
1248
+ terminal === "completed" &&
1249
+ latest !== undefined &&
1250
+ latest.row.queueSequence > stored.row.queueSequence &&
1251
+ !(
1252
+ latest.joinedHostSubmissionId === stored.row.submissionId &&
1253
+ latest.inputApplied !== undefined
1254
+ );
1255
+
1256
+ let sealed = current;
1257
+
1258
+ if (
1259
+ terminal !== undefined &&
1260
+ !pendingNewer &&
1261
+ !current.stoppedWorkers.has(stored.row.threadId)
1262
+ ) {
1263
+ const submissions = new Map(current.submissions);
1264
+
1265
+ for (const id of current.activeByThread.get(stored.row.threadId) ?? []) {
1266
+ const other = submissions.get(id);
1267
+
1268
+ if (other === undefined) continue;
1269
+ if (
1270
+ id !== stored.row.submissionId &&
1271
+ !(
1272
+ other.joinedHostSubmissionId === stored.row.submissionId &&
1273
+ other.inputApplied !== undefined
1274
+ ) &&
1275
+ other.abortIntent === undefined
1276
+ ) {
1277
+ submissions.set(id, {
1278
+ ...other,
1279
+ abortIntent: AbortIntent.make({
1280
+ submissionId: id,
1281
+ author: stored.row.principal,
1282
+ reason: `Worker assignment ${terminal}`,
1283
+ requestedAt: utc(nowMillis),
1284
+ }),
1285
+ });
1286
+ }
1287
+ }
1288
+ sealed = {
1289
+ ...current,
1290
+ submissions,
1291
+ stoppedWorkers: new Map(current.stoppedWorkers).set(stored.row.threadId, terminal),
1292
+ };
1293
+ }
1294
+
1295
+ const next = withSubmission(sealed, {
932
1296
  ...stored,
933
1297
  row: { ...stored.row, state: "settled", settledOutcome: reservation.outcome },
934
1298
  ownership: undefined,
935
1299
  reservation: { ...reservation, finalizedAtMillis: nowMillis },
936
1300
  });
1301
+
937
1302
  return [
938
1303
  success(
939
1304
  Settlement.make({
@@ -941,6 +1306,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
941
1306
  settlementId: reservation.settlementId,
942
1307
  receiptId: stored.row.receiptId,
943
1308
  outcome: reservation.outcome,
1309
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
944
1310
  settledAt: utc(nowMillis),
945
1311
  }),
946
1312
  ),
@@ -948,17 +1314,85 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
948
1314
  ];
949
1315
  },
950
1316
  );
1317
+
951
1318
  if (decision._tag === "failure") return yield* decision.error;
1319
+
952
1320
  return decision.value;
953
1321
  }),
954
1322
  );
955
1323
 
1324
+ const inspectWorker = Effect.fn("MemorySubmissionLedger.inspectWorker")(function* (
1325
+ threadId: ThreadId,
1326
+ ) {
1327
+ const current = yield* Ref.get(state);
1328
+ const latestId = current.latestByThread.get(threadId);
1329
+ const latest = latestId === undefined ? undefined : current.submissions.get(latestId);
1330
+
1331
+ const active = [...(current.activeByThread.get(threadId) ?? [])]
1332
+ .flatMap((id) => {
1333
+ const row = current.submissions.get(id);
1334
+
1335
+ return row === undefined ? [] : [row];
1336
+ })
1337
+ .sort((a, b) => a.row.queueSequence - b.row.queueSequence)[0];
1338
+
1339
+ const terminal = current.stoppedWorkers.get(threadId);
1340
+
1341
+ return WorkerLedgerState.make({
1342
+ latest: latest === undefined ? null : toSnapshot(latest.row),
1343
+ active: active === undefined ? null : toSnapshot(active.row),
1344
+ stopped: current.stoppedWorkers.has(threadId),
1345
+ ...(terminal === undefined ? {} : { terminal }),
1346
+ });
1347
+ });
1348
+
1349
+ const stopWorker = Effect.fn("MemorySubmissionLedger.stopWorker")(function* (
1350
+ unvalidated: WorkerStopCommand,
1351
+ ) {
1352
+ const request = yield* validate(WorkerStopCommand, "stopWorker", unvalidated);
1353
+ const now = yield* Clock.currentTimeMillis;
1354
+
1355
+ return yield* Ref.modify(state, (current) => {
1356
+ const submissions = new Map(current.submissions);
1357
+ let owned = 0;
1358
+
1359
+ for (const id of current.activeByThread.get(request.threadId) ?? []) {
1360
+ const stored = submissions.get(id);
1361
+
1362
+ if (stored === undefined) continue;
1363
+ if (stored.ownership !== undefined) owned++;
1364
+ if (stored.abortIntent === undefined)
1365
+ submissions.set(id, {
1366
+ ...stored,
1367
+ abortIntent: AbortIntent.make({
1368
+ submissionId: id,
1369
+ author: request.author,
1370
+ reason: "Worker owner stopped the worker",
1371
+ requestedAt: utc(now),
1372
+ }),
1373
+ });
1374
+ }
1375
+
1376
+ return [
1377
+ owned,
1378
+ {
1379
+ ...current,
1380
+ submissions,
1381
+ stoppedWorkers: current.stoppedWorkers.has(request.threadId)
1382
+ ? current.stoppedWorkers
1383
+ : new Map(current.stoppedWorkers).set(request.threadId, undefined),
1384
+ },
1385
+ ] as const;
1386
+ });
1387
+ });
1388
+
956
1389
  const requestAbort: SubmissionLedger["Service"]["requestAbort"] = Effect.fn(
957
1390
  "MemorySubmissionLedger.requestAbort",
958
1391
  )((unvalidated) =>
959
1392
  Effect.gen(function* () {
960
1393
  const request = yield* validate(AbortCommand, "requestAbort", unvalidated);
961
1394
  const nowMillis = yield* Clock.currentTimeMillis;
1395
+
962
1396
  const decision = yield* Ref.modify(
963
1397
  state,
964
1398
  (
@@ -968,6 +1402,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
968
1402
  LedgerState,
969
1403
  ] => {
970
1404
  const stored = current.submissions.get(request.submissionId);
1405
+
971
1406
  if (stored === undefined) {
972
1407
  return [
973
1408
  failure(ledgerError("requestAbort", `Unknown Submission ${request.submissionId}`)),
@@ -989,6 +1424,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
989
1424
  current,
990
1425
  ];
991
1426
  }
1427
+
992
1428
  return [
993
1429
  failure(
994
1430
  JoinedToHost.make({
@@ -1011,6 +1447,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1011
1447
  current,
1012
1448
  ];
1013
1449
  }
1450
+
1014
1451
  return [
1015
1452
  failure(
1016
1453
  SettlementConflict.make({
@@ -1022,16 +1459,20 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1022
1459
  ];
1023
1460
  }
1024
1461
  if (stored.abortIntent !== undefined) return [success(stored.abortIntent), current];
1462
+
1025
1463
  const intent = AbortIntent.make({
1026
1464
  submissionId: request.submissionId,
1027
1465
  author: request.author,
1028
1466
  reason: request.reason,
1029
1467
  requestedAt: utc(nowMillis),
1030
1468
  });
1469
+
1031
1470
  return [success(intent), withSubmission(current, { ...stored, abortIntent: intent })];
1032
1471
  },
1033
1472
  );
1473
+
1034
1474
  if (decision._tag === "failure") return yield* decision.error;
1475
+
1035
1476
  return decision.value;
1036
1477
  }),
1037
1478
  );
@@ -1041,6 +1482,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1041
1482
  )((unvalidated) =>
1042
1483
  Effect.gen(function* () {
1043
1484
  const request = yield* validate(ClaimJoiningRequest, "claimJoining", unvalidated);
1485
+
1044
1486
  const decision = yield* Ref.modify(
1045
1487
  state,
1046
1488
  (
@@ -1049,7 +1491,10 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1049
1491
  Decision<ReadonlyArray<JoiningClaim>, OwnershipLost | LedgerError>,
1050
1492
  LedgerState,
1051
1493
  ] => {
1494
+ if (current.stoppedWorkers.has(request.threadId)) return [success([]), current];
1495
+
1052
1496
  const host = current.submissions.get(request.hostSubmissionId);
1497
+
1053
1498
  if (host === undefined) {
1054
1499
  return [
1055
1500
  failure(
@@ -1058,29 +1503,32 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1058
1503
  current,
1059
1504
  ];
1060
1505
  }
1061
- if (host.row.conversationId !== request.conversationId) {
1506
+ if (host.row.threadId !== request.threadId) {
1062
1507
  return [
1063
1508
  failure(
1064
1509
  ledgerError(
1065
1510
  "claimJoining",
1066
- `Host Submission ${request.hostSubmissionId} does not belong to Conversation ${request.conversationId}`,
1511
+ `Host Submission ${request.hostSubmissionId} does not belong to Thread ${request.threadId}`,
1067
1512
  ),
1068
1513
  ),
1069
1514
  current,
1070
1515
  ];
1071
1516
  }
1072
- if (!ownsLane(host, request.ownershipToken)) {
1517
+ if (!ownsLane(current, host, request.ownershipToken)) {
1073
1518
  return [failure(ownershipLost(current, host)), current];
1074
1519
  }
1520
+
1075
1521
  const later = [...current.submissions.values()]
1076
1522
  .filter(
1077
1523
  (stored) =>
1078
- stored.row.conversationId === request.conversationId &&
1524
+ stored.row.threadId === request.threadId &&
1079
1525
  stored.row.queueSequence > host.row.queueSequence,
1080
1526
  )
1081
1527
  .sort((left, right) => left.row.queueSequence - right.row.queueSequence);
1528
+
1082
1529
  const claims: Array<JoiningClaim> = [];
1083
1530
  const submissions = new Map(current.submissions);
1531
+
1084
1532
  for (const stored of later) {
1085
1533
  if (claims.length >= request.maxCount) break;
1086
1534
  // Rows already claimed by THIS host extend its contiguous prefix and are skipped;
@@ -1113,10 +1561,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1113
1561
  }),
1114
1562
  );
1115
1563
  }
1564
+
1116
1565
  return [success(claims), { ...current, submissions }];
1117
1566
  },
1118
1567
  );
1568
+
1119
1569
  if (decision._tag === "failure") return yield* decision.error;
1570
+
1120
1571
  return decision.value;
1121
1572
  }),
1122
1573
  );
@@ -1126,10 +1577,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1126
1577
  )((unvalidated) =>
1127
1578
  Effect.gen(function* () {
1128
1579
  const request = yield* validate(MarkJoinedRequest, "markJoined", unvalidated);
1580
+
1129
1581
  const decision = yield* Ref.modify(
1130
1582
  state,
1131
1583
  (current): readonly [Decision<void, OwnershipLost | LedgerError>, LedgerState] => {
1132
1584
  const stored = current.submissions.get(request.submissionId);
1585
+
1133
1586
  if (stored === undefined) {
1134
1587
  return [
1135
1588
  failure(ledgerError("markJoined", `Unknown Submission ${request.submissionId}`)),
@@ -1148,6 +1601,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1148
1601
  ];
1149
1602
  }
1150
1603
  const host = current.submissions.get(stored.joinedHostSubmissionId);
1604
+
1151
1605
  if (host === undefined) {
1152
1606
  return [
1153
1607
  failure(
@@ -1161,7 +1615,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1161
1615
  }
1162
1616
  // The lane is host-owned: the presented token must own the HOST's ownership period,
1163
1617
  // which also lets a later host Attempt repair a lost marker from history (DUR-016).
1164
- if (!ownsLane(host, request.ownershipToken)) {
1618
+ if (!ownsLane(current, host, request.ownershipToken)) {
1165
1619
  return [failure(ownershipLost(current, host)), current];
1166
1620
  }
1167
1621
  if (stored.inputApplied !== undefined) {
@@ -1171,6 +1625,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1171
1625
  ) {
1172
1626
  return [success(undefined), current];
1173
1627
  }
1628
+
1174
1629
  return [
1175
1630
  failure(
1176
1631
  ledgerError(
@@ -1192,10 +1647,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1192
1647
  current,
1193
1648
  ];
1194
1649
  }
1650
+
1195
1651
  const marker = InputAppliedMarker.make({
1196
1652
  recordId: request.recordId,
1197
1653
  sequence: request.sequence,
1198
1654
  });
1655
+
1199
1656
  return [
1200
1657
  success(undefined),
1201
1658
  withSubmission(current, {
@@ -1206,6 +1663,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1206
1663
  ];
1207
1664
  },
1208
1665
  );
1666
+
1209
1667
  if (decision._tag === "failure") return yield* decision.error;
1210
1668
  }),
1211
1669
  );
@@ -1215,10 +1673,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1215
1673
  )((unvalidated) =>
1216
1674
  Effect.gen(function* () {
1217
1675
  const request = yield* validate(RevertJoiningRequest, "revertJoining", unvalidated);
1676
+
1218
1677
  const decision = yield* Ref.modify(
1219
1678
  state,
1220
1679
  (current): readonly [Decision<void, LedgerError>, LedgerState] => {
1221
1680
  const stored = current.submissions.get(request.submissionId);
1681
+
1222
1682
  if (stored === undefined) {
1223
1683
  return [
1224
1684
  failure(ledgerError("revertJoining", `Unknown Submission ${request.submissionId}`)),
@@ -1228,6 +1688,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1228
1688
  // Idempotent and recovery-only: only a still-`joining` Submission reverts; an
1229
1689
  // already-joined (or already-reverted) Submission is a no-op (DUR-016).
1230
1690
  if (stored.row.state !== "joining") return [success(undefined), current];
1691
+
1231
1692
  return [
1232
1693
  success(undefined),
1233
1694
  withSubmission(current, {
@@ -1238,6 +1699,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1238
1699
  ];
1239
1700
  },
1240
1701
  );
1702
+
1241
1703
  if (decision._tag === "failure") return yield* decision.error;
1242
1704
  }),
1243
1705
  );
@@ -1248,6 +1710,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1248
1710
  Effect.gen(function* () {
1249
1711
  const request = yield* validate(SuspendRequest, "suspend", unvalidated);
1250
1712
  const nowMillis = yield* Clock.currentTimeMillis;
1713
+
1251
1714
  const decision = yield* Ref.modify(
1252
1715
  state,
1253
1716
  (
@@ -1257,6 +1720,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1257
1720
  LedgerState,
1258
1721
  ] => {
1259
1722
  const stored = current.submissions.get(request.submissionId);
1723
+
1260
1724
  if (stored === undefined) {
1261
1725
  return [
1262
1726
  failure(ledgerError("suspend", `Unknown Submission ${request.submissionId}`)),
@@ -1275,6 +1739,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1275
1739
  current,
1276
1740
  ];
1277
1741
  }
1742
+
1278
1743
  return [
1279
1744
  failure(
1280
1745
  SettlementConflict.make({
@@ -1298,9 +1763,10 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1298
1763
  current,
1299
1764
  ];
1300
1765
  }
1301
- if (!ownsLane(stored, request.ownershipToken)) {
1766
+ if (!ownsLane(current, stored, request.ownershipToken)) {
1302
1767
  return [failure(ownershipLost(current, stored)), current];
1303
1768
  }
1769
+
1304
1770
  // A covering event that raced ahead of the suspend transaction (an approval decision,
1305
1771
  // or a child settlement observed directly from the child's row in this single store)
1306
1772
  // resumes the caller immediately WITHOUT releasing the lane (plan §2.6, spec §12).
@@ -1313,9 +1779,11 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1313
1779
  (child) =>
1314
1780
  current.submissions.get(child.childSubmissionId)?.row.state === "settled",
1315
1781
  );
1782
+
1316
1783
  if (alreadyCovered) {
1317
1784
  return [success("resume-immediately" as const), current];
1318
1785
  }
1786
+
1319
1787
  return [
1320
1788
  success("suspended" as const),
1321
1789
  withSubmission(current, {
@@ -1327,7 +1795,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1327
1795
  ];
1328
1796
  },
1329
1797
  );
1798
+
1330
1799
  if (decision._tag === "failure") return yield* decision.error;
1800
+
1331
1801
  return decision.value;
1332
1802
  }),
1333
1803
  );
@@ -1341,7 +1811,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1341
1811
  "recordApprovalDecision",
1342
1812
  unvalidated,
1343
1813
  );
1814
+
1344
1815
  const nowMillis = yield* Clock.currentTimeMillis;
1816
+
1345
1817
  const decision = yield* Ref.modify(
1346
1818
  state,
1347
1819
  (
@@ -1351,6 +1823,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1351
1823
  LedgerState,
1352
1824
  ] => {
1353
1825
  const stored = current.submissions.get(command.submissionId);
1826
+
1354
1827
  if (stored === undefined) {
1355
1828
  return [
1356
1829
  failure(
@@ -1374,6 +1847,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1374
1847
  current,
1375
1848
  ];
1376
1849
  }
1850
+
1377
1851
  return [
1378
1852
  failure(
1379
1853
  SettlementConflict.make({
@@ -1385,6 +1859,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1385
1859
  ];
1386
1860
  }
1387
1861
  const existing = stored.approvalDecisions.get(command.toolCallId);
1862
+
1388
1863
  if (existing !== undefined) {
1389
1864
  if (existing.decision !== command.decision) {
1390
1865
  return [
@@ -1398,8 +1873,10 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1398
1873
  current,
1399
1874
  ];
1400
1875
  }
1876
+
1401
1877
  return [success(existing), current];
1402
1878
  }
1879
+
1403
1880
  const intent = ApprovalDecisionIntent.make({
1404
1881
  submissionId: command.submissionId,
1405
1882
  toolCallId: command.toolCallId,
@@ -1408,10 +1885,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1408
1885
  reason: command.reason,
1409
1886
  decidedAt: utc(nowMillis),
1410
1887
  });
1888
+
1411
1889
  const approvalDecisions = new Map(stored.approvalDecisions).set(
1412
1890
  command.toolCallId,
1413
1891
  intent,
1414
1892
  );
1893
+
1415
1894
  // Once every pending call of an ApprovalPending suspension is decided, the lane
1416
1895
  // wakes: suspended → input-applied (plan §2.6). A WaitingForChild suspension wakes
1417
1896
  // only through recordChildSettled.
@@ -1422,6 +1901,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1422
1901
  stored.suspension.reason.toolCallIds.every((toolCallId) =>
1423
1902
  approvalDecisions.has(toolCallId),
1424
1903
  );
1904
+
1425
1905
  return [
1426
1906
  success(intent),
1427
1907
  withSubmission(current, {
@@ -1433,7 +1913,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1433
1913
  ];
1434
1914
  },
1435
1915
  );
1916
+
1436
1917
  if (decision._tag === "failure") return yield* decision.error;
1918
+
1437
1919
  return decision.value;
1438
1920
  }),
1439
1921
  );
@@ -1443,10 +1925,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1443
1925
  )((unvalidated) =>
1444
1926
  Effect.gen(function* () {
1445
1927
  const request = yield* validate(MarkUnknownRequest, "markUnknown", unvalidated);
1928
+
1446
1929
  const decision = yield* Ref.modify(
1447
1930
  state,
1448
1931
  (current): readonly [Decision<void, SettlementConflict | LedgerError>, LedgerState] => {
1449
1932
  const stored = current.submissions.get(request.submissionId);
1933
+
1450
1934
  if (stored === undefined) {
1451
1935
  return [
1452
1936
  failure(ledgerError("markUnknown", `Unknown Submission ${request.submissionId}`)),
@@ -1465,6 +1949,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1465
1949
  current,
1466
1950
  ];
1467
1951
  }
1952
+
1468
1953
  return [
1469
1954
  failure(
1470
1955
  SettlementConflict.make({
@@ -1492,10 +1977,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1492
1977
  // set while the first recorded reason is kept.
1493
1978
  const existing = stored.unknownMark;
1494
1979
  const known = new Set(existing?.toolCallIds ?? []);
1980
+
1495
1981
  const merged = [
1496
1982
  ...(existing?.toolCallIds ?? []),
1497
1983
  ...request.toolCallIds.filter((toolCallId) => !known.has(toolCallId)),
1498
1984
  ];
1985
+
1499
1986
  return [
1500
1987
  success(undefined),
1501
1988
  withSubmission(current, {
@@ -1507,6 +1994,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1507
1994
  ];
1508
1995
  },
1509
1996
  );
1997
+
1510
1998
  if (decision._tag === "failure") return yield* decision.error;
1511
1999
  }),
1512
2000
  );
@@ -1519,15 +2007,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1519
2007
  "recordUnknownResolution",
1520
2008
  unvalidated,
1521
2009
  );
2010
+
1522
2011
  const nowMillis = yield* Clock.currentTimeMillis;
1523
- const encodedResolution = yield* Schema.encodeUnknownEffect(UnknownResolution)(
1524
- command.resolution,
1525
- ).pipe(
1526
- Effect.mapError((error) =>
1527
- ledgerError("recordUnknownResolution", "Invalid unknown-outcome resolution", error),
1528
- ),
1529
- );
1530
- const resolutionJson = JSON.stringify(encodedResolution);
2012
+
1531
2013
  const decision = yield* Ref.modify(
1532
2014
  state,
1533
2015
  (
@@ -1540,6 +2022,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1540
2022
  LedgerState,
1541
2023
  ] => {
1542
2024
  const stored = current.submissions.get(command.submissionId);
2025
+
1543
2026
  if (stored === undefined) {
1544
2027
  return [
1545
2028
  failure(
@@ -1563,6 +2046,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1563
2046
  current,
1564
2047
  ];
1565
2048
  }
2049
+
1566
2050
  return [
1567
2051
  failure(
1568
2052
  SettlementConflict.make({
@@ -1574,7 +2058,11 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1574
2058
  ];
1575
2059
  }
1576
2060
  const existing = stored.unknownResolutions.get(command.toolCallId);
1577
- if (existing !== undefined && existing.resolutionJson !== resolutionJson) {
2061
+
2062
+ if (
2063
+ existing !== undefined &&
2064
+ !equivalentUnknownResolution(existing.intent.resolution, command.resolution)
2065
+ ) {
1578
2066
  return [
1579
2067
  failure(
1580
2068
  UnknownResolutionConflict.make({
@@ -1585,6 +2073,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1585
2073
  current,
1586
2074
  ];
1587
2075
  }
2076
+
1588
2077
  const intent =
1589
2078
  existing?.intent ??
1590
2079
  UnknownResolutionIntent.make({
@@ -1595,13 +2084,14 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1595
2084
  resolution: command.resolution,
1596
2085
  resolvedAt: utc(nowMillis),
1597
2086
  });
2087
+
1598
2088
  const unknownResolutions =
1599
2089
  existing !== undefined
1600
2090
  ? stored.unknownResolutions
1601
2091
  : new Map(stored.unknownResolutions).set(command.toolCallId, {
1602
2092
  intent,
1603
- resolutionJson,
1604
2093
  });
2094
+
1605
2095
  // The lane reopens only when EVERY marked open call has a durable resolution
1606
2096
  // intent: unknown → input-applied (DUR-017). Replays re-run the coverage check so
1607
2097
  // a recovering caller can wake the lane idempotently.
@@ -1611,6 +2101,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1611
2101
  stored.unknownMark.toolCallIds.every((toolCallId) =>
1612
2102
  unknownResolutions.has(toolCallId),
1613
2103
  );
2104
+
1614
2105
  return [
1615
2106
  success(intent),
1616
2107
  withSubmission(current, {
@@ -1622,7 +2113,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1622
2113
  ];
1623
2114
  },
1624
2115
  );
2116
+
1625
2117
  if (decision._tag === "failure") return yield* decision.error;
2118
+
1626
2119
  return decision.value;
1627
2120
  }),
1628
2121
  );
@@ -1636,10 +2129,12 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1636
2129
  "recordChildSettled",
1637
2130
  unvalidated,
1638
2131
  );
2132
+
1639
2133
  const decision = yield* Ref.modify(
1640
2134
  state,
1641
2135
  (current): readonly [Decision<ChildSettledOutcome, LedgerError>, LedgerState] => {
1642
2136
  const parent = current.submissions.get(request.parentSubmissionId);
2137
+
1643
2138
  if (parent === undefined) {
1644
2139
  return [
1645
2140
  failure(
@@ -1651,10 +2146,18 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1651
2146
  current,
1652
2147
  ];
1653
2148
  }
1654
- // The child's canonical Settlement is the authority for this wake; a notification
1655
- // for an unsettled (or unknown) child is a caller error in a single-store adapter.
2149
+ // The caller may notify after the canonical Settlement append but before ledger
2150
+ // finalization. In a single store, an exact reservation plus `terminalizing` is the
2151
+ // narrow durable prefix that makes that ordering admissible; earlier states remain
2152
+ // a caller error.
1656
2153
  const child = current.submissions.get(request.childSubmissionId);
1657
- if (child === undefined || child.row.state !== "settled") {
2154
+
2155
+ const announced =
2156
+ child !== undefined &&
2157
+ (child.row.state === "settled" ||
2158
+ (child.row.state === "terminalizing" && child.reservation !== undefined));
2159
+
2160
+ if (!announced) {
1658
2161
  return [
1659
2162
  failure(
1660
2163
  ledgerError(
@@ -1673,16 +2176,24 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1673
2176
  return [success("not-waiting" as const), current];
1674
2177
  }
1675
2178
  const children = parent.suspension.reason.children;
2179
+
1676
2180
  if (!children.some((entry) => entry.childSubmissionId === request.childSubmissionId)) {
1677
2181
  return [success("not-waiting" as const), current];
1678
2182
  }
1679
- // The parent wakes exactly when EVERY listed child is settled (spec §12 step 10);
1680
- // replays re-run the coverage check so a recovering caller wakes the lane
1681
- // idempotently.
1682
- const allSettled = children.every(
1683
- (entry) => current.submissions.get(entry.childSubmissionId)?.row.state === "settled",
1684
- );
2183
+
2184
+ // Every listed child must be either finalized or canonically announced from the
2185
+ // exact terminalizing reservation. Replays re-run this coverage check idempotently.
2186
+ const allSettled = children.every((entry) => {
2187
+ const listed = current.submissions.get(entry.childSubmissionId);
2188
+
2189
+ return (
2190
+ listed?.row.state === "settled" ||
2191
+ (listed?.row.state === "terminalizing" && listed.reservation !== undefined)
2192
+ );
2193
+ });
2194
+
1685
2195
  if (!allSettled) return [success("still-waiting" as const), current];
2196
+
1686
2197
  return [
1687
2198
  success("woken" as const),
1688
2199
  withSubmission(current, {
@@ -1693,7 +2204,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1693
2204
  ];
1694
2205
  },
1695
2206
  );
2207
+
1696
2208
  if (decision._tag === "failure") return yield* decision.error;
2209
+
1697
2210
  return decision.value;
1698
2211
  }),
1699
2212
  );
@@ -1707,8 +2220,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1707
2220
  "reserveChildBudget",
1708
2221
  unvalidated,
1709
2222
  );
2223
+
1710
2224
  const nowMillis = yield* Clock.currentTimeMillis;
1711
- const allocationJson = JSON.stringify(request.allocation);
2225
+
1712
2226
  const decision = yield* Ref.modify(
1713
2227
  state,
1714
2228
  (
@@ -1718,6 +2232,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1718
2232
  LedgerState,
1719
2233
  ] => {
1720
2234
  const existing = current.childReservations.get(request.reservationId);
2235
+
1721
2236
  if (existing !== undefined) {
1722
2237
  // Identical replays short-circuit before the fence, mirroring reserveSettlement:
1723
2238
  // a replay creates nothing, so a recovering caller resumes rather than duplicates.
@@ -1725,7 +2240,8 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1725
2240
  existing.parentSubmissionId === request.parentSubmissionId &&
1726
2241
  existing.parentToolCallId === request.parentToolCallId &&
1727
2242
  existing.allocationDigest === request.allocationDigest &&
1728
- existing.allocationJson === allocationJson;
2243
+ equivalentPersistedJson(existing.allocation, request.allocation);
2244
+
1729
2245
  if (!identical) {
1730
2246
  return [
1731
2247
  failure(
@@ -1739,6 +2255,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1739
2255
  current,
1740
2256
  ];
1741
2257
  }
2258
+
1742
2259
  return [
1743
2260
  success(
1744
2261
  ReservedChildBudget.make({
@@ -1767,6 +2284,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1767
2284
  }
1768
2285
  }
1769
2286
  const parent = current.submissions.get(request.parentSubmissionId);
2287
+
1770
2288
  if (parent === undefined) {
1771
2289
  return [
1772
2290
  failure(
@@ -1780,9 +2298,10 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1780
2298
  }
1781
2299
  // Creation is fenced by the parent lane's live ownership (spec §12 step 2): a stale
1782
2300
  // parent Attempt can never create new reservation state.
1783
- if (!ownsLane(parent, request.ownershipToken)) {
2301
+ if (!ownsLane(current, parent, request.ownershipToken)) {
1784
2302
  return [failure(ownershipLost(current, parent)), current];
1785
2303
  }
2304
+
1786
2305
  const reservation: StoredChildReservation = {
1787
2306
  reservationId: request.reservationId,
1788
2307
  parentSubmissionId: request.parentSubmissionId,
@@ -1790,14 +2309,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1790
2309
  childSubmissionId: undefined,
1791
2310
  status: "reserved",
1792
2311
  allocation: request.allocation,
1793
- allocationJson,
1794
2312
  allocationDigest: request.allocationDigest,
1795
2313
  accounting: undefined,
1796
- accountingJson: undefined,
1797
2314
  reservedAtMillis: nowMillis,
1798
2315
  releaseBeganAtMillis: undefined,
1799
2316
  releasedAtMillis: undefined,
1800
2317
  };
2318
+
1801
2319
  return [
1802
2320
  success(
1803
2321
  ReservedChildBudget.make({
@@ -1809,7 +2327,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1809
2327
  ];
1810
2328
  },
1811
2329
  );
2330
+
1812
2331
  if (decision._tag === "failure") return yield* decision.error;
2332
+
1813
2333
  return decision.value;
1814
2334
  }),
1815
2335
  );
@@ -1822,6 +2342,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1822
2342
  "attachChildToReservation",
1823
2343
  unvalidated,
1824
2344
  );
2345
+
1825
2346
  const decision = yield* Ref.modify(
1826
2347
  state,
1827
2348
  (
@@ -1834,6 +2355,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1834
2355
  LedgerState,
1835
2356
  ] => {
1836
2357
  const reservation = current.childReservations.get(request.reservationId);
2358
+
1837
2359
  if (reservation === undefined) {
1838
2360
  return [
1839
2361
  failure(
@@ -1850,6 +2372,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1850
2372
  if (reservation.childSubmissionId === request.childSubmissionId) {
1851
2373
  return [success(toReservationSnapshot(reservation)), current];
1852
2374
  }
2375
+
1853
2376
  return [
1854
2377
  failure(
1855
2378
  ChildReservationConflict.make({
@@ -1862,6 +2385,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1862
2385
  ];
1863
2386
  }
1864
2387
  const parent = current.submissions.get(reservation.parentSubmissionId);
2388
+
1865
2389
  if (parent === undefined) {
1866
2390
  return [
1867
2391
  failure(
@@ -1873,7 +2397,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1873
2397
  current,
1874
2398
  ];
1875
2399
  }
1876
- if (!ownsLane(parent, request.ownershipToken)) {
2400
+ if (!ownsLane(current, parent, request.ownershipToken)) {
1877
2401
  return [failure(ownershipLost(current, parent)), current];
1878
2402
  }
1879
2403
  if (reservation.status !== "reserved") {
@@ -1901,17 +2425,21 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1901
2425
  current,
1902
2426
  ];
1903
2427
  }
2428
+
1904
2429
  const attached: StoredChildReservation = {
1905
2430
  ...reservation,
1906
2431
  childSubmissionId: request.childSubmissionId,
1907
2432
  };
2433
+
1908
2434
  return [
1909
2435
  success(toReservationSnapshot(attached)),
1910
2436
  withChildReservation(current, attached),
1911
2437
  ];
1912
2438
  },
1913
2439
  );
2440
+
1914
2441
  if (decision._tag === "failure") return yield* decision.error;
2442
+
1915
2443
  return decision.value;
1916
2444
  }),
1917
2445
  );
@@ -1924,8 +2452,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1924
2452
  "beginChildBudgetRelease",
1925
2453
  unvalidated,
1926
2454
  );
2455
+
1927
2456
  const nowMillis = yield* Clock.currentTimeMillis;
1928
- const accountingJson = JSON.stringify(request.accounting);
2457
+
1929
2458
  const decision = yield* Ref.modify(
1930
2459
  state,
1931
2460
  (
@@ -1935,6 +2464,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1935
2464
  LedgerState,
1936
2465
  ] => {
1937
2466
  const reservation = current.childReservations.get(request.reservationId);
2467
+
1938
2468
  if (reservation === undefined) {
1939
2469
  return [
1940
2470
  failure(
@@ -1949,9 +2479,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1949
2479
  if (reservation.status !== "reserved") {
1950
2480
  // The accounting decision was already frozen exactly once; an identical replay is
1951
2481
  // a no-op and a divergent decision conflicts (spec §12 join step 6).
1952
- if (reservation.accountingJson === accountingJson) {
2482
+ if (
2483
+ reservation.accounting !== undefined &&
2484
+ equivalentPersistedJson(reservation.accounting, request.accounting)
2485
+ ) {
1953
2486
  return [success(toReservationSnapshot(reservation)), current];
1954
2487
  }
2488
+
1955
2489
  return [
1956
2490
  failure(
1957
2491
  ChildReservationConflict.make({
@@ -1964,20 +2498,23 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1964
2498
  current,
1965
2499
  ];
1966
2500
  }
2501
+
1967
2502
  const frozen: StoredChildReservation = {
1968
2503
  ...reservation,
1969
2504
  status: "releasePending",
1970
2505
  accounting: request.accounting,
1971
- accountingJson,
1972
2506
  releaseBeganAtMillis: nowMillis,
1973
2507
  };
2508
+
1974
2509
  return [
1975
2510
  success(toReservationSnapshot(frozen)),
1976
2511
  withChildReservation(current, frozen),
1977
2512
  ];
1978
2513
  },
1979
2514
  );
2515
+
1980
2516
  if (decision._tag === "failure") return yield* decision.error;
2517
+
1981
2518
  return decision.value;
1982
2519
  }),
1983
2520
  );
@@ -1991,7 +2528,9 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
1991
2528
  "releaseChildBudget",
1992
2529
  unvalidated,
1993
2530
  );
2531
+
1994
2532
  const nowMillis = yield* Clock.currentTimeMillis;
2533
+
1995
2534
  const decision = yield* Ref.modify(
1996
2535
  state,
1997
2536
  (
@@ -2001,6 +2540,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2001
2540
  LedgerState,
2002
2541
  ] => {
2003
2542
  const reservation = current.childReservations.get(request.reservationId);
2543
+
2004
2544
  if (reservation === undefined) {
2005
2545
  return [
2006
2546
  failure(
@@ -2030,18 +2570,22 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2030
2570
  current,
2031
2571
  ];
2032
2572
  }
2573
+
2033
2574
  const released: StoredChildReservation = {
2034
2575
  ...reservation,
2035
2576
  status: "released",
2036
2577
  releasedAtMillis: nowMillis,
2037
2578
  };
2579
+
2038
2580
  return [
2039
2581
  success(toReservationSnapshot(released)),
2040
2582
  withChildReservation(current, released),
2041
2583
  ];
2042
2584
  },
2043
2585
  );
2586
+
2044
2587
  if (decision._tag === "failure") return yield* decision.error;
2588
+
2045
2589
  return decision.value;
2046
2590
  }),
2047
2591
  );
@@ -2052,18 +2596,43 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2052
2596
  const snapshots = [...current.submissions.values()]
2053
2597
  .filter((stored) => stored.row.state !== "settled")
2054
2598
  .sort((left, right) =>
2055
- left.row.conversationId < right.row.conversationId
2599
+ left.row.threadId < right.row.threadId
2056
2600
  ? -1
2057
- : left.row.conversationId > right.row.conversationId
2601
+ : left.row.threadId > right.row.threadId
2058
2602
  ? 1
2059
2603
  : left.row.queueSequence - right.row.queueSequence,
2060
2604
  )
2061
- .map((stored) => toSnapshot(stored.row));
2605
+ .map(({ row }) =>
2606
+ SubmissionWorkItem.make({
2607
+ submissionId: row.submissionId,
2608
+ threadId: row.threadId,
2609
+ queueSequence: row.queueSequence,
2610
+ principal: row.principal,
2611
+ idempotencyKey: row.idempotencyKey,
2612
+ deploymentId: row.deploymentId,
2613
+ receiptId: row.receiptId,
2614
+ state: row.state,
2615
+ }),
2616
+ );
2617
+
2062
2618
  return Stream.fromIterable(snapshots);
2063
2619
  }),
2064
2620
  ),
2065
2621
  );
2066
2622
 
2623
+ const readAbortIntent: SubmissionLedger["Service"]["readAbortIntent"] = Effect.fn(
2624
+ "MemorySubmissionLedger.readAbortIntent",
2625
+ )(function* (unvalidated) {
2626
+ const request = yield* validate(AbortIntentRequest, "readAbortIntent", unvalidated);
2627
+ const stored = (yield* Ref.get(state)).submissions.get(request.submissionId);
2628
+
2629
+ if (stored === undefined) {
2630
+ return yield* ledgerError("readAbortIntent", `Unknown Submission ${request.submissionId}`);
2631
+ }
2632
+
2633
+ return stored.abortIntent;
2634
+ });
2635
+
2067
2636
  const loadRecoverySnapshot: SubmissionLedger["Service"]["loadRecoverySnapshot"] = Effect.fn(
2068
2637
  "MemorySubmissionLedger.loadRecoverySnapshot",
2069
2638
  )((unvalidated) =>
@@ -2073,14 +2642,17 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2073
2642
  "loadRecoverySnapshot",
2074
2643
  unvalidated,
2075
2644
  );
2645
+
2076
2646
  const current = yield* Ref.get(state);
2077
2647
  const stored = current.submissions.get(request.submissionId);
2648
+
2078
2649
  if (stored === undefined) {
2079
2650
  return yield* ledgerError(
2080
2651
  "loadRecoverySnapshot",
2081
2652
  `Unknown Submission ${request.submissionId}`,
2082
2653
  );
2083
2654
  }
2655
+
2084
2656
  const joins = [...current.submissions.values()]
2085
2657
  .filter((candidate) => candidate.joinedHostSubmissionId === request.submissionId)
2086
2658
  .sort((left, right) => left.row.queueSequence - right.row.queueSequence)
@@ -2091,11 +2663,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2091
2663
  hostSubmissionId: request.submissionId,
2092
2664
  }),
2093
2665
  );
2666
+
2094
2667
  const byToolCallId = <A extends { readonly toolCallId: ToolCallId }>(
2095
2668
  left: A,
2096
2669
  right: A,
2097
2670
  ): number =>
2098
2671
  left.toolCallId < right.toolCallId ? -1 : left.toolCallId > right.toolCallId ? 1 : 0;
2672
+
2099
2673
  // Parent-side subagent view: this Submission's child budget reservations in parent Tool
2100
2674
  // Call order, plus each attached child's current lane state (a disposable derived view;
2101
2675
  // the canonical records stay the recovery truth, DUR-015).
@@ -2108,10 +2682,13 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2108
2682
  ? 1
2109
2683
  : 0,
2110
2684
  );
2685
+
2111
2686
  const childAttachments: Array<ChildAttachmentSnapshot> = [];
2687
+
2112
2688
  for (const reservation of childReservations) {
2113
2689
  if (reservation.childSubmissionId === undefined) continue;
2114
2690
  const child = current.submissions.get(reservation.childSubmissionId);
2691
+
2115
2692
  if (child === undefined) continue;
2116
2693
  childAttachments.push(
2117
2694
  ChildAttachmentSnapshot.make({
@@ -2124,6 +2701,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2124
2701
  }),
2125
2702
  );
2126
2703
  }
2704
+
2127
2705
  return RecoverySnapshot.make({
2128
2706
  submission: toSnapshot(stored.row),
2129
2707
  joins,
@@ -2187,6 +2765,8 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2187
2765
  reserveSettlement,
2188
2766
  finalizeSettlement,
2189
2767
  requestAbort,
2768
+ stopWorker,
2769
+ inspectWorker,
2190
2770
  claimJoining,
2191
2771
  markJoined,
2192
2772
  revertJoining,
@@ -2201,6 +2781,7 @@ const makeSubmissionLedger = (options: MemorySubmissionLedgerOptions = {}) =>
2201
2781
  releaseChildBudget,
2202
2782
  scanNonterminal,
2203
2783
  loadRecoverySnapshot,
2784
+ readAbortIntent,
2204
2785
  });
2205
2786
  });
2206
2787