@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/dist/index.d.mts +3331 -0
- package/dist/index.mjs +4205 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
- package/src/certification.ts +979 -0
- package/src/chaos.ts +1187 -0
- package/src/fixtures/docs-researcher/definition.ts +342 -0
- package/src/fixtures/docs-researcher/harness.ts +316 -0
- package/src/fixtures/docs-researcher/index.ts +34 -0
- package/src/fixtures/docs-researcher/mcp.ts +126 -0
- package/src/fixtures/travel-planner/definition.ts +167 -0
- package/src/fixtures/travel-planner/deterministic-layers.ts +431 -0
- package/src/fixtures/travel-planner/index.ts +11 -0
- package/src/fixtures/travel-planner/phase2.ts +94 -0
- package/src/fixtures/travel-planner/phase3.ts +159 -0
- package/src/fixtures/travel-planner/phase4.ts +272 -0
- package/src/fixtures/travel-planner/phase5.ts +476 -0
- package/src/fixtures/travel-planner/phase6.ts +923 -0
- package/src/fixtures/travel-planner/phase7.ts +112 -0
- package/src/fixtures/travel-planner/scenarios.ts +85 -0
- package/src/fixtures/travel-planner/subagents-durable.ts +391 -0
- package/src/fixtures/travel-planner/subagents.ts +525 -0
- package/src/index.ts +5 -0
- package/src/scripted-model.ts +303 -0
|
@@ -0,0 +1,979 @@
|
|
|
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
|
+
ToolCallId,
|
|
14
|
+
TurnId,
|
|
15
|
+
type AgentId,
|
|
16
|
+
type SubmissionId,
|
|
17
|
+
} from "@effect-agent/core";
|
|
18
|
+
import { DurableStep, DurableStepError } from "@effect-agent/engine";
|
|
19
|
+
import {
|
|
20
|
+
AgentBindingResolver,
|
|
21
|
+
ApprovalDecisionCommand,
|
|
22
|
+
CertificationCaseResult,
|
|
23
|
+
CertificationReport,
|
|
24
|
+
CertificationSweepResult,
|
|
25
|
+
CertificationTierThreeReport,
|
|
26
|
+
CertifiedAdapterIdentity,
|
|
27
|
+
ConversationExportRequest,
|
|
28
|
+
ConversationStore,
|
|
29
|
+
DefinitionDigests,
|
|
30
|
+
DeploymentId,
|
|
31
|
+
Digest,
|
|
32
|
+
DurableAgentRuntime,
|
|
33
|
+
DurableRuntimeConfig,
|
|
34
|
+
DurableRuntimeFailpoint,
|
|
35
|
+
DurableRuntimeFailpointError,
|
|
36
|
+
DurableRuntimeFailpointLocation,
|
|
37
|
+
DurableRuntimeFailpointTestControl,
|
|
38
|
+
DurableWorkerBinding,
|
|
39
|
+
IdempotencyKey,
|
|
40
|
+
LoadCheckpointRequest,
|
|
41
|
+
Principal,
|
|
42
|
+
ProducerId,
|
|
43
|
+
ResolutionSafeToRetry,
|
|
44
|
+
SubmissionLedger,
|
|
45
|
+
SubmissionLookupById,
|
|
46
|
+
ToolReconciler,
|
|
47
|
+
UnknownResolutionCommand,
|
|
48
|
+
WakeScheduler,
|
|
49
|
+
DEFAULT_OWNERSHIP_LEASE_DURATION,
|
|
50
|
+
certifyPorts,
|
|
51
|
+
childConversationIdFor,
|
|
52
|
+
verifyConversationInvariants,
|
|
53
|
+
type BatchId,
|
|
54
|
+
type CertificationScenario,
|
|
55
|
+
type DurableSubmitFailure,
|
|
56
|
+
type DurableSubmitOptions,
|
|
57
|
+
type Receipt,
|
|
58
|
+
type ResolvedBinding,
|
|
59
|
+
type SubmissionSnapshot,
|
|
60
|
+
} from "@effect-agent/session";
|
|
61
|
+
import {
|
|
62
|
+
Cause,
|
|
63
|
+
Clock,
|
|
64
|
+
Crypto,
|
|
65
|
+
DateTime,
|
|
66
|
+
Duration,
|
|
67
|
+
Effect,
|
|
68
|
+
Exit,
|
|
69
|
+
Layer,
|
|
70
|
+
Option,
|
|
71
|
+
Ref,
|
|
72
|
+
Schema,
|
|
73
|
+
Stream,
|
|
74
|
+
} from "effect";
|
|
75
|
+
import { TestClock } from "effect/testing";
|
|
76
|
+
import { LanguageModel, Model, Tool, Toolkit, type Response } from "effect/unstable/ai";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* P7 WP2 — `certifyDurableAdapters` (plan §1): the one certification entry point a durable
|
|
80
|
+
* adapter pair runs to earn a Schema-encoded certificate.
|
|
81
|
+
*
|
|
82
|
+
* - **Tier 1 — port contract**: the two shared conformance case arrays, verbatim, through
|
|
83
|
+
* `certifyPorts` (TEST-004/STORE-010).
|
|
84
|
+
* - **Tier 2 — coordinator protocol + failpoint convergence**: the durable coordinator is
|
|
85
|
+
* assembled over the CANDIDATE Layer pair with a scripted deterministic model; every
|
|
86
|
+
* `DurableRuntimeFailpointLocation` is armed one-shot across the six scenario shapes
|
|
87
|
+
* (plain / uncertain-tool / durable-steps / approval / join / delegation). After the injected
|
|
88
|
+
* fault the runner asserts the state stays CLASSIFIABLE (recovery + the public unblocking
|
|
89
|
+
* operations `resolveUnknown`/`resolveApproval` are the only levers used) and that the
|
|
90
|
+
* re-drive CONVERGES to `verifyConversationInvariants` with `requireAllSettled` — including a
|
|
91
|
+
* fully discharged digest-chain check, because the runner captures per-batch producer
|
|
92
|
+
* identity at append time.
|
|
93
|
+
* - **Tier 3 — real loss lever**: recorded honestly. A durable adapter either supplies a
|
|
94
|
+
* `CertificationCrashLever` executed in this run, cites its committed real-loss evidence
|
|
95
|
+
* (process-kill / eviction suites), or the certificate says `not-exercised`. A non-durable
|
|
96
|
+
* reference adapter records `not-applicable`.
|
|
97
|
+
*
|
|
98
|
+
* The runner is adapter-neutral and platform-neutral: it imports nothing Node-only, so the
|
|
99
|
+
* same entry point runs under `@effect/vitest` on Node and inside workerd (storage-cloudflare's
|
|
100
|
+
* in-workerd runner). It must run under a TestClock (Tier 1 drives lease expiry through
|
|
101
|
+
* virtual time), and requires only `Crypto.Crypto` from the environment.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Options
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A real-loss lever supplied by an adapter that wants Tier 3 exercised IN this certification
|
|
110
|
+
* run (kill/evict/reopen around a designated row subset). Rows report with
|
|
111
|
+
* `suite: "real-loss"`. Failures must be captured per-row — the lever's error channel is
|
|
112
|
+
* `never` so a certificate is always produced.
|
|
113
|
+
*/
|
|
114
|
+
export type CertificationCrashLever = Effect.Effect<ReadonlyArray<CertificationCaseResult>>;
|
|
115
|
+
|
|
116
|
+
export interface CertifyDurableAdaptersOptions<LedgerE = never, StoreE = never> {
|
|
117
|
+
/** Adapter pair identity named by the certificate (durability is read from the ledger). */
|
|
118
|
+
readonly adapter: {
|
|
119
|
+
readonly name: string;
|
|
120
|
+
readonly version?: string | undefined;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The candidate Layer pair. When both ports must share one connection root (the ADR-0011
|
|
124
|
+
* "same file" rule), pass the SAME combined Layer instance for both fields — Layer
|
|
125
|
+
* memoization builds it once. A candidate may require `Crypto.Crypto` (the memory reference
|
|
126
|
+
* does); the certification's own environment supplies nothing else.
|
|
127
|
+
*/
|
|
128
|
+
readonly submissionLedger: Layer.Layer<SubmissionLedger, LedgerE, Crypto.Crypto>;
|
|
129
|
+
readonly conversationStore: Layer.Layer<ConversationStore, StoreE, Crypto.Crypto>;
|
|
130
|
+
/** Defaults to `WakeScheduler.layerNoop`; the runner re-drives lanes explicitly. */
|
|
131
|
+
readonly wakeScheduler?: Layer.Layer<WakeScheduler> | undefined;
|
|
132
|
+
/** Executes Tier 3 in this run; takes precedence over `tierThreeEvidence`. */
|
|
133
|
+
readonly crashLever?: CertificationCrashLever | undefined;
|
|
134
|
+
/** Repository-relative citations of committed real-loss suites (Tier 3 `recorded-evidence`). */
|
|
135
|
+
readonly tierThreeEvidence?: ReadonlyArray<string> | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* The candidate ledger's configured ownership lease (defaults to the D5 default, 30s).
|
|
138
|
+
* Every Tier-2 re-drive round advances the TestClock past this lease before reclaiming:
|
|
139
|
+
* a live lease may block ALL new claims (the SQL adapters do; the memory reference also
|
|
140
|
+
* allows same-producer reclaim), so the adapter-NEUTRAL recovery lever after a mid-Attempt
|
|
141
|
+
* fault is lease expiry — exactly the documented D5 liveness mechanism, driven through
|
|
142
|
+
* virtual time.
|
|
143
|
+
*/
|
|
144
|
+
readonly ownershipLeaseDuration?: Duration.Duration | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The six Tier-2 scenario shapes in sweep order. */
|
|
148
|
+
export const CERTIFICATION_SCENARIOS: ReadonlyArray<CertificationScenario> = [
|
|
149
|
+
"plain",
|
|
150
|
+
"uncertain-tool",
|
|
151
|
+
"durable-steps",
|
|
152
|
+
"approval",
|
|
153
|
+
"join",
|
|
154
|
+
"delegation",
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Coordinator failpoint locations that none of the six scenario shapes can reach, recorded
|
|
159
|
+
* honestly instead of silently claimed: all three sit on operator/abort paths the shapes do
|
|
160
|
+
* not take. They are pinned in-process by the P5/S2 suites
|
|
161
|
+
* (`packages/testing/test/durable-tools.test.ts` "resolveUnknown is idempotent across the
|
|
162
|
+
* intent failpoint", `durable-runtime.test.ts` abort rows,
|
|
163
|
+
* `durable-subagents.test.ts` abort propagation) and by the process-kill/eviction crash
|
|
164
|
+
* matrices. Runner tests assert the observed never-fired set equals EXACTLY this list, so a
|
|
165
|
+
* protocol change that silently stops exercising a location fails the certification.
|
|
166
|
+
*/
|
|
167
|
+
export const TIER2_UNREACHED_LOCATIONS: ReadonlyArray<DurableRuntimeFailpointLocation> = [
|
|
168
|
+
"abort:after-intent",
|
|
169
|
+
"resolve:after-intent",
|
|
170
|
+
"subagent:after-child-abort-intent",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
/** Locations of `tier2` rows whose armed fault never fired in ANY scenario, sorted. */
|
|
174
|
+
export const tier2NeverFiredLocations = (
|
|
175
|
+
tier2: ReadonlyArray<CertificationSweepResult>,
|
|
176
|
+
): ReadonlyArray<DurableRuntimeFailpointLocation> => {
|
|
177
|
+
const fired = new Set<DurableRuntimeFailpointLocation>();
|
|
178
|
+
for (const row of tier2) {
|
|
179
|
+
if (row.failpointFired) fired.add(row.location);
|
|
180
|
+
}
|
|
181
|
+
return DurableRuntimeFailpointLocation.literals.filter((location) => !fired.has(location)).sort();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Deterministic fixtures (scripted prompt-shape models, agents, delegation)
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
const SHA_A = Schema.decodeSync(Digest)("a".repeat(64));
|
|
189
|
+
const DIGESTS = DefinitionDigests.make({ agent: SHA_A, model: SHA_A, tools: SHA_A });
|
|
190
|
+
const CHILD_DIGEST_STRINGS = {
|
|
191
|
+
agent: "b".repeat(64),
|
|
192
|
+
model: "c".repeat(64),
|
|
193
|
+
tools: "d".repeat(64),
|
|
194
|
+
} as const;
|
|
195
|
+
const CHILD_DIGESTS = DefinitionDigests.make({
|
|
196
|
+
agent: Schema.decodeSync(Digest)(CHILD_DIGEST_STRINGS.agent),
|
|
197
|
+
model: Schema.decodeSync(Digest)(CHILD_DIGEST_STRINGS.model),
|
|
198
|
+
tools: Schema.decodeSync(Digest)(CHILD_DIGEST_STRINGS.tools),
|
|
199
|
+
});
|
|
200
|
+
const PRINCIPAL = Schema.decodeSync(Principal)("principal-certification");
|
|
201
|
+
const decodeConversationId = Schema.decodeSync(ConversationId);
|
|
202
|
+
const decodeIdempotencyKey = Schema.decodeSync(IdempotencyKey);
|
|
203
|
+
const decodeToolCallId = Schema.decodeSync(ToolCallId);
|
|
204
|
+
|
|
205
|
+
const usage = { inputTokens: {}, outputTokens: {} };
|
|
206
|
+
|
|
207
|
+
const finalParts = (text: string): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
208
|
+
{ type: "text-start", id: "answer" },
|
|
209
|
+
{ type: "text-delta", id: "answer", delta: text },
|
|
210
|
+
{ type: "text-end", id: "answer" },
|
|
211
|
+
{ type: "finish", reason: "stop", usage },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const toolCallPart = (id: string, name: string, params: unknown): Response.StreamPartEncoded => ({
|
|
215
|
+
type: "tool-call",
|
|
216
|
+
id,
|
|
217
|
+
name,
|
|
218
|
+
params,
|
|
219
|
+
providerExecuted: false,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const toolTurn = (
|
|
223
|
+
...calls: ReadonlyArray<Response.StreamPartEncoded>
|
|
224
|
+
): ReadonlyArray<Response.StreamPartEncoded> => [
|
|
225
|
+
...calls,
|
|
226
|
+
{ type: "finish", reason: "tool-calls", usage },
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Stateless scripted model that decides by PROMPT SHAPE instead of call count: while the
|
|
231
|
+
* prompt carries no committed tool result the model declares `toolParts` (when given),
|
|
232
|
+
* otherwise it answers with the final text. Deciding on the canonical prompt keeps every cell
|
|
233
|
+
* deterministic regardless of where the injected fault fell — a re-invoked Turn re-declares
|
|
234
|
+
* the same batch and a resumed batch flows into the final answer, so every scenario always
|
|
235
|
+
* exercises its tool path and always converges.
|
|
236
|
+
*/
|
|
237
|
+
const promptShapeModel = (
|
|
238
|
+
name: string,
|
|
239
|
+
finalText: string,
|
|
240
|
+
toolParts?: ReadonlyArray<Response.StreamPartEncoded>,
|
|
241
|
+
) =>
|
|
242
|
+
Model.make(
|
|
243
|
+
"scripted",
|
|
244
|
+
name,
|
|
245
|
+
Layer.effect(
|
|
246
|
+
LanguageModel.LanguageModel,
|
|
247
|
+
LanguageModel.make({
|
|
248
|
+
generateText: () => Effect.succeed([]),
|
|
249
|
+
streamText: (request) => {
|
|
250
|
+
const hasToolResult = request.prompt.content.some((message) => message.role === "tool");
|
|
251
|
+
const parts =
|
|
252
|
+
toolParts === undefined || hasToolResult ? finalParts(finalText) : toolParts;
|
|
253
|
+
return Stream.fromIterable(parts);
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const policy = AgentPolicy.make({
|
|
260
|
+
maxTurns: 4,
|
|
261
|
+
maxToolCalls: 4,
|
|
262
|
+
maxDuration: "30 seconds",
|
|
263
|
+
toolConcurrency: 2,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const QuestionInput = Schema.Struct({ question: Schema.String });
|
|
267
|
+
const AnswerOutput = Schema.Struct({ answer: Schema.String });
|
|
268
|
+
|
|
269
|
+
/** plain / join: no tools — the pure Turn/submission/join seams. */
|
|
270
|
+
const plainDefinition = Agent.define("certify-plain", {
|
|
271
|
+
input: QuestionInput,
|
|
272
|
+
output: AnswerOutput,
|
|
273
|
+
instructions: "Answer as JSON.",
|
|
274
|
+
toolkit: Toolkit.empty,
|
|
275
|
+
policy,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/** uncertain-tool: unannotated → fail-closed `uncertain`, enters the prepared/settled protocol. */
|
|
279
|
+
const Book = Tool.make("book", {
|
|
280
|
+
parameters: Schema.Struct({ ref: Schema.String }),
|
|
281
|
+
success: Schema.Struct({ confirmation: Schema.String }),
|
|
282
|
+
});
|
|
283
|
+
const bookToolkit = Toolkit.make(Book);
|
|
284
|
+
const uncertainDefinition = Agent.define("certify-uncertain", {
|
|
285
|
+
input: QuestionInput,
|
|
286
|
+
output: AnswerOutput,
|
|
287
|
+
instructions: "Book it.",
|
|
288
|
+
toolkit: bookToolkit,
|
|
289
|
+
policy,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/** durable-steps: declaring `DurableStep` as a dependency is what makes the Tool durable. */
|
|
293
|
+
const Itinerary = Tool.make("itinerary", {
|
|
294
|
+
parameters: Schema.Struct({ ref: Schema.String }),
|
|
295
|
+
success: Schema.Struct({ state: Schema.String }),
|
|
296
|
+
failure: DurableStepError,
|
|
297
|
+
dependencies: [DurableStep],
|
|
298
|
+
});
|
|
299
|
+
const itineraryToolkit = Toolkit.make(Itinerary);
|
|
300
|
+
const stepsDefinition = Agent.define("certify-steps", {
|
|
301
|
+
input: QuestionInput,
|
|
302
|
+
output: AnswerOutput,
|
|
303
|
+
instructions: "Reserve the itinerary.",
|
|
304
|
+
toolkit: itineraryToolkit,
|
|
305
|
+
policy,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/** approval: fail-closed — no `DurableApprovalResolver` Layer, so undecided approvals suspend. */
|
|
309
|
+
const BookApproval = Tool.make("book", {
|
|
310
|
+
parameters: Schema.Struct({ ref: Schema.String }),
|
|
311
|
+
success: Schema.Struct({ confirmation: Schema.String }),
|
|
312
|
+
needsApproval: true,
|
|
313
|
+
});
|
|
314
|
+
const approvalToolkit = Toolkit.make(BookApproval);
|
|
315
|
+
const approvalDefinition = Agent.define("certify-approval", {
|
|
316
|
+
input: QuestionInput,
|
|
317
|
+
output: AnswerOutput,
|
|
318
|
+
instructions: "Book after approval.",
|
|
319
|
+
toolkit: approvalToolkit,
|
|
320
|
+
policy,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/** delegation: durable attached child plus an ordinary uncertain sibling in ONE batch. */
|
|
324
|
+
const childDefinition = Agent.define("certify-child", {
|
|
325
|
+
input: QuestionInput,
|
|
326
|
+
output: AnswerOutput,
|
|
327
|
+
instructions: "Answer as JSON.",
|
|
328
|
+
toolkit: Toolkit.empty,
|
|
329
|
+
policy: AgentPolicy.make({
|
|
330
|
+
maxTurns: 2,
|
|
331
|
+
maxToolCalls: 1,
|
|
332
|
+
maxDuration: "30 seconds",
|
|
333
|
+
toolConcurrency: 1,
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
class CertifyDelegationFailed extends Schema.TaggedErrorClass<CertifyDelegationFailed>()(
|
|
338
|
+
"CertifyDelegationFailed",
|
|
339
|
+
{ childErrorTag: Schema.String },
|
|
340
|
+
) {}
|
|
341
|
+
|
|
342
|
+
const researchDelegation = Subagent.define("delegate_research", {
|
|
343
|
+
description: "Research one bounded question and return findings.",
|
|
344
|
+
target: childDefinition,
|
|
345
|
+
parameters: Schema.Struct({ topic: Schema.String }),
|
|
346
|
+
success: Schema.Struct({ summary: Schema.String }),
|
|
347
|
+
failure: CertifyDelegationFailed,
|
|
348
|
+
prepareInput: ({ topic }) => Effect.succeed({ question: `research:${topic}` }),
|
|
349
|
+
projectResult: (output) => Effect.succeed({ summary: `finding:${output.answer}` }),
|
|
350
|
+
policy: SubagentPolicy.make({
|
|
351
|
+
maxChildren: 2,
|
|
352
|
+
maxConcurrency: 2,
|
|
353
|
+
maxTurns: 4,
|
|
354
|
+
maxToolCalls: 4,
|
|
355
|
+
maxDuration: "10 seconds",
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const Lookup = Tool.make("lookup", {
|
|
360
|
+
parameters: Schema.Struct({ key: Schema.String }),
|
|
361
|
+
success: Schema.Struct({ value: Schema.String }),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const coordinatorDefinition = Agent.define("certify-coordinator", {
|
|
365
|
+
input: Schema.Struct({ mission: Schema.String }),
|
|
366
|
+
output: Schema.Struct({ report: Schema.String }),
|
|
367
|
+
instructions: "Delegate and look up, then answer as JSON.",
|
|
368
|
+
toolkit: Toolkit.make(researchDelegation.tool, Lookup),
|
|
369
|
+
policy: AgentPolicy.make({
|
|
370
|
+
maxTurns: 4,
|
|
371
|
+
maxToolCalls: 3,
|
|
372
|
+
maxDuration: "30 seconds",
|
|
373
|
+
toolConcurrency: 2,
|
|
374
|
+
}),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const mapChildFailure = (failure: { readonly _tag: string }) =>
|
|
378
|
+
CertifyDelegationFailed.make({ childErrorTag: failure._tag });
|
|
379
|
+
|
|
380
|
+
const DELEGATE_CALL = decodeToolCallId("delegate-1");
|
|
381
|
+
|
|
382
|
+
/** Fixture-only identity source consumed by the delegation Layer's ephemeral capture. */
|
|
383
|
+
const identifiers = Layer.effect(
|
|
384
|
+
IdGenerator,
|
|
385
|
+
Effect.gen(function* () {
|
|
386
|
+
const counter = yield* Ref.make(0);
|
|
387
|
+
const next = <A>(decode: (value: string) => A, prefix: string) =>
|
|
388
|
+
Ref.getAndUpdate(counter, (value) => value + 1).pipe(
|
|
389
|
+
Effect.map((value) => decode(`${prefix}-${value}`)),
|
|
390
|
+
);
|
|
391
|
+
return {
|
|
392
|
+
nextConversationId: next(decodeConversationId, "certify-fixture-conversation"),
|
|
393
|
+
nextRunId: next(Schema.decodeSync(RunId), "certify-fixture-run"),
|
|
394
|
+
nextTurnId: next(Schema.decodeSync(TurnId), "certify-fixture-turn"),
|
|
395
|
+
};
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const delegationSupport = Layer.mergeAll(SubagentReservationsMemoryLive, identifiers);
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Tier 2 — scenario cells
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
interface CertificationCell {
|
|
406
|
+
readonly resolver: (typeof AgentBindingResolver)["Service"];
|
|
407
|
+
/** Idempotent submission batch — safe to replay verbatim after a submit-boundary fault. */
|
|
408
|
+
readonly submit: Effect.Effect<ReadonlyArray<Receipt>, DurableSubmitFailure, DurableAgentRuntime>;
|
|
409
|
+
/** All lanes of the cell in drive order, computed from the (possibly replayed) receipts. */
|
|
410
|
+
readonly lanes: (receipts: ReadonlyArray<Receipt>) => ReadonlyArray<ConversationId>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const submitOptionsFor = (slug: string, conversationId: ConversationId): DurableSubmitOptions => ({
|
|
414
|
+
conversationId,
|
|
415
|
+
principal: PRINCIPAL,
|
|
416
|
+
idempotencyKey: decodeIdempotencyKey(`certify-key-${slug}`),
|
|
417
|
+
definitions: DIGESTS,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/** One single-agent cell: one lane, one Submission, one registered exact-digest binding. */
|
|
421
|
+
const makeSingleAgentCell = (
|
|
422
|
+
definition: { readonly id: AgentId; readonly input: typeof QuestionInput },
|
|
423
|
+
resolved: ResolvedBinding,
|
|
424
|
+
slug: string,
|
|
425
|
+
): CertificationCell => {
|
|
426
|
+
const conversationId = decodeConversationId(`certify-${slug}`);
|
|
427
|
+
const submit = Effect.gen(function* () {
|
|
428
|
+
const runtime = yield* DurableAgentRuntime;
|
|
429
|
+
const receipt = yield* runtime.submit(
|
|
430
|
+
{ definition: { id: definition.id, input: definition.input } },
|
|
431
|
+
{ question: `certify ${slug}` },
|
|
432
|
+
submitOptionsFor(slug, conversationId),
|
|
433
|
+
);
|
|
434
|
+
return [receipt];
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
resolver: AgentBindingResolver.fromBindings([resolved]),
|
|
438
|
+
submit,
|
|
439
|
+
lanes: () => [conversationId],
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const makeCell = Effect.fn("Certification.makeCell")(function* (
|
|
444
|
+
scenario: CertificationScenario,
|
|
445
|
+
slug: string,
|
|
446
|
+
) {
|
|
447
|
+
switch (scenario) {
|
|
448
|
+
case "plain": {
|
|
449
|
+
const binding = Agent.withModel(
|
|
450
|
+
plainDefinition,
|
|
451
|
+
promptShapeModel("certify-plain", '{"answer":"done"}'),
|
|
452
|
+
);
|
|
453
|
+
const resolved = yield* DurableWorkerBinding.make(binding, DIGESTS);
|
|
454
|
+
return makeSingleAgentCell(plainDefinition, resolved, slug);
|
|
455
|
+
}
|
|
456
|
+
case "uncertain-tool": {
|
|
457
|
+
const binding = Agent.withModel(
|
|
458
|
+
uncertainDefinition,
|
|
459
|
+
promptShapeModel(
|
|
460
|
+
"certify-uncertain",
|
|
461
|
+
'{"answer":"booked"}',
|
|
462
|
+
toolTurn(toolCallPart("book-1", "book", { ref: `r-${slug}` })),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
const toolLayer = bookToolkit.toLayer({
|
|
466
|
+
book: ({ ref }) => Effect.succeed({ confirmation: `confirmed-${ref}` }),
|
|
467
|
+
});
|
|
468
|
+
const resolved = yield* DurableWorkerBinding.make(binding, DIGESTS).pipe(
|
|
469
|
+
Effect.provide(toolLayer),
|
|
470
|
+
);
|
|
471
|
+
return makeSingleAgentCell(uncertainDefinition, resolved, slug);
|
|
472
|
+
}
|
|
473
|
+
case "durable-steps": {
|
|
474
|
+
const binding = Agent.withModel(
|
|
475
|
+
stepsDefinition,
|
|
476
|
+
promptShapeModel(
|
|
477
|
+
"certify-steps",
|
|
478
|
+
'{"answer":"reserved"}',
|
|
479
|
+
toolTurn(toolCallPart("itinerary-1", "itinerary", { ref: `trip-${slug}` })),
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
const toolLayer = itineraryToolkit.toLayer({
|
|
483
|
+
itinerary: ({ ref }) =>
|
|
484
|
+
Effect.gen(function* () {
|
|
485
|
+
const step = yield* DurableStep;
|
|
486
|
+
const flight = yield* step.do(
|
|
487
|
+
"reserve-flight",
|
|
488
|
+
Schema.String,
|
|
489
|
+
Effect.succeed(`flight-${ref}`),
|
|
490
|
+
);
|
|
491
|
+
const lodging = yield* step.do(
|
|
492
|
+
"reserve-lodging",
|
|
493
|
+
Schema.String,
|
|
494
|
+
Effect.succeed(`lodging-${ref}`),
|
|
495
|
+
);
|
|
496
|
+
return { state: `${flight}+${lodging}` };
|
|
497
|
+
}),
|
|
498
|
+
});
|
|
499
|
+
const resolved = yield* DurableWorkerBinding.make(binding, DIGESTS).pipe(
|
|
500
|
+
Effect.provide(toolLayer),
|
|
501
|
+
);
|
|
502
|
+
return makeSingleAgentCell(stepsDefinition, resolved, slug);
|
|
503
|
+
}
|
|
504
|
+
case "approval": {
|
|
505
|
+
const binding = Agent.withModel(
|
|
506
|
+
approvalDefinition,
|
|
507
|
+
promptShapeModel(
|
|
508
|
+
"certify-approval",
|
|
509
|
+
'{"answer":"approved"}',
|
|
510
|
+
toolTurn(toolCallPart("book-1", "book", { ref: `r-${slug}` })),
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
const toolLayer = approvalToolkit.toLayer({
|
|
514
|
+
book: ({ ref }) => Effect.succeed({ confirmation: `confirmed-${ref}` }),
|
|
515
|
+
});
|
|
516
|
+
const resolved = yield* DurableWorkerBinding.make(binding, DIGESTS).pipe(
|
|
517
|
+
Effect.provide(toolLayer),
|
|
518
|
+
);
|
|
519
|
+
return makeSingleAgentCell(approvalDefinition, resolved, slug);
|
|
520
|
+
}
|
|
521
|
+
case "join": {
|
|
522
|
+
const binding = Agent.withModel(
|
|
523
|
+
plainDefinition,
|
|
524
|
+
promptShapeModel("certify-join", '{"answer":"host answer"}'),
|
|
525
|
+
);
|
|
526
|
+
const resolved = yield* DurableWorkerBinding.make(binding, DIGESTS);
|
|
527
|
+
const conversationId = decodeConversationId(`certify-${slug}`);
|
|
528
|
+
const submitOne = (key: string, question: string) =>
|
|
529
|
+
Effect.gen(function* () {
|
|
530
|
+
const runtime = yield* DurableAgentRuntime;
|
|
531
|
+
return yield* runtime.submit(
|
|
532
|
+
{ definition: { id: plainDefinition.id, input: plainDefinition.input } },
|
|
533
|
+
{ question },
|
|
534
|
+
{
|
|
535
|
+
conversationId,
|
|
536
|
+
principal: PRINCIPAL,
|
|
537
|
+
idempotencyKey: decodeIdempotencyKey(key),
|
|
538
|
+
definitions: DIGESTS,
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
const cell: CertificationCell = {
|
|
543
|
+
resolver: AgentBindingResolver.fromBindings([resolved]),
|
|
544
|
+
submit: Effect.gen(function* () {
|
|
545
|
+
const host = yield* submitOne(`certify-key-${slug}-host`, "host question");
|
|
546
|
+
const queued = yield* submitOne(`certify-key-${slug}-queued`, "queued question");
|
|
547
|
+
return [host, queued];
|
|
548
|
+
}),
|
|
549
|
+
lanes: () => [conversationId],
|
|
550
|
+
};
|
|
551
|
+
return cell;
|
|
552
|
+
}
|
|
553
|
+
case "delegation": {
|
|
554
|
+
const childBinding = Agent.withModel(
|
|
555
|
+
childDefinition,
|
|
556
|
+
promptShapeModel("certify-child", '{"answer":"child-answer"}'),
|
|
557
|
+
);
|
|
558
|
+
const parentBinding = Agent.withModel(
|
|
559
|
+
coordinatorDefinition,
|
|
560
|
+
promptShapeModel(
|
|
561
|
+
"certify-parent",
|
|
562
|
+
'{"report":"done"}',
|
|
563
|
+
toolTurn(
|
|
564
|
+
toolCallPart("delegate-1", "delegate_research", { topic: "paris" }),
|
|
565
|
+
toolCallPart("lookup-1", "lookup", { key: "hotels" }),
|
|
566
|
+
),
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
const delegationLayer = SubagentRuntime.layer(researchDelegation, childBinding, {
|
|
570
|
+
mapChildFailure,
|
|
571
|
+
durable: { targetDigests: CHILD_DIGEST_STRINGS },
|
|
572
|
+
}).pipe(Layer.provide(delegationSupport));
|
|
573
|
+
const lookupLayer = Toolkit.make(Lookup).toLayer({
|
|
574
|
+
lookup: ({ key }) => Effect.succeed({ value: `found-${key}` }),
|
|
575
|
+
});
|
|
576
|
+
const parentResolved = yield* DurableWorkerBinding.make(parentBinding, DIGESTS).pipe(
|
|
577
|
+
Effect.provide(Layer.mergeAll(delegationLayer, lookupLayer)),
|
|
578
|
+
);
|
|
579
|
+
const childResolved = yield* DurableWorkerBinding.make(childBinding, CHILD_DIGESTS);
|
|
580
|
+
const conversationId = decodeConversationId(`certify-${slug}`);
|
|
581
|
+
const cell: CertificationCell = {
|
|
582
|
+
resolver: AgentBindingResolver.fromBindings([parentResolved, childResolved]),
|
|
583
|
+
submit: Effect.gen(function* () {
|
|
584
|
+
const runtime = yield* DurableAgentRuntime;
|
|
585
|
+
const receipt = yield* runtime.submit(
|
|
586
|
+
{ definition: { id: coordinatorDefinition.id, input: coordinatorDefinition.input } },
|
|
587
|
+
{ mission: "plan" },
|
|
588
|
+
submitOptionsFor(slug, conversationId),
|
|
589
|
+
);
|
|
590
|
+
return [receipt];
|
|
591
|
+
}),
|
|
592
|
+
lanes: (receipts) => {
|
|
593
|
+
const parent = receipts.at(0);
|
|
594
|
+
return parent === undefined
|
|
595
|
+
? [conversationId]
|
|
596
|
+
: [conversationId, childConversationIdFor(parent.submissionId, DELEGATE_CALL)];
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
return cell;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
/** Maximum recovery/drive/unblock rounds before a cell is reported non-convergent. */
|
|
605
|
+
const MAX_REDRIVE_ROUNDS = 8;
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Verify one lane after convergence: canonical export + every lane Submission the ledger or
|
|
609
|
+
* the log names (the same collection rule as the admin `verify` member), fed to the shared
|
|
610
|
+
* invariant checker in convergence mode WITH the captured per-batch producer directory, so
|
|
611
|
+
* the digest chain is fully recomputed instead of skipped.
|
|
612
|
+
*/
|
|
613
|
+
const verifyLane = Effect.fn("Certification.verifyLane")(function* (
|
|
614
|
+
lane: ConversationId,
|
|
615
|
+
batchProducers: ReadonlyMap<BatchId, ProducerId>,
|
|
616
|
+
) {
|
|
617
|
+
const store = yield* ConversationStore;
|
|
618
|
+
const ledger = yield* SubmissionLedger;
|
|
619
|
+
const exported = yield* store.export(ConversationExportRequest.make({ conversationId: lane }));
|
|
620
|
+
const rows = new Map<SubmissionId, SubmissionSnapshot>();
|
|
621
|
+
const nonterminal = yield* Stream.runCollect(ledger.scanNonterminal);
|
|
622
|
+
for (const submission of nonterminal) {
|
|
623
|
+
if (submission.conversationId === lane) rows.set(submission.submissionId, submission);
|
|
624
|
+
}
|
|
625
|
+
const named = new Set<SubmissionId>();
|
|
626
|
+
for (const envelope of exported.records) {
|
|
627
|
+
const payload = envelope.record.payload;
|
|
628
|
+
if (
|
|
629
|
+
payload._tag === "UserInputRecorded" ||
|
|
630
|
+
payload._tag === "SubmissionSettled" ||
|
|
631
|
+
payload._tag === "AbortRequested"
|
|
632
|
+
) {
|
|
633
|
+
named.add(payload.submissionId);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
for (const submissionId of named) {
|
|
637
|
+
if (rows.has(submissionId)) continue;
|
|
638
|
+
const found = yield* ledger.lookup(SubmissionLookupById.make({ submissionId }));
|
|
639
|
+
if (Option.isSome(found) && found.value.conversationId === lane) {
|
|
640
|
+
rows.set(submissionId, found.value);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const checkpoint = yield* store.loadCheckpoint(
|
|
644
|
+
LoadCheckpointRequest.make({ conversationId: lane }),
|
|
645
|
+
);
|
|
646
|
+
return yield* verifyConversationInvariants({
|
|
647
|
+
export: exported,
|
|
648
|
+
submissions: [...rows.values()],
|
|
649
|
+
batchProducers,
|
|
650
|
+
checkpoint: Option.getOrUndefined(checkpoint),
|
|
651
|
+
requireAllSettled: true,
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const failureTagOf = <E>(cause: Cause.Cause<E>): string => {
|
|
656
|
+
const failure = Cause.findErrorOption(cause);
|
|
657
|
+
if (Option.isSome(failure)) {
|
|
658
|
+
const error: unknown = failure.value;
|
|
659
|
+
if (typeof error === "object" && error !== null && "_tag" in error) {
|
|
660
|
+
return String((error as { _tag: unknown })._tag);
|
|
661
|
+
}
|
|
662
|
+
return String(error).slice(0, 256);
|
|
663
|
+
}
|
|
664
|
+
return "defect";
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/** One Tier-2 sweep cell: arm `location` one-shot, drive `scenario`, converge, verify. */
|
|
668
|
+
const runSweepCell = Effect.fn("Certification.runSweepCell")(function* (
|
|
669
|
+
scenario: CertificationScenario,
|
|
670
|
+
location: DurableRuntimeFailpointLocation,
|
|
671
|
+
batchProducers: ReadonlyMap<BatchId, ProducerId>,
|
|
672
|
+
leaseAdvance: Duration.Duration,
|
|
673
|
+
) {
|
|
674
|
+
const runtime = yield* DurableAgentRuntime;
|
|
675
|
+
const ledger = yield* SubmissionLedger;
|
|
676
|
+
const control = yield* DurableRuntimeFailpointTestControl;
|
|
677
|
+
const slug = `${scenario}-${location.replaceAll(":", "-")}`;
|
|
678
|
+
|
|
679
|
+
const failed = (detail: string, fired: boolean): CertificationSweepResult =>
|
|
680
|
+
CertificationSweepResult.make({
|
|
681
|
+
scenario,
|
|
682
|
+
location,
|
|
683
|
+
failpointFired: fired,
|
|
684
|
+
status: "failed",
|
|
685
|
+
digestChainVerified: false,
|
|
686
|
+
detail: detail.slice(0, 4_096),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const cell = yield* makeCell(scenario, slug);
|
|
690
|
+
|
|
691
|
+
// One-shot arm: the fault fires at most once anywhere in the cell (initial drive OR a
|
|
692
|
+
// re-drive round's public unblocking operation), modelling one crash at this boundary.
|
|
693
|
+
const fired = yield* Ref.make(false);
|
|
694
|
+
yield* control.setHandler((hit) =>
|
|
695
|
+
hit !== location
|
|
696
|
+
? Effect.void
|
|
697
|
+
: Ref.getAndSet(fired, true).pipe(
|
|
698
|
+
Effect.flatMap((already) =>
|
|
699
|
+
already
|
|
700
|
+
? Effect.void
|
|
701
|
+
: Effect.fail(DurableRuntimeFailpointError.make({ location: hit })),
|
|
702
|
+
),
|
|
703
|
+
),
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
// Submissions are idempotent (DUR-001): one replay recovers a submit-boundary fault.
|
|
707
|
+
let receipts: ReadonlyArray<Receipt>;
|
|
708
|
+
const firstSubmit = yield* Effect.exit(cell.submit);
|
|
709
|
+
if (Exit.isSuccess(firstSubmit)) {
|
|
710
|
+
receipts = firstSubmit.value;
|
|
711
|
+
} else {
|
|
712
|
+
const secondSubmit = yield* Effect.exit(cell.submit);
|
|
713
|
+
if (Exit.isFailure(secondSubmit)) {
|
|
714
|
+
yield* control.clear;
|
|
715
|
+
return failed(
|
|
716
|
+
`submission replay did not recover: ${failureTagOf(secondSubmit.cause)}`,
|
|
717
|
+
yield* Ref.get(fired),
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
receipts = secondSubmit.value;
|
|
721
|
+
}
|
|
722
|
+
const lanes = cell.lanes(receipts);
|
|
723
|
+
|
|
724
|
+
const driveLane = (lane: ConversationId) =>
|
|
725
|
+
runtime
|
|
726
|
+
.processConversationResolved(lane)
|
|
727
|
+
.pipe(Effect.provideService(AgentBindingResolver, cell.resolver));
|
|
728
|
+
|
|
729
|
+
const allSettled = Effect.gen(function* () {
|
|
730
|
+
for (const receipt of receipts) {
|
|
731
|
+
const snapshot = yield* ledger.lookup(
|
|
732
|
+
SubmissionLookupById.make({ submissionId: receipt.submissionId }),
|
|
733
|
+
);
|
|
734
|
+
if (Option.isNone(snapshot) || snapshot.value.state !== "settled") return false;
|
|
735
|
+
}
|
|
736
|
+
return true;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// Re-drive to convergence using ONLY public operations: worker drives, recovery passes,
|
|
740
|
+
// and the authorized DUR-017/approval unblocking paths chosen from `explainConversation`.
|
|
741
|
+
let converged = false;
|
|
742
|
+
for (let round = 0; round < MAX_REDRIVE_ROUNDS && !converged; round++) {
|
|
743
|
+
// Expire any lease a faulted Attempt left behind (D5): virtual time is the
|
|
744
|
+
// adapter-neutral reclaim lever — a live lease may block every new claim.
|
|
745
|
+
yield* TestClock.adjust(leaseAdvance);
|
|
746
|
+
yield* Effect.exit(runtime.runRecovery);
|
|
747
|
+
for (const lane of lanes) {
|
|
748
|
+
yield* Effect.exit(driveLane(lane));
|
|
749
|
+
}
|
|
750
|
+
for (const lane of lanes) {
|
|
751
|
+
const explains = yield* Effect.exit(runtime.explainConversation(lane));
|
|
752
|
+
if (Exit.isFailure(explains)) continue;
|
|
753
|
+
for (const explanation of explains.value) {
|
|
754
|
+
for (const unknown of explanation.evidence.unknownCalls) {
|
|
755
|
+
if (unknown.resolved) continue;
|
|
756
|
+
yield* Effect.exit(
|
|
757
|
+
runtime.resolveUnknown(
|
|
758
|
+
UnknownResolutionCommand.make({
|
|
759
|
+
submissionId: explanation.submission.submissionId,
|
|
760
|
+
toolCallId: unknown.toolCallId,
|
|
761
|
+
author: "certification-runner",
|
|
762
|
+
reason: `re-drive after injected fault at ${location}`,
|
|
763
|
+
resolution: ResolutionSafeToRetry.make(),
|
|
764
|
+
}),
|
|
765
|
+
),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
for (const pending of explanation.evidence.approvalsPending) {
|
|
769
|
+
const decided = explanation.evidence.approvalDecisions.some(
|
|
770
|
+
(decision) => decision.toolCallId === pending.toolCallId,
|
|
771
|
+
);
|
|
772
|
+
if (decided) continue;
|
|
773
|
+
yield* Effect.exit(
|
|
774
|
+
runtime.resolveApproval(
|
|
775
|
+
ApprovalDecisionCommand.make({
|
|
776
|
+
submissionId: explanation.submission.submissionId,
|
|
777
|
+
toolCallId: pending.toolCallId,
|
|
778
|
+
decision: "approved",
|
|
779
|
+
resolver: "certification-runner",
|
|
780
|
+
reason: `re-drive after injected fault at ${location}`,
|
|
781
|
+
}),
|
|
782
|
+
),
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const settled = yield* Effect.exit(allSettled);
|
|
788
|
+
converged = Exit.isSuccess(settled) && settled.value;
|
|
789
|
+
}
|
|
790
|
+
yield* control.clear;
|
|
791
|
+
const wasFired = yield* Ref.get(fired);
|
|
792
|
+
|
|
793
|
+
if (!converged) {
|
|
794
|
+
return failed(`did not converge within ${MAX_REDRIVE_ROUNDS} re-drive rounds`, wasFired);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Every lane of the cell must verify in convergence mode with a recomputed digest chain.
|
|
798
|
+
let digestChainVerified = true;
|
|
799
|
+
const failedChecks: Array<string> = [];
|
|
800
|
+
for (const lane of lanes) {
|
|
801
|
+
const verdict = yield* Effect.exit(verifyLane(lane, batchProducers));
|
|
802
|
+
if (Exit.isFailure(verdict)) {
|
|
803
|
+
return failed(`lane ${lane} could not be verified: ${failureTagOf(verdict.cause)}`, wasFired);
|
|
804
|
+
}
|
|
805
|
+
for (const check of verdict.value.checks) {
|
|
806
|
+
if (check.status === "failed") {
|
|
807
|
+
failedChecks.push(
|
|
808
|
+
`${lane}:${check.name}${check.detail === undefined ? "" : ` (${check.detail})`}`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
if (check.name === "digest-chain" && check.status !== "passed") {
|
|
812
|
+
digestChainVerified = false;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (failedChecks.length > 0 || !digestChainVerified) {
|
|
817
|
+
return failed(
|
|
818
|
+
failedChecks.length > 0
|
|
819
|
+
? `invariant checks failed: ${failedChecks.join("; ")}`
|
|
820
|
+
: "the digest chain was not fully recomputed",
|
|
821
|
+
wasFired,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return CertificationSweepResult.make({
|
|
826
|
+
scenario,
|
|
827
|
+
location,
|
|
828
|
+
failpointFired: wasFired,
|
|
829
|
+
status: wasFired ? "converged" : "not-triggered",
|
|
830
|
+
digestChainVerified,
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// Tier 3 — real loss lever record
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Resolve the Tier-3 record honestly (plan §1): a non-durable reference adapter has no real
|
|
840
|
+
* loss to exercise (`not-applicable`); a supplied lever runs NOW (`exercised`); committed
|
|
841
|
+
* real-loss citations are recorded (`recorded-evidence`); otherwise the certificate says
|
|
842
|
+
* `not-exercised` — a scoped statement, never a silent claim.
|
|
843
|
+
*/
|
|
844
|
+
export const resolveTierThree = Effect.fn("Certification.resolveTierThree")(function* (
|
|
845
|
+
durability: CertifiedAdapterIdentity["durability"],
|
|
846
|
+
options: {
|
|
847
|
+
readonly crashLever?: CertificationCrashLever | undefined;
|
|
848
|
+
readonly tierThreeEvidence?: ReadonlyArray<string> | undefined;
|
|
849
|
+
},
|
|
850
|
+
): Effect.fn.Return<CertificationTierThreeReport, never> {
|
|
851
|
+
if (durability === "non-durable") {
|
|
852
|
+
return CertificationTierThreeReport.make({
|
|
853
|
+
status: "not-applicable",
|
|
854
|
+
evidence: [],
|
|
855
|
+
cases: [],
|
|
856
|
+
detail:
|
|
857
|
+
"the adapter declares non-durable state (reference/conformance adapter); there is no real loss to exercise",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (options.crashLever !== undefined) {
|
|
861
|
+
const cases = yield* options.crashLever;
|
|
862
|
+
return CertificationTierThreeReport.make({
|
|
863
|
+
status: "exercised",
|
|
864
|
+
evidence: options.tierThreeEvidence ?? [],
|
|
865
|
+
cases,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
if (options.tierThreeEvidence !== undefined && options.tierThreeEvidence.length > 0) {
|
|
869
|
+
return CertificationTierThreeReport.make({
|
|
870
|
+
status: "recorded-evidence",
|
|
871
|
+
evidence: options.tierThreeEvidence,
|
|
872
|
+
cases: [],
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return CertificationTierThreeReport.make({
|
|
876
|
+
status: "not-exercised",
|
|
877
|
+
evidence: [],
|
|
878
|
+
cases: [],
|
|
879
|
+
detail:
|
|
880
|
+
"no crash lever was supplied and no committed real-loss evidence was cited; Tier 3 is NOT discharged for this adapter",
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// Entry point
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
const nowUtc: Effect.Effect<DateTime.Utc> = Effect.map(Clock.currentTimeMillis, (millis) =>
|
|
889
|
+
DateTime.toUtc(DateTime.makeUnsafe(millis)),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Certify one durable adapter pair (plan §1, §8 WP2). Runs Tier 2 FIRST over pristine
|
|
894
|
+
* storage (each cell converges to all-settled before the next starts, so the recovery scan
|
|
895
|
+
* never sees foreign leftovers), then Tier 1's port contract cases (whose lanes deliberately
|
|
896
|
+
* end in every nonterminal shape), then records Tier 3. Requires `Crypto.Crypto` and a
|
|
897
|
+
* TestClock-backed environment; the candidate Layers are built exactly once.
|
|
898
|
+
*/
|
|
899
|
+
export const certifyDurableAdapters = <LedgerE = never, StoreE = never>(
|
|
900
|
+
options: CertifyDurableAdaptersOptions<LedgerE, StoreE>,
|
|
901
|
+
): Effect.Effect<CertificationReport, LedgerE | StoreE, Crypto.Crypto> => {
|
|
902
|
+
const batchProducers = new Map<BatchId, ProducerId>();
|
|
903
|
+
// Interpose the candidate store with an append-time capture of each batch's producer
|
|
904
|
+
// identity — the one value the ConversationStore port deliberately does not export — so
|
|
905
|
+
// Tier 2's invariant verification recomputes the FULL digest chain instead of skipping it.
|
|
906
|
+
const capturingStore = Layer.effect(ConversationStore)(
|
|
907
|
+
Effect.gen(function* () {
|
|
908
|
+
const inner = yield* ConversationStore;
|
|
909
|
+
return ConversationStore.of({
|
|
910
|
+
...inner,
|
|
911
|
+
append: (request) =>
|
|
912
|
+
Effect.sync(() => {
|
|
913
|
+
batchProducers.set(request.batch.batchId, request.batch.producerId);
|
|
914
|
+
}).pipe(Effect.andThen(inner.append(request))),
|
|
915
|
+
});
|
|
916
|
+
}),
|
|
917
|
+
).pipe(Layer.provide(options.conversationStore));
|
|
918
|
+
|
|
919
|
+
const support = Layer.mergeAll(
|
|
920
|
+
options.submissionLedger,
|
|
921
|
+
capturingStore,
|
|
922
|
+
options.wakeScheduler ?? WakeScheduler.layerNoop,
|
|
923
|
+
DurableRuntimeFailpoint.layerTest,
|
|
924
|
+
ToolReconciler.uncertain,
|
|
925
|
+
DurableRuntimeConfig.layer({
|
|
926
|
+
deploymentId: Schema.decodeSync(DeploymentId)("deployment-certification"),
|
|
927
|
+
producerId: Schema.decodeSync(ProducerId)("producer-certification"),
|
|
928
|
+
settlementPollInterval: Duration.millis(50),
|
|
929
|
+
leaseRenewalInterval: Duration.seconds(5),
|
|
930
|
+
abortPollInterval: Duration.millis(50),
|
|
931
|
+
}),
|
|
932
|
+
);
|
|
933
|
+
const environment = DurableAgentRuntime.layer.pipe(Layer.provideMerge(support));
|
|
934
|
+
|
|
935
|
+
const leaseAdvance = Duration.millis(
|
|
936
|
+
Duration.toMillis(options.ownershipLeaseDuration ?? DEFAULT_OWNERSHIP_LEASE_DURATION) + 1_000,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
const program = Effect.gen(function* () {
|
|
940
|
+
const ledger = yield* SubmissionLedger;
|
|
941
|
+
|
|
942
|
+
// Tier 2 — coordinator failpoint convergence sweep.
|
|
943
|
+
const tier2: Array<CertificationSweepResult> = [];
|
|
944
|
+
for (const scenario of CERTIFICATION_SCENARIOS) {
|
|
945
|
+
for (const location of DurableRuntimeFailpointLocation.literals) {
|
|
946
|
+
tier2.push(yield* runSweepCell(scenario, location, batchProducers, leaseAdvance));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Tier 1 — the shared port contract suites, verbatim.
|
|
951
|
+
const tier1 = yield* certifyPorts();
|
|
952
|
+
|
|
953
|
+
// Tier 3 — the real loss lever record.
|
|
954
|
+
const capabilities = yield* ledger.capabilities;
|
|
955
|
+
const tier3 = yield* resolveTierThree(capabilities.durability, options);
|
|
956
|
+
|
|
957
|
+
const generatedAt = yield* nowUtc;
|
|
958
|
+
const ok =
|
|
959
|
+
tier1.every((result) => result.status === "passed") &&
|
|
960
|
+
tier2.every((result) => result.status !== "failed") &&
|
|
961
|
+
tier3.cases.every((result) => result.status === "passed");
|
|
962
|
+
|
|
963
|
+
return CertificationReport.make({
|
|
964
|
+
format: "effect-agent/certification@1",
|
|
965
|
+
adapter: CertifiedAdapterIdentity.make({
|
|
966
|
+
name: options.adapter.name,
|
|
967
|
+
...(options.adapter.version === undefined ? {} : { version: options.adapter.version }),
|
|
968
|
+
durability: capabilities.durability,
|
|
969
|
+
}),
|
|
970
|
+
generatedAt,
|
|
971
|
+
tier1,
|
|
972
|
+
tier2,
|
|
973
|
+
tier3,
|
|
974
|
+
ok,
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
return program.pipe(Effect.provide(environment));
|
|
979
|
+
};
|