From 98646df5eb83c85078d2783cc5d29e8bd55c869a Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Thu, 12 Nov 2026 08:53:27 +0100 Subject: [PATCH 1/2] feat: let a claim and a completion carry an attempt key The paper gives each task operation an attempt key from the caller, so a retry cannot claim two tasks or complete a task two times (Appendix A, Appendix C). The ledger had no such key. A caller that did not get the answer to `claim_next` and asked again got a second task. - `claim_next_once` takes a key. The same key asked again gives the same claim, while that claim is in force for that holder. When the claim is over, the ledger refuses the key with `Error::AttemptSpent`. - `complete_once` takes a key. The same key asked again gives the done task, and the ledger appends no second event. A key that was used for a different task or a different operation is refused. - The key is in the event of the claim or the completion. The ledger finds it in the events, so a rebuild keeps it. The event of a claim now also has its generation. - The memory tools `claim_next` and `complete` take `attempt`. `amem task claim-next` and `amem task complete` take `--attempt`. - A completion asked again reaches the ledger after the session forgot the claim. A test of the tools found that the session refused it first. Not in this change: an attempt key for the other operations. Co-Authored-By: Claude Fable 5.2 --- PLAN.md | 2 +- README.md | 3 +- crates/ledger/src/lib.rs | 237 +++++++++++++++++++++++++++++++++- crates/ledger/src/postgres.rs | 69 +++++++++- crates/ledger/src/sqlite.rs | 52 ++++++-- crates/memory/src/main.rs | 19 ++- crates/server/src/lib.rs | 66 ++++++++-- crates/server/src/mcp.rs | 80 +++++++++++- docs/terms.md | 1 + local/README.md | 18 ++- 10 files changed, 512 insertions(+), 35 deletions(-) diff --git a/PLAN.md b/PLAN.md index 05a58389..608d78fa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -68,7 +68,7 @@ Landed so far: |---|---|---| | 0 | #69 | Nothing | | 1 | #71 | Nothing | -| 2 | #73 | An attempt key on every operation; an uncertain external effect blocking reassignment; waiting on an outside event as a state; the record's remaining fields (`parent`, `scores`, `implements`, `reversibility`, `artifacts_touched`, `policy_context`) | +| 2 | #73, and attempt keys for a claim and a completion in the pull request after #74 | An attempt key on the other operations; an uncertain external effect blocking reassignment; waiting on an outside event as a state; the record's remaining fields (`parent`, `scores`, `implements`, `reversibility`, `artifacts_touched`, `policy_context`) | | 3 | the pull request after #73 | A suite that gates a version of a definition; the data scope enforced; a definition required by default, once the system's own filings name one; tools offered a few at boot and the rest by search | What stays unbuilt after these, and stays on README's list: federation across domains, authentication of people, logprob-based drift, and accelerator time, which only a gateway can count. So do the items on that list that need another machine or an outside service more than they need code: the limits on re-enactment, harness settings and decomposition templates in the platform domain, bytes stored and resource limits on the sandbox, the authoring of agent definitions, the search tool against its live provider, a harness under Seatbelt on a Mac, and the Gofannon bridge against a live deployment, whose agents make model calls that the egress does not meter. diff --git a/README.md b/README.md index 2b102984..f704619b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ two weeks. It is also the reason that none of it is the production shape. - CPU or memory limits on the sandbox - **The task record, in part.** A claim has a generation, and the ledger refuses a holder that gives an earlier one. A task can be `blocked` on a task that it depends on. - These parts are not built: an attempt key for each operation, a block on a new claim + A claim and a completion can carry an attempt key. These parts are not built: an + attempt key for the other operations, a block on a new claim while an external effect is uncertain, and a `blocked` state for a task that waits on an outside event. The generation does not prove who the caller is. - **Task definitions, in part, and autonomy levels.** A task can name a task definition. diff --git a/crates/ledger/src/lib.rs b/crates/ledger/src/lib.rs index bcd3604e..981dab71 100644 --- a/crates/ledger/src/lib.rs +++ b/crates/ledger/src/lib.rs @@ -83,6 +83,15 @@ pub enum Error { /// The generation of the claim in force. current: u64, }, + /// The attempt key was used before, for another operation or another task, or for + /// a claim that is over: the same key cannot claim or complete a second time. + #[error("the attempt key {key} was used for {id}, and that attempt is over")] + AttemptSpent { + /// The key. + key: String, + /// The task it was used for. + id: String, + }, /// Another connection holds the write-ahead log, so text just removed may still be /// in it. Run the operation again. #[error("ledger: another connection holds the log: {0}")] @@ -290,6 +299,14 @@ pub struct Claim { pub generation: u64, } +/// Who claims, and the attempt key it gave: a retry of the same claim carries the same +/// key, so it is the same claim and not a second one. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Claimant<'a> { + pub(crate) holder: &'a str, + pub(crate) attempt: Option<&'a str>, +} + /// A holder and the generation of the claim it acts under. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Held<'a> { @@ -381,6 +398,12 @@ enum Event { holder: String, #[serde(with = "time::serde::rfc3339")] until: OffsetDateTime, + /// The generation this claim got. Absent from a claim of an older ledger. + #[serde(default)] + generation: u64, + /// The attempt key the caller gave, when it gave one. + #[serde(default, skip_serializing_if = "Option::is_none")] + attempt: Option, }, /// A claim of a named task the ledger refused, and why. Changes nothing; it is the /// record that someone tried. @@ -406,6 +429,9 @@ enum Event { }, Completed { handoff: String, + /// The attempt key the caller gave, when it gave one. + #[serde(default, skip_serializing_if = "Option::is_none")] + attempt: Option, }, Invalidated { reason: String, @@ -498,8 +524,9 @@ fn redact_event(id: &str, payload: &str, redaction: &Redaction) -> Result