5.3 KiB
5.3 KiB
T2.2 — Effect-class recovery
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T6.5, T8.3 |
Goal
Resolve a Dispatched-but-unresolved intent by the tool's declared effect class.
Indeterminate becomes a real terminal state with an operator path.
Facts (inlined — no spec read needed)
pub struct ToolRegistration {
pub id: ToolId,
/// No Default. The author is the only party who knows this.
pub effects: EffectClass,
pub caps: CapabilitySet,
pub timeout: Duration,
}
/// Each class carries what its recovery path actually needs. A bare
/// discriminant would let a tool claim `Idempotent` while withholding the one
/// thing that makes the claim actionable.
pub enum EffectClass {
Idempotent { key: KeyDerivation },
Queryable { lookup: RequestIdLookup },
Unsafe,
}
Resolution for a Dispatched intent:
| Class | Recovery |
|---|---|
Idempotent |
retry with the same idempotency key; the provider deduplicates |
Queryable |
ask the provider whether the request id landed, then complete or retry |
Unsafe |
never auto-retry. Attempt becomes Indeterminate, operator notified |
- The third row is the honest one. Some effects cannot be made safe by any
protocol. The intent log's value is converting an invisible unknown into a
recorded one:
Indeterminateis a fact a grader and an operator can both use; a silently retried payment is not. - A tool that cannot state whether it is safe to retry does not register.
- Declaration is not enforcement — that is T8.3's keyed capability, and the sandbox (T6.5) is the third layer. This task builds layer one plus recovery.
- Until built-in tools are audited, default
Unsafeand never auto-retry.
Steps
- Define
EffectClasswith payloads as above — no bare discriminants. A registration declaringIdempotentwithout aKeyDerivationmust not compile. - Extend
ToolRegistrationand reject registration of a tool with no declared effect class. NoDefaultimpl. - Implement the recovery pass, driven by T2.1's restart scan: for each
Dispatchedintent, dispatch on the registered class. Idempotent: re-derive the key from the recorded arguments, re-issue, record the outcome. Same key, so the provider deduplicates.Queryable: call the registeredRequestIdLookupwith the recorded request id; complete from the provider's answer or retry if it never landed.Unsafe: transition the attempt toIndeterminate, emit the operator notification, and stop. No retry path exists for this arm — do not add one behind a flag.- Build three test tools, one per class, each with an injectable crash point mid-call.
Acceptance
- Three tools, one per class; crash mid-call for each.
Idempotentretries and produces no duplicate effect.Queryablereconciles against the provider and completes or retries correctly.UnsafebecomesIndeterminateand raises, rather than retrying.
Verify
Harness: three real test tools, one per class, each writing to an external side-effect ledger (a file or a counter service) so effect count is observable independently of the log. Plus T2.1's child-process kill.
Integration test — tests/it_effect_recovery.rs:
Idempotent: tool appends to the ledger keyed by its idempotency key. Kill mid-call, restart, let recovery retry. Assert the ledger holds exactly one entry and the attempt completes.Queryable: tool records a request id; the fake provider is configurable to answer "landed" or "never arrived".- Provider says landed → assert recovery completes without re-issuing; ledger count stays 1.
- Provider says never arrived → assert recovery retries; ledger count becomes 1 (from the retry, not two).
Unsafe: kill mid-call. Assert the attempt becomesIndeterminate, the operator notification fires, and the ledger count is unchanged — no retry was issued.- Registration: a tool with no declared effect class is rejected; a
Idempotentregistration without aKeyDerivationfails to compile (trybuild). - Key stability: run recovery twice; assert the derived key is identical both times.
Command: cargo test -p durability --features test-hooks effect_recovery -- --test-threads=1
False pass:
- Counting effects only in the framework's own log. The log is what you are testing; the external ledger is the witness.
- The
Idempotentcase passing because the provider stub deduplicates on content rather than on the supplied key — it then passes even when the key derivation is broken. Make the stub key-only. - Testing
Unsafeby asserting an error is returned.Indeterminateis a state, not an error, and the distinction is the deliverable.
Traps
- A "retry anyway with a warning" path for
Unsafe. The class exists to say the answer is no. - Deriving the idempotency key at recovery time from something that changed — it must derive from the recorded arguments, deterministically.
Background (not required to do this task): rust-agentic-sys.md §8.4, §13.1, §13.2 · rust-agentic-task.md