@effect-agent/testing 0.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chaos.ts ADDED
@@ -0,0 +1,1187 @@
1
+ import {
2
+ Subagent,
3
+ SubagentPolicy,
4
+ SubagentReservationsMemoryLive,
5
+ SubagentRuntime,
6
+ } from "@effect-agent/capabilities";
7
+ import {
8
+ Agent,
9
+ AgentPolicy,
10
+ ConversationId,
11
+ IdGenerator,
12
+ RunId,
13
+ SubmissionId,
14
+ ToolCallId,
15
+ TurnId,
16
+ } from "@effect-agent/core";
17
+ import { DurableStep, DurableStepError, ToolExecutionClass } from "@effect-agent/engine";
18
+ import {
19
+ AbortCommand,
20
+ AgentBindingResolver,
21
+ ApprovalDecisionCommand,
22
+ ConversationExportRequest,
23
+ ConversationStore,
24
+ DefinitionDigests,
25
+ Digest,
26
+ DurableAgentRuntime,
27
+ DurableRuntimeConfig,
28
+ DurableRuntimeFailpointError,
29
+ DurableRuntimeFailpointLocation,
30
+ DurableRuntimeFailpointTestControl,
31
+ DurableWorkerBinding,
32
+ IdempotencyKey,
33
+ ObligationThresholds,
34
+ Principal,
35
+ ResolutionAbortSubmission,
36
+ ResolutionCompletedWithResult,
37
+ ResolutionNeverHappened,
38
+ SubmissionLedger,
39
+ SubmissionLookupById,
40
+ UnknownResolutionCommand,
41
+ childConversationIdFor,
42
+ verifyConversationInvariants,
43
+ type CanonicalRecordEnvelope,
44
+ type BatchId,
45
+ type ProducerId,
46
+ type Receipt,
47
+ type ResolvedBinding,
48
+ type Settlement,
49
+ type SubmissionSnapshot,
50
+ type UnknownResolution,
51
+ } from "@effect-agent/session";
52
+ import { Cause, Effect, Exit, Layer, Option, Ref, Schema, Stream } from "effect";
53
+ import { FastCheck } from "effect/testing";
54
+ import { LanguageModel, Model, Prompt, Tool, Toolkit, type Response } from "effect/unstable/ai";
55
+
56
+ /**
57
+ * P7 WP4 chaos machinery (plan §5): a Schema-first `ChaosPlan`, a seeded generator over
58
+ * `effect/testing/FastCheck` (already inside the pinned Effect — no new dependency), and a
59
+ * deterministic runner that drives the durable coordinator over whatever adapter pair the test
60
+ * provides. Every plan ends in the SAME claims the crash matrices make:
61
+ *
62
+ * 1. `verifyConversationInvariants` in convergence mode over every touched Conversation (the
63
+ * shared WP1 checker — one set of claims for admin verify, certification, chaos, and soak);
64
+ * 2. `scanObligations` returning ZERO entries (everything settled; nothing invisibly stuck);
65
+ * 3. supplier non-fabrication wherever the deterministic desk was in play (durability §10: no
66
+ * canonical Tool success exists that the external store did not actually produce).
67
+ *
68
+ * Replay contract: the memory/SQLite chaos tests derive every plan from one root seed
69
+ * (`CHAOS_SEED` env override; see `chaosSeedFromEnv`) and print that seed plus the failing
70
+ * plan's own seed in the failure output, so any red run is replayable byte-for-byte.
71
+ */
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // ChaosPlan schema
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** The six durable scenario flavors a chaos lane can exercise (plan §5). */
78
+ export const ChaosScenarioKind = Schema.Literals([
79
+ "plain",
80
+ "uncertain-tool",
81
+ "durable-steps",
82
+ "approval",
83
+ "join",
84
+ "delegation",
85
+ ]);
86
+ export type ChaosScenarioKind = typeof ChaosScenarioKind.Type;
87
+
88
+ const LaneIndex = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(7));
89
+
90
+ /** One Submission of a plan: which lane it queues into and that lane's scenario flavor. */
91
+ export class ChaosSubmissionSpec extends Schema.Class<ChaosSubmissionSpec>(
92
+ "@effect-agent/testing/ChaosSubmissionSpec",
93
+ )({
94
+ lane: LaneIndex,
95
+ /** The lane's flavor; the FIRST spec of a lane fixes the lane's agent. */
96
+ kind: ChaosScenarioKind,
97
+ }) {}
98
+
99
+ /** How the runner resolves a durable Unknown Outcome it encounters (DUR-017 driver). */
100
+ export const ChaosResolutionKind = Schema.Literals([
101
+ /** The call provably never started: the batch resumes and executes it. */
102
+ "never-happened",
103
+ /**
104
+ * Recovered supplier truth: resolve with the EXACT value the desk produced. Falls back to
105
+ * `never-happened` when the desk holds no value for the call, so the runner never fabricates.
106
+ */
107
+ "completed-from-supplier",
108
+ /** Unresolvable: route into the abort path (settles aborted, audit retained). */
109
+ "abort-submission",
110
+ ]);
111
+ export type ChaosResolutionKind = typeof ChaosResolutionKind.Type;
112
+
113
+ export const ChaosApprovalDecision = Schema.Literals(["approved", "denied"]);
114
+ export type ChaosApprovalDecision = typeof ChaosApprovalDecision.Type;
115
+
116
+ const BoundedAdapterArm = Schema.String.check(Schema.isMaxLength(128));
117
+
118
+ /**
119
+ * One seeded chaos plan (plan §5): the full fault schedule is data, so a failing run replays
120
+ * from the plan alone. `failpointArms` are coordinator locations; `adapterArms` are
121
+ * adapter-owned location names the adapter test validates (the memory runner has none).
122
+ */
123
+ export class ChaosPlan extends Schema.Class<ChaosPlan>("@effect-agent/testing/ChaosPlan")({
124
+ /** Identifies this plan in failure output; derived from the root seed plus the plan index. */
125
+ seed: Schema.Int,
126
+ /** Lane count; submissions address lanes `0..lanes-1`. */
127
+ lanes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(8)),
128
+ submissions: Schema.NonEmptyArray(ChaosSubmissionSpec),
129
+ /** Coordinator failpoint arms, consumed one per round (each fails every hit that round). */
130
+ failpointArms: Schema.Array(DurableRuntimeFailpointLocation),
131
+ /** Adapter-owned failpoint arms (e.g. SQLite `ledger:*`/`append:*` locations). */
132
+ adapterArms: Schema.Array(BoundedAdapterArm),
133
+ /** Flattened submission indices to abort mid-plan (modulo the submission count). */
134
+ abortInjections: Schema.Array(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
135
+ /** Resolution choices for Unknown Outcomes, indexed deterministically per open call. */
136
+ resolutionInjections: Schema.Array(ChaosResolutionKind),
137
+ /** Approval decisions for suspended approval lanes, indexed deterministically per call. */
138
+ approvalDecisions: Schema.Array(ChaosApprovalDecision),
139
+ }) {}
140
+
141
+ /** Per-lane verification result inside a plan report. */
142
+ export class ChaosLaneReport extends Schema.Class<ChaosLaneReport>(
143
+ "@effect-agent/testing/ChaosLaneReport",
144
+ )({
145
+ conversationId: ConversationId,
146
+ kind: ChaosScenarioKind,
147
+ submissionCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
148
+ /** Verdict of `verifyConversationInvariants` in convergence mode. */
149
+ verified: Schema.Boolean,
150
+ }) {}
151
+
152
+ /** The Schema-first outcome of one executed chaos plan. */
153
+ export class ChaosPlanReport extends Schema.Class<ChaosPlanReport>(
154
+ "@effect-agent/testing/ChaosPlanReport",
155
+ )({
156
+ seed: Schema.Int,
157
+ rounds: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
158
+ lanes: Schema.Array(ChaosLaneReport),
159
+ /** `scanObligations` entries after convergence — MUST be zero. */
160
+ openObligations: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
161
+ }) {}
162
+
163
+ /** Typed convergence/verification failure of one chaos plan (never a bare defect). */
164
+ export class ChaosConvergenceFailure extends Schema.TaggedErrorClass<ChaosConvergenceFailure>()(
165
+ "ChaosConvergenceFailure",
166
+ {
167
+ seed: Schema.Int,
168
+ message: Schema.String.check(Schema.isMaxLength(16_384)),
169
+ },
170
+ ) {}
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Seeded generation
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** Default root seed for chaos suites; override with the `CHAOS_SEED` environment variable. */
177
+ export const DEFAULT_CHAOS_SEED = 20260813;
178
+
179
+ /** The root seed for this run: `CHAOS_SEED` when set to an integer, the default otherwise. */
180
+ export const chaosSeedFromEnv = (env: Record<string, string | undefined>): number => {
181
+ const raw = env["CHAOS_SEED"];
182
+ if (raw === undefined || raw === "") return DEFAULT_CHAOS_SEED;
183
+ const parsed = Number.parseInt(raw, 10);
184
+ return Number.isSafeInteger(parsed) ? parsed : DEFAULT_CHAOS_SEED;
185
+ };
186
+
187
+ export interface ChaosGeneratorOptions {
188
+ /** Root seed (print this in failure output for replay). */
189
+ readonly seed: number;
190
+ /** How many plans to derive. */
191
+ readonly count: number;
192
+ /** Adapter failpoint location names available to arm (empty for memory). */
193
+ readonly adapterArms?: ReadonlyArray<string> | undefined;
194
+ }
195
+
196
+ interface GeneratedLane {
197
+ readonly kind: ChaosScenarioKind;
198
+ readonly depth: number;
199
+ }
200
+
201
+ const laneArbitrary: FastCheck.Arbitrary<GeneratedLane> = FastCheck.constantFrom<ChaosScenarioKind>(
202
+ "plain",
203
+ "uncertain-tool",
204
+ "durable-steps",
205
+ "approval",
206
+ "join",
207
+ "delegation",
208
+ ).chain(
209
+ (kind): FastCheck.Arbitrary<GeneratedLane> =>
210
+ kind === "join"
211
+ ? FastCheck.integer({ min: 2, max: 3 }).map((depth): GeneratedLane => ({ kind, depth }))
212
+ : kind === "plain"
213
+ ? FastCheck.integer({ min: 1, max: 2 }).map((depth): GeneratedLane => ({ kind, depth }))
214
+ : FastCheck.constant<GeneratedLane>({ kind, depth: 1 }),
215
+ );
216
+
217
+ interface ChaosPlanShape {
218
+ readonly lanes: number;
219
+ readonly submissions: readonly [ChaosSubmissionSpec, ...Array<ChaosSubmissionSpec>];
220
+ readonly failpointArms: ReadonlyArray<DurableRuntimeFailpointLocation>;
221
+ readonly adapterArms: ReadonlyArray<string>;
222
+ readonly abortInjections: ReadonlyArray<number>;
223
+ readonly resolutionInjections: ReadonlyArray<ChaosResolutionKind>;
224
+ readonly approvalDecisions: ReadonlyArray<ChaosApprovalDecision>;
225
+ }
226
+
227
+ const planShapeArbitrary = (
228
+ adapterArms: ReadonlyArray<string>,
229
+ ): FastCheck.Arbitrary<ChaosPlanShape> =>
230
+ FastCheck.record({
231
+ lanes: FastCheck.array(laneArbitrary, { minLength: 1, maxLength: 3 }),
232
+ failpointArms: FastCheck.uniqueArray(
233
+ FastCheck.constantFrom(...DurableRuntimeFailpointLocation.literals),
234
+ { maxLength: 3 },
235
+ ),
236
+ adapterArms:
237
+ adapterArms.length === 0
238
+ ? FastCheck.constant<Array<string>>([])
239
+ : FastCheck.uniqueArray(FastCheck.constantFrom(...adapterArms), { maxLength: 2 }),
240
+ abortInjections: FastCheck.uniqueArray(FastCheck.integer({ min: 0, max: 15 }), {
241
+ maxLength: 2,
242
+ }),
243
+ resolutionInjections: FastCheck.array(
244
+ FastCheck.constantFrom<ChaosResolutionKind>(
245
+ "never-happened",
246
+ "completed-from-supplier",
247
+ "abort-submission",
248
+ ),
249
+ { maxLength: 4 },
250
+ ),
251
+ approvalDecisions: FastCheck.array(
252
+ FastCheck.constantFrom<ChaosApprovalDecision>("approved", "denied"),
253
+ { maxLength: 2 },
254
+ ),
255
+ }).map((shape) => {
256
+ const submissions = shape.lanes.flatMap((lane, index) =>
257
+ Array.from({ length: lane.depth }, () =>
258
+ ChaosSubmissionSpec.make({ lane: index, kind: lane.kind }),
259
+ ),
260
+ );
261
+ const [first, ...rest] = submissions;
262
+ // `lanes` >= 1 and every lane has depth >= 1, so `first` always exists.
263
+ if (first === undefined) throw new Error("chaos generator produced an empty plan");
264
+ return {
265
+ lanes: shape.lanes.length,
266
+ submissions: [first, ...rest] as const,
267
+ failpointArms: shape.failpointArms,
268
+ adapterArms: shape.adapterArms,
269
+ abortInjections: shape.abortInjections,
270
+ resolutionInjections: shape.resolutionInjections,
271
+ approvalDecisions: shape.approvalDecisions,
272
+ };
273
+ });
274
+
275
+ /**
276
+ * Derive `count` chaos plans deterministically from one root seed. The same
277
+ * `{seed, count, adapterArms}` triple always yields byte-identical plans, so a failure line
278
+ * `CHAOS_SEED=<seed>` replays the exact schedule.
279
+ */
280
+ export const generateChaosPlans = (options: ChaosGeneratorOptions): ReadonlyArray<ChaosPlan> => {
281
+ const sampled = FastCheck.sample(planShapeArbitrary(options.adapterArms ?? []), {
282
+ seed: options.seed,
283
+ numRuns: options.count,
284
+ });
285
+ return sampled.map((shape, index) =>
286
+ ChaosPlan.make({ ...shape, seed: (Math.imul(options.seed, 31) + index) | 0 }),
287
+ );
288
+ };
289
+
290
+ /** Deterministic PRNG for the runner's small ordering choices (lane drive order). */
291
+ const mulberry32 = (seed: number): (() => number) => {
292
+ let state = seed | 0;
293
+ return () => {
294
+ state = (state + 0x6d2b79f5) | 0;
295
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
296
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
297
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
298
+ };
299
+ };
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Lane fixtures (agents, scripted models, deterministic desk)
303
+ // ---------------------------------------------------------------------------
304
+
305
+ const usage = { inputTokens: {}, outputTokens: {} };
306
+
307
+ const finalParts = (text: string): ReadonlyArray<Response.StreamPartEncoded> => [
308
+ { type: "text-start", id: "answer" },
309
+ { type: "text-delta", id: "answer", delta: text },
310
+ { type: "text-end", id: "answer" },
311
+ { type: "finish", reason: "stop", usage },
312
+ ];
313
+
314
+ const toolTurn = (
315
+ ...calls: ReadonlyArray<Response.StreamPartEncoded>
316
+ ): ReadonlyArray<Response.StreamPartEncoded> => [
317
+ ...calls,
318
+ { type: "finish", reason: "tool-calls", usage },
319
+ ];
320
+
321
+ const toolCallPart = (id: string, name: string, params: unknown): Response.StreamPartEncoded => ({
322
+ type: "tool-call",
323
+ id,
324
+ name,
325
+ params,
326
+ providerExecuted: false,
327
+ });
328
+
329
+ /**
330
+ * Prompt-shaped scripted model: the response depends ONLY on the request prompt, so it stays
331
+ * deterministic across Attempt re-invocations, batch resumes, and joined steering — no counter
332
+ * to drift when chaos re-enters a Turn.
333
+ */
334
+ const promptScriptedModel = (
335
+ label: string,
336
+ script: (prompt: Prompt.Prompt) => ReadonlyArray<Response.StreamPartEncoded>,
337
+ ) =>
338
+ Model.make(
339
+ "scripted",
340
+ label,
341
+ Layer.effect(
342
+ LanguageModel.LanguageModel,
343
+ LanguageModel.make({
344
+ generateText: () => Effect.succeed([]),
345
+ streamText: (request) => Stream.fromIterable(script(request.prompt)),
346
+ }),
347
+ ),
348
+ );
349
+
350
+ const lastRole = (prompt: Prompt.Prompt): string | undefined => prompt.content.at(-1)?.role;
351
+
352
+ const policy = AgentPolicy.make({
353
+ maxTurns: 3,
354
+ maxToolCalls: 4,
355
+ maxDuration: "30 seconds",
356
+ toolConcurrency: 2,
357
+ });
358
+
359
+ const PlainInput = Schema.Struct({ question: Schema.String });
360
+ const PlainOutput = Schema.Struct({ answer: Schema.String });
361
+
362
+ const plainDefinition = Agent.define("chaos-plain", {
363
+ input: PlainInput,
364
+ output: PlainOutput,
365
+ instructions: "Answer as JSON.",
366
+ toolkit: Toolkit.empty,
367
+ policy,
368
+ });
369
+
370
+ /** Unannotated → fail-closed `uncertain`: enters the prepared/settled protocol (DUR-009). */
371
+ const BookUncertain = Tool.make("book", {
372
+ parameters: Schema.Struct({ ref: Schema.String }),
373
+ success: Schema.Struct({ confirmation: Schema.String }),
374
+ });
375
+ const bookTools = Toolkit.make(BookUncertain);
376
+ const bookDefinition = Agent.define("chaos-book", {
377
+ input: PlainInput,
378
+ output: PlainOutput,
379
+ instructions: "Book it.",
380
+ toolkit: bookTools,
381
+ policy,
382
+ });
383
+
384
+ const BookApproval = Tool.make("book", {
385
+ parameters: Schema.Struct({ ref: Schema.String }),
386
+ success: Schema.Struct({ confirmation: Schema.String }),
387
+ needsApproval: true,
388
+ });
389
+ const approvalTools = Toolkit.make(BookApproval);
390
+ const approvalDefinition = Agent.define("chaos-approval", {
391
+ input: PlainInput,
392
+ output: PlainOutput,
393
+ instructions: "Book after approval.",
394
+ toolkit: approvalTools,
395
+ policy,
396
+ });
397
+
398
+ const Itinerary = Tool.make("itinerary", {
399
+ parameters: Schema.Struct({ ref: Schema.String }),
400
+ success: Schema.Struct({ state: Schema.String }),
401
+ failure: DurableStepError,
402
+ dependencies: [DurableStep],
403
+ }).annotate(ToolExecutionClass, "uncertain");
404
+ const itineraryTools = Toolkit.make(Itinerary);
405
+ const itineraryDefinition = Agent.define("chaos-itinerary", {
406
+ input: PlainInput,
407
+ output: PlainOutput,
408
+ instructions: "Reserve the itinerary.",
409
+ toolkit: itineraryTools,
410
+ policy,
411
+ });
412
+
413
+ const childDefinition = Agent.define("chaos-child", {
414
+ input: PlainInput,
415
+ output: PlainOutput,
416
+ instructions: "Answer as JSON.",
417
+ toolkit: Toolkit.empty,
418
+ policy: AgentPolicy.make({
419
+ maxTurns: 2,
420
+ maxToolCalls: 1,
421
+ maxDuration: "30 seconds",
422
+ toolConcurrency: 1,
423
+ }),
424
+ });
425
+
426
+ class ChaosDelegationFailed extends Schema.TaggedErrorClass<ChaosDelegationFailed>()(
427
+ "ChaosDelegationFailed",
428
+ { childErrorTag: Schema.String },
429
+ ) {}
430
+
431
+ const chaosDelegation = Subagent.define("delegate_chaos", {
432
+ description: "Delegate one bounded chaos question.",
433
+ target: childDefinition,
434
+ parameters: Schema.Struct({ topic: Schema.String }),
435
+ success: Schema.Struct({ summary: Schema.String }),
436
+ failure: ChaosDelegationFailed,
437
+ prepareInput: ({ topic }) => Effect.succeed({ question: `chaos:${topic}` }),
438
+ projectResult: (output) => Effect.succeed({ summary: `finding:${output.answer}` }),
439
+ policy: SubagentPolicy.make({
440
+ maxChildren: 2,
441
+ maxConcurrency: 2,
442
+ maxTurns: 4,
443
+ maxToolCalls: 4,
444
+ maxDuration: "30 seconds",
445
+ }),
446
+ });
447
+
448
+ const coordinatorDefinition = Agent.define("chaos-coordinator", {
449
+ input: Schema.Struct({ mission: Schema.String }),
450
+ output: Schema.Struct({ report: Schema.String }),
451
+ instructions: "Delegate, then report as JSON.",
452
+ toolkit: Toolkit.make(chaosDelegation.tool),
453
+ policy,
454
+ });
455
+
456
+ const DELEGATE_CALL_ID = "chaos-delegate-1";
457
+
458
+ const HEX = "0123456789abcdef";
459
+ const decodeDigest = Schema.decodeSync(Digest);
460
+ const laneDigests = (lane: number): DefinitionDigests => {
461
+ const digest = decodeDigest(HEX[lane % 8]!.repeat(64));
462
+ return DefinitionDigests.make({ agent: digest, model: digest, tools: digest });
463
+ };
464
+ const childDigestStrings = (lane: number) => {
465
+ const char = HEX[8 + (lane % 8)]!;
466
+ return { agent: char.repeat(64), model: char.repeat(64), tools: char.repeat(64) } as const;
467
+ };
468
+ const childLaneDigests = (lane: number): DefinitionDigests => {
469
+ const strings = childDigestStrings(lane);
470
+ return DefinitionDigests.make({
471
+ agent: decodeDigest(strings.agent),
472
+ model: decodeDigest(strings.model),
473
+ tools: decodeDigest(strings.tools),
474
+ });
475
+ };
476
+
477
+ const CHAOS_PRINCIPAL = Schema.decodeSync(Principal)("principal-chaos");
478
+ const decodeConversationId = Schema.decodeSync(ConversationId);
479
+ const decodeIdempotencyKey = Schema.decodeSync(IdempotencyKey);
480
+ const decodeToolCallId = Schema.decodeSync(ToolCallId);
481
+ const decodeRunId = Schema.decodeSync(RunId);
482
+ const decodeTurnId = Schema.decodeSync(TurnId);
483
+
484
+ /** Fixture-only identity source consumed by the delegation Layer's ephemeral capture. */
485
+ const chaosIdentifiers = Layer.effect(
486
+ IdGenerator,
487
+ Effect.gen(function* () {
488
+ const counter = yield* Ref.make(0);
489
+ const next = <A>(decode: (value: string) => A, prefix: string) =>
490
+ Ref.getAndUpdate(counter, (value) => value + 1).pipe(
491
+ Effect.map((value) => decode(`${prefix}-${value}`)),
492
+ );
493
+ return {
494
+ nextConversationId: next(decodeConversationId, "chaos-fixture-conversation"),
495
+ nextRunId: next(decodeRunId, "chaos-fixture-run"),
496
+ nextTurnId: next(decodeTurnId, "chaos-fixture-turn"),
497
+ };
498
+ }),
499
+ );
500
+
501
+ const delegationSupport = Layer.mergeAll(SubagentReservationsMemoryLive, chaosIdentifiers);
502
+
503
+ /**
504
+ * The deterministic external desk of one plan: every produced value is recorded so the final
505
+ * non-fabrication sweep can prove each canonical Tool success came from here (durability §10).
506
+ */
507
+ interface ChaosDesk {
508
+ readonly produced: Effect.Effect<ReadonlySet<string>>;
509
+ readonly record: (value: string) => Effect.Effect<void>;
510
+ }
511
+
512
+ const makeChaosDesk: Effect.Effect<ChaosDesk> = Effect.gen(function* () {
513
+ const produced = yield* Ref.make<ReadonlySet<string>>(new Set());
514
+ return {
515
+ produced: Ref.get(produced),
516
+ record: (value: string) => Ref.update(produced, (current) => new Set(current).add(value)),
517
+ };
518
+ });
519
+
520
+ const bookConfirmation = (ref: string): string => `confirmed-${ref}`;
521
+ const flightValue = (ref: string): string => `flight-${ref}`;
522
+ const lodgingValue = (ref: string): string => `lodging-${ref}`;
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Runner
526
+ // ---------------------------------------------------------------------------
527
+
528
+ /** Adapter-owned failpoint control the SQLite runner supplies; memory has none. */
529
+ export interface ChaosAdapterFailpoints {
530
+ readonly arm: (location: string) => Effect.Effect<void>;
531
+ readonly clear: Effect.Effect<void>;
532
+ }
533
+
534
+ export interface ChaosRunOptions {
535
+ readonly adapterFailpoints?: ChaosAdapterFailpoints | undefined;
536
+ /**
537
+ * Executed at the end of every round. Adapters whose ownership leases block every new claim
538
+ * until expiry (the SQLite ledger's D5 semantics — expiry only revokes the liveness
539
+ * assumption; producer epochs stay the correctness fence) pass a deterministic
540
+ * `TestClock.adjust` here so a dead Attempt's lane becomes reclaimable next round. The memory
541
+ * ledger needs nothing: it allows same-producer reclaim under a live lease.
542
+ */
543
+ readonly betweenRounds?: Effect.Effect<void> | undefined;
544
+ }
545
+
546
+ /** Success → Some; typed failure → None (chaos tolerates it); defect → rethrown loudly. */
547
+ const tolerateTyped = <A, E, R>(
548
+ effect: Effect.Effect<A, E, R>,
549
+ ): Effect.Effect<Option.Option<A>, never, R> =>
550
+ effect.pipe(
551
+ Effect.exit,
552
+ Effect.flatMap((exit) => {
553
+ if (Exit.isSuccess(exit)) return Effect.succeed(Option.some(exit.value));
554
+ if (Option.isSome(Cause.findErrorOption(exit.cause))) {
555
+ return Effect.succeed(Option.none<A>());
556
+ }
557
+ return Effect.die(new Error(`chaos step died: ${Cause.pretty(exit.cause)}`));
558
+ }),
559
+ );
560
+
561
+ interface LaneFixture {
562
+ readonly index: number;
563
+ readonly kind: ChaosScenarioKind;
564
+ readonly conversationId: ConversationId;
565
+ readonly ref: string;
566
+ readonly deskInPlay: boolean;
567
+ readonly submissionIndexes: ReadonlyArray<number>;
568
+ readonly submitOne: (flatIndex: number) => Effect.Effect<Receipt, unknown>;
569
+ readonly drives: (
570
+ firstReceipt: Receipt | undefined,
571
+ ) => ReadonlyArray<Effect.Effect<ReadonlyArray<Settlement>, unknown>>;
572
+ readonly childConversationOf: (firstReceipt: Receipt) => ConversationId | undefined;
573
+ }
574
+
575
+ interface SubmissionState {
576
+ readonly flatIndex: number;
577
+ readonly lane: LaneFixture;
578
+ receipt: Receipt | undefined;
579
+ }
580
+
581
+ const scriptFor = (
582
+ kind: ChaosScenarioKind,
583
+ ref: string,
584
+ ): ((prompt: Prompt.Prompt) => ReadonlyArray<Response.StreamPartEncoded>) => {
585
+ switch (kind) {
586
+ case "plain":
587
+ case "join":
588
+ return () => finalParts('{"answer":"chaos"}');
589
+ case "uncertain-tool":
590
+ case "approval":
591
+ return (prompt) =>
592
+ lastRole(prompt) === "tool"
593
+ ? finalParts('{"answer":"booked"}')
594
+ : toolTurn(toolCallPart(`book-${ref}`, "book", { ref }));
595
+ case "durable-steps":
596
+ return (prompt) =>
597
+ lastRole(prompt) === "tool"
598
+ ? finalParts('{"answer":"reserved"}')
599
+ : toolTurn(toolCallPart(`itinerary-${ref}`, "itinerary", { ref }));
600
+ case "delegation":
601
+ return (prompt) =>
602
+ lastRole(prompt) === "tool"
603
+ ? finalParts('{"report":"done"}')
604
+ : toolTurn(toolCallPart(DELEGATE_CALL_ID, "delegate_chaos", { topic: ref }));
605
+ }
606
+ };
607
+
608
+ const makeLaneFixture = Effect.fn("Chaos.makeLaneFixture")(function* (
609
+ plan: ChaosPlan,
610
+ laneIndex: number,
611
+ kind: ChaosScenarioKind,
612
+ submissionIndexes: ReadonlyArray<number>,
613
+ desk: ChaosDesk,
614
+ ) {
615
+ const runtime = yield* DurableAgentRuntime;
616
+ const conversationId = decodeConversationId(`chaos-${plan.seed}-lane-${laneIndex}`);
617
+ const ref = `ref-l${laneIndex}`;
618
+ const script = scriptFor(kind, ref);
619
+ const model = promptScriptedModel(`chaos-${kind}-${laneIndex}`, script);
620
+ const digests = laneDigests(laneIndex);
621
+
622
+ const submitOptionsFor = (flatIndex: number) => ({
623
+ conversationId,
624
+ principal: CHAOS_PRINCIPAL,
625
+ idempotencyKey: decodeIdempotencyKey(`chaos-${plan.seed}-s${flatIndex}`),
626
+ definitions: digests,
627
+ });
628
+
629
+ const bookToolLayerFor = (tools: typeof bookTools | typeof approvalTools) =>
630
+ tools.toLayer({
631
+ book: ({ ref: called }) =>
632
+ desk
633
+ .record(bookConfirmation(called))
634
+ .pipe(Effect.as({ confirmation: bookConfirmation(called) })),
635
+ });
636
+
637
+ const plainLaneFixture = (
638
+ deskInPlay: boolean,
639
+ drive: Effect.Effect<ReadonlyArray<Settlement>, unknown>,
640
+ submitOne: (flatIndex: number) => Effect.Effect<Receipt, unknown>,
641
+ ): LaneFixture => ({
642
+ index: laneIndex,
643
+ kind,
644
+ conversationId,
645
+ ref,
646
+ deskInPlay,
647
+ submissionIndexes,
648
+ submitOne,
649
+ drives: () => [drive],
650
+ childConversationOf: () => undefined,
651
+ });
652
+
653
+ switch (kind) {
654
+ case "plain":
655
+ case "join": {
656
+ const agent = Agent.withModel(plainDefinition, model);
657
+ return plainLaneFixture(
658
+ false,
659
+ runtime.processConversation(agent, conversationId),
660
+ (flatIndex) =>
661
+ runtime.submit(agent, { question: `chaos ${flatIndex}` }, submitOptionsFor(flatIndex)),
662
+ );
663
+ }
664
+ case "uncertain-tool": {
665
+ const agent = Agent.withModel(bookDefinition, model);
666
+ return plainLaneFixture(
667
+ true,
668
+ runtime
669
+ .processConversation(agent, conversationId)
670
+ .pipe(Effect.provide(bookToolLayerFor(bookTools))),
671
+ (flatIndex) =>
672
+ runtime.submit(agent, { question: `chaos ${flatIndex}` }, submitOptionsFor(flatIndex)),
673
+ );
674
+ }
675
+ case "approval": {
676
+ const agent = Agent.withModel(approvalDefinition, model);
677
+ return plainLaneFixture(
678
+ true,
679
+ runtime
680
+ .processConversation(agent, conversationId)
681
+ .pipe(Effect.provide(bookToolLayerFor(approvalTools))),
682
+ (flatIndex) =>
683
+ runtime.submit(agent, { question: `chaos ${flatIndex}` }, submitOptionsFor(flatIndex)),
684
+ );
685
+ }
686
+ case "durable-steps": {
687
+ const agent = Agent.withModel(itineraryDefinition, model);
688
+ const toolLayer = itineraryTools.toLayer({
689
+ itinerary: ({ ref: called }) =>
690
+ Effect.gen(function* () {
691
+ const step = yield* DurableStep;
692
+ const flight = yield* step.do(
693
+ "reserve-flight",
694
+ Schema.String,
695
+ desk.record(flightValue(called)).pipe(Effect.as(flightValue(called))),
696
+ );
697
+ const lodging = yield* step.do(
698
+ "reserve-lodging",
699
+ Schema.String,
700
+ desk.record(lodgingValue(called)).pipe(Effect.as(lodgingValue(called))),
701
+ );
702
+ return { state: `${flight}+${lodging}` };
703
+ }),
704
+ });
705
+ return plainLaneFixture(
706
+ true,
707
+ runtime.processConversation(agent, conversationId).pipe(Effect.provide(toolLayer)),
708
+ (flatIndex) =>
709
+ runtime.submit(agent, { question: `chaos ${flatIndex}` }, submitOptionsFor(flatIndex)),
710
+ );
711
+ }
712
+ case "delegation": {
713
+ const parentBinding = Agent.withModel(coordinatorDefinition, model);
714
+ const childModel = promptScriptedModel(`chaos-child-${laneIndex}`, () =>
715
+ finalParts('{"answer":"child"}'),
716
+ );
717
+ const childBinding = Agent.withModel(childDefinition, childModel);
718
+ const delegationLayer = SubagentRuntime.layer(chaosDelegation, childBinding, {
719
+ mapChildFailure: (failure) => ChaosDelegationFailed.make({ childErrorTag: failure._tag }),
720
+ durable: { targetDigests: childDigestStrings(laneIndex) },
721
+ }).pipe(Layer.provide(delegationSupport));
722
+ const parentResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
723
+ parentBinding,
724
+ digests,
725
+ ).pipe(Effect.provide(delegationLayer));
726
+ const childResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
727
+ childBinding,
728
+ childLaneDigests(laneIndex),
729
+ );
730
+ const resolver = AgentBindingResolver.fromBindings([parentResolved, childResolved]);
731
+ const driveResolved = (conversation: ConversationId) =>
732
+ runtime
733
+ .processConversationResolved(conversation)
734
+ .pipe(Effect.provideService(AgentBindingResolver, resolver));
735
+ const fixture: LaneFixture = {
736
+ index: laneIndex,
737
+ kind,
738
+ conversationId,
739
+ ref,
740
+ deskInPlay: false,
741
+ submissionIndexes,
742
+ submitOne: (flatIndex) =>
743
+ runtime.submit(
744
+ { definition: { id: coordinatorDefinition.id, input: coordinatorDefinition.input } },
745
+ { mission: `chaos ${flatIndex}` },
746
+ submitOptionsFor(flatIndex),
747
+ ),
748
+ drives: (firstReceipt) => {
749
+ const drives: Array<Effect.Effect<ReadonlyArray<Settlement>, unknown>> = [
750
+ driveResolved(conversationId),
751
+ ];
752
+ if (firstReceipt !== undefined) {
753
+ drives.push(
754
+ driveResolved(
755
+ childConversationIdFor(
756
+ firstReceipt.submissionId,
757
+ decodeToolCallId(DELEGATE_CALL_ID),
758
+ ),
759
+ ),
760
+ );
761
+ }
762
+ return drives;
763
+ },
764
+ childConversationOf: (firstReceipt) =>
765
+ childConversationIdFor(firstReceipt.submissionId, decodeToolCallId(DELEGATE_CALL_ID)),
766
+ };
767
+ return fixture;
768
+ }
769
+ }
770
+ });
771
+
772
+ /** Stable per-call index into an injection list (identical across resolution passes). */
773
+ const injectionIndex = (submissionFlatIndex: number, callId: string, length: number): number => {
774
+ let hash = submissionFlatIndex + 1;
775
+ for (const char of callId) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) | 0;
776
+ return ((hash % length) + length) % length;
777
+ };
778
+
779
+ const resolutionFor = (
780
+ kind: ChaosResolutionKind,
781
+ toolName: string,
782
+ ref: string,
783
+ produced: ReadonlySet<string>,
784
+ ): UnknownResolution => {
785
+ switch (kind) {
786
+ case "abort-submission":
787
+ return ResolutionAbortSubmission.make();
788
+ case "completed-from-supplier": {
789
+ if (toolName === "book" && produced.has(bookConfirmation(ref))) {
790
+ return ResolutionCompletedWithResult.make({
791
+ result: { confirmation: bookConfirmation(ref) },
792
+ isFailure: false,
793
+ });
794
+ }
795
+ if (
796
+ toolName === "itinerary" &&
797
+ produced.has(flightValue(ref)) &&
798
+ produced.has(lodgingValue(ref))
799
+ ) {
800
+ return ResolutionCompletedWithResult.make({
801
+ result: { state: `${flightValue(ref)}+${lodgingValue(ref)}` },
802
+ isFailure: false,
803
+ });
804
+ }
805
+ // The desk never produced a value for this call — resolving "completed" would fabricate.
806
+ return ResolutionNeverHappened.make();
807
+ }
808
+ case "never-happened":
809
+ return ResolutionNeverHappened.make();
810
+ }
811
+ };
812
+
813
+ /** Drive one DUR-017 pass: resolve Unknown Outcomes and pending approvals from the plan. */
814
+ const resolutionPass = Effect.fn("Chaos.resolutionPass")(function* (
815
+ plan: ChaosPlan,
816
+ states: ReadonlyArray<SubmissionState>,
817
+ desk: ChaosDesk,
818
+ ) {
819
+ const runtime = yield* DurableAgentRuntime;
820
+ const ledger = yield* SubmissionLedger;
821
+ const produced = yield* desk.produced;
822
+ const nonterminal = yield* tolerateTyped(Stream.runCollect(ledger.scanNonterminal));
823
+ if (Option.isNone(nonterminal)) return;
824
+ const byId = new Map<SubmissionId, SubmissionState>();
825
+ for (const state of states) {
826
+ if (state.receipt !== undefined) byId.set(state.receipt.submissionId, state);
827
+ }
828
+ for (const row of nonterminal.value) {
829
+ if (row.state !== "unknown" && row.state !== "suspended") continue;
830
+ const state = byId.get(row.submissionId);
831
+ const explanation = yield* tolerateTyped(runtime.explain(row.submissionId));
832
+ if (Option.isNone(explanation)) continue;
833
+ const flatIndex = state?.flatIndex ?? 0;
834
+ const ref = state?.lane.ref ?? "ref-child";
835
+ if (row.state === "unknown") {
836
+ for (const call of explanation.value.evidence.unknownCalls) {
837
+ if (call.resolved) continue;
838
+ const kind =
839
+ plan.resolutionInjections.length === 0
840
+ ? "never-happened"
841
+ : plan.resolutionInjections[
842
+ injectionIndex(flatIndex, call.toolCallId, plan.resolutionInjections.length)
843
+ ]!;
844
+ yield* tolerateTyped(
845
+ runtime.resolveUnknown(
846
+ UnknownResolutionCommand.make({
847
+ submissionId: row.submissionId,
848
+ toolCallId: call.toolCallId,
849
+ author: "chaos-runner",
850
+ reason: `chaos plan ${plan.seed} resolution (${kind})`,
851
+ resolution: resolutionFor(kind, call.toolName, ref, produced),
852
+ }),
853
+ ),
854
+ );
855
+ }
856
+ } else {
857
+ for (const pending of explanation.value.evidence.approvalsPending) {
858
+ const decision =
859
+ plan.approvalDecisions.length === 0
860
+ ? "approved"
861
+ : plan.approvalDecisions[
862
+ injectionIndex(flatIndex, pending.toolCallId, plan.approvalDecisions.length)
863
+ ]!;
864
+ yield* tolerateTyped(
865
+ runtime.resolveApproval(
866
+ ApprovalDecisionCommand.make({
867
+ submissionId: row.submissionId,
868
+ toolCallId: pending.toolCallId,
869
+ decision,
870
+ resolver: "chaos-runner",
871
+ reason: `chaos plan ${plan.seed} approval (${decision})`,
872
+ }),
873
+ ),
874
+ );
875
+ }
876
+ }
877
+ }
878
+ });
879
+
880
+ const submissionIdsNamedBy = (
881
+ records: ReadonlyArray<CanonicalRecordEnvelope>,
882
+ ): ReadonlySet<SubmissionId> => {
883
+ const named = new Set<SubmissionId>();
884
+ for (const envelope of records) {
885
+ const payload = envelope.record.payload;
886
+ if (
887
+ payload._tag === "UserInputRecorded" ||
888
+ payload._tag === "SubmissionSettled" ||
889
+ payload._tag === "AbortRequested"
890
+ ) {
891
+ named.add(payload.submissionId);
892
+ }
893
+ }
894
+ return named;
895
+ };
896
+
897
+ /**
898
+ * The final non-fabrication sweep (durability §10): every canonical Tool success recorded on a
899
+ * desk-backed lane must be a value the desk actually produced.
900
+ */
901
+ const BookResult = Schema.Struct({ confirmation: Schema.String });
902
+ const ItineraryResult = Schema.Struct({ state: Schema.String });
903
+ const decodeBookResult = Schema.decodeUnknownOption(BookResult);
904
+ const decodeItineraryResult = Schema.decodeUnknownOption(ItineraryResult);
905
+ const decodeStepOutput = Schema.decodeUnknownOption(Schema.String);
906
+
907
+ const assertNoFabrication = (
908
+ plan: ChaosPlan,
909
+ records: ReadonlyArray<CanonicalRecordEnvelope>,
910
+ produced: ReadonlySet<string>,
911
+ ): Effect.Effect<void, ChaosConvergenceFailure> => {
912
+ const fabricated: Array<string> = [];
913
+ const requireProduced = (value: string, label: string): void => {
914
+ if (!produced.has(value)) fabricated.push(`${label} "${value}"`);
915
+ };
916
+ for (const envelope of records) {
917
+ const payload = envelope.record.payload;
918
+ if (payload._tag === "ToolCallSettled" && !payload.isFailure) {
919
+ if (payload.toolName === "book") {
920
+ const result = decodeBookResult(payload.result);
921
+ if (Option.isSome(result)) requireProduced(result.value.confirmation, "book result");
922
+ }
923
+ if (payload.toolName === "itinerary") {
924
+ const result = decodeItineraryResult(payload.result);
925
+ if (Option.isSome(result)) {
926
+ for (const part of result.value.state.split("+")) {
927
+ requireProduced(part, "itinerary step result");
928
+ }
929
+ }
930
+ }
931
+ }
932
+ if (payload._tag === "ToolStepSettled") {
933
+ const output = decodeStepOutput(payload.output);
934
+ if (Option.isSome(output)) requireProduced(output.value, "step output");
935
+ }
936
+ }
937
+ return fabricated.length === 0
938
+ ? Effect.void
939
+ : Effect.fail(
940
+ ChaosConvergenceFailure.make({
941
+ seed: plan.seed,
942
+ message: `fabricated Tool results absent from the desk: ${fabricated.join(", ")}`,
943
+ }),
944
+ );
945
+ };
946
+
947
+ /**
948
+ * Execute one chaos plan against whatever adapters the ambient Layer provides and end in the
949
+ * shared invariant claims. Deterministic: same plan + same adapters → same schedule.
950
+ */
951
+ export const runChaosPlan = Effect.fn("Chaos.runChaosPlan")(function* (
952
+ plan: ChaosPlan,
953
+ options?: ChaosRunOptions,
954
+ ) {
955
+ const runtime = yield* DurableAgentRuntime;
956
+ const ledger = yield* SubmissionLedger;
957
+ const store = yield* ConversationStore;
958
+ const config = yield* DurableRuntimeConfig;
959
+ const failpoints = yield* DurableRuntimeFailpointTestControl;
960
+ const random = mulberry32(plan.seed);
961
+ const desk = yield* makeChaosDesk;
962
+
963
+ // Lane fixtures: the FIRST spec of each lane fixes the lane's agent kind.
964
+ const laneKinds = new Map<number, ChaosScenarioKind>();
965
+ const laneSubmissions = new Map<number, Array<number>>();
966
+ plan.submissions.forEach((spec, flatIndex) => {
967
+ const lane = spec.lane % plan.lanes;
968
+ if (!laneKinds.has(lane)) laneKinds.set(lane, spec.kind);
969
+ const list = laneSubmissions.get(lane) ?? [];
970
+ list.push(flatIndex);
971
+ laneSubmissions.set(lane, list);
972
+ });
973
+ const lanes: Array<LaneFixture> = [];
974
+ for (const [lane, kind] of laneKinds) {
975
+ lanes.push(yield* makeLaneFixture(plan, lane, kind, laneSubmissions.get(lane) ?? [], desk));
976
+ }
977
+
978
+ const states: Array<SubmissionState> = plan.submissions.map((spec, flatIndex) => ({
979
+ flatIndex,
980
+ lane: lanes.find((fixture) => fixture.index === spec.lane % plan.lanes)!,
981
+ receipt: undefined,
982
+ }));
983
+ const appliedAborts = new Set<number>();
984
+
985
+ type ArmEntry =
986
+ | { readonly family: "coordinator"; readonly location: DurableRuntimeFailpointLocation }
987
+ | { readonly family: "adapter"; readonly location: string };
988
+ const armQueue: Array<ArmEntry> = [
989
+ ...plan.failpointArms.map((location): ArmEntry => ({ family: "coordinator", location })),
990
+ ...plan.adapterArms.map((location): ArmEntry => ({ family: "adapter", location })),
991
+ ];
992
+
993
+ const allSettled = Effect.gen(function* () {
994
+ if (states.some((state) => state.receipt === undefined)) return false;
995
+ const nonterminal = yield* tolerateTyped(Stream.runCollect(ledger.scanNonterminal));
996
+ return Option.isSome(nonterminal) && Array.from(nonterminal.value).length === 0;
997
+ });
998
+
999
+ const maxRounds = armQueue.length + states.length * 2 + 12;
1000
+ let rounds = 0;
1001
+ let converged = false;
1002
+ for (let round = 0; round < maxRounds; round++) {
1003
+ rounds = round + 1;
1004
+ const arm = armQueue[round];
1005
+ if (arm?.family === "coordinator") {
1006
+ const location = arm.location;
1007
+ yield* failpoints.setHandler((hit) =>
1008
+ hit === location
1009
+ ? Effect.fail(DurableRuntimeFailpointError.make({ location: hit }))
1010
+ : Effect.void,
1011
+ );
1012
+ } else if (arm?.family === "adapter" && options?.adapterFailpoints !== undefined) {
1013
+ yield* options.adapterFailpoints.arm(arm.location);
1014
+ }
1015
+
1016
+ // Admission chaos: pending submissions retry under the active arm until a Receipt lands;
1017
+ // the identical (conversation, principal, key) triple reattaches, never duplicates.
1018
+ for (const state of states) {
1019
+ if (state.receipt !== undefined) continue;
1020
+ const receipt = yield* tolerateTyped(state.lane.submitOne(state.flatIndex));
1021
+ if (Option.isSome(receipt)) state.receipt = receipt.value;
1022
+ }
1023
+
1024
+ // Drive every lane (and discovered child lanes) in seeded order under the active arm.
1025
+ const order = [...lanes].sort(() => random() - 0.5);
1026
+ for (const lane of order) {
1027
+ const firstFlat = lane.submissionIndexes[0];
1028
+ const firstReceipt = firstFlat === undefined ? undefined : states[firstFlat]?.receipt;
1029
+ for (const drive of lane.drives(firstReceipt)) {
1030
+ yield* tolerateTyped(drive);
1031
+ }
1032
+ }
1033
+
1034
+ // Abort injections fire once, while arms may still be active (abort:after-intent etc.).
1035
+ if (round >= 1) {
1036
+ for (const rawIndex of plan.abortInjections) {
1037
+ const index = rawIndex % states.length;
1038
+ if (appliedAborts.has(index)) continue;
1039
+ const receipt = states[index]?.receipt;
1040
+ if (receipt === undefined) continue;
1041
+ appliedAborts.add(index);
1042
+ yield* tolerateTyped(
1043
+ runtime.abort(
1044
+ AbortCommand.make({
1045
+ submissionId: receipt.submissionId,
1046
+ author: "chaos-runner",
1047
+ reason: `chaos plan ${plan.seed} abort injection`,
1048
+ }),
1049
+ ),
1050
+ );
1051
+ }
1052
+ }
1053
+
1054
+ // First resolution pass runs under the arm so resolve:* locations can fire.
1055
+ yield* resolutionPass(plan, states, desk);
1056
+
1057
+ yield* failpoints.clear;
1058
+ if (options?.adapterFailpoints !== undefined) yield* options.adapterFailpoints.clear;
1059
+
1060
+ yield* tolerateTyped(runtime.runRecovery);
1061
+ // Second, unarmed pass guarantees forward progress for newly marked Unknown lanes.
1062
+ yield* resolutionPass(plan, states, desk);
1063
+
1064
+ if (yield* allSettled) {
1065
+ converged = true;
1066
+ break;
1067
+ }
1068
+ if (options?.betweenRounds !== undefined) yield* options.betweenRounds;
1069
+ }
1070
+
1071
+ if (!converged) {
1072
+ const nonterminal = yield* tolerateTyped(Stream.runCollect(ledger.scanNonterminal));
1073
+ const detail = Option.isSome(nonterminal)
1074
+ ? Array.from(nonterminal.value)
1075
+ .map((row: SubmissionSnapshot) => `${row.submissionId}(${row.state})`)
1076
+ .join(", ")
1077
+ : "ledger scan failed";
1078
+ return yield* ChaosConvergenceFailure.make({
1079
+ seed: plan.seed,
1080
+ message: `plan did not converge within ${maxRounds} rounds; nonterminal: [${detail}]; pending receipts: ${states.filter((state) => state.receipt === undefined).length}`,
1081
+ });
1082
+ }
1083
+
1084
+ // Final claims: the shared invariant checker per touched Conversation, in convergence mode,
1085
+ // with the full digest chain (single known producer), plus the desk non-fabrication sweep.
1086
+ const produced = yield* desk.produced;
1087
+ const laneReports: Array<ChaosLaneReport> = [];
1088
+ const verifyConversation = Effect.fn("Chaos.verifyConversation")(function* (
1089
+ conversationId: ConversationId,
1090
+ kind: ChaosScenarioKind,
1091
+ deskInPlay: boolean,
1092
+ ) {
1093
+ const exported = yield* store.export(ConversationExportRequest.make({ conversationId })).pipe(
1094
+ Effect.mapError((error) =>
1095
+ ChaosConvergenceFailure.make({
1096
+ seed: plan.seed,
1097
+ message: `export of ${conversationId} failed: ${String(error)}`,
1098
+ }),
1099
+ ),
1100
+ );
1101
+ const rows: Array<SubmissionSnapshot> = [];
1102
+ for (const submissionId of submissionIdsNamedBy(exported.records)) {
1103
+ const found = yield* ledger.lookup(SubmissionLookupById.make({ submissionId })).pipe(
1104
+ Effect.mapError((error) =>
1105
+ ChaosConvergenceFailure.make({
1106
+ seed: plan.seed,
1107
+ message: `lookup of ${submissionId} failed: ${String(error)}`,
1108
+ }),
1109
+ ),
1110
+ );
1111
+ if (Option.isSome(found)) rows.push(found.value);
1112
+ }
1113
+ const batchProducers = new Map<BatchId, ProducerId>(
1114
+ exported.records.map((envelope) => [envelope.batchId, config.producerId]),
1115
+ );
1116
+ const report = yield* verifyConversationInvariants({
1117
+ export: exported,
1118
+ submissions: rows,
1119
+ batchProducers,
1120
+ requireAllSettled: true,
1121
+ });
1122
+ if (!report.ok) {
1123
+ const failed = report.checks
1124
+ .filter((check) => check.status === "failed")
1125
+ .map((check) => `${check.name}: ${check.detail ?? "failed"}`)
1126
+ .join("; ");
1127
+ return yield* ChaosConvergenceFailure.make({
1128
+ seed: plan.seed,
1129
+ message: `invariants failed for ${conversationId} (${kind}): ${failed}`,
1130
+ });
1131
+ }
1132
+ if (deskInPlay) {
1133
+ yield* assertNoFabrication(plan, exported.records, produced);
1134
+ }
1135
+ laneReports.push(
1136
+ ChaosLaneReport.make({
1137
+ conversationId,
1138
+ kind,
1139
+ submissionCount: rows.length,
1140
+ verified: report.ok,
1141
+ }),
1142
+ );
1143
+ });
1144
+
1145
+ for (const lane of lanes) {
1146
+ yield* verifyConversation(lane.conversationId, lane.kind, lane.deskInPlay);
1147
+ // Delegation lanes: verify every materialized child Conversation too.
1148
+ for (const flatIndex of lane.submissionIndexes) {
1149
+ const receipt = states[flatIndex]?.receipt;
1150
+ if (receipt === undefined) continue;
1151
+ const child = lane.childConversationOf(receipt);
1152
+ if (child === undefined) continue;
1153
+ const childExport = yield* Effect.exit(
1154
+ store.export(ConversationExportRequest.make({ conversationId: child })),
1155
+ );
1156
+ if (Exit.isSuccess(childExport) && childExport.value.records.length > 0) {
1157
+ yield* verifyConversation(child, "plain", false);
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ const obligations = yield* runtime
1163
+ .scanObligations(ObligationThresholds.make({ agingSeconds: 0, overdueSeconds: 0 }))
1164
+ .pipe(
1165
+ Effect.mapError((error) =>
1166
+ ChaosConvergenceFailure.make({
1167
+ seed: plan.seed,
1168
+ message: `scanObligations failed: ${String(error)}`,
1169
+ }),
1170
+ ),
1171
+ );
1172
+ if (obligations.entries.length > 0) {
1173
+ return yield* ChaosConvergenceFailure.make({
1174
+ seed: plan.seed,
1175
+ message: `open obligations after convergence: ${obligations.entries
1176
+ .map((entry) => `${entry.submissionId}(${entry.blockedOn})`)
1177
+ .join(", ")}`,
1178
+ });
1179
+ }
1180
+
1181
+ return ChaosPlanReport.make({
1182
+ seed: plan.seed,
1183
+ rounds,
1184
+ lanes: laneReports,
1185
+ openObligations: obligations.entries.length,
1186
+ });
1187
+ });