From b88b6fd547f9e7d2ce59cb2cbb853e1e07bde57f Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 01/92] Edit source tree --- design/actors.md | 311 +++++++++++++++++++++++++++++++++++++++++++++++ main | 1 + 2 files changed, 312 insertions(+) create mode 100644 design/actors.md create mode 160000 main diff --git a/design/actors.md b/design/actors.md new file mode 100644 index 00000000..224f83f9 --- /dev/null +++ b/design/actors.md @@ -0,0 +1,311 @@ +# Actors and daemons — persistent state in Git, single writer by compare-and-swap + +**Status:** proposal. Nothing here is implemented. + +Builds on [client-owned conversation refs](client-owned-conversation-refs.md) +(the Git protocol workers already use for conversation heads) and the +[runner protocol](runner-protocol.md) (warm runners, and the deferred "resident +worker daemon"). + +--- + +## Problem + +caos runs pure, cached, hermetic jobs to completion. We also want things that +live across requests: + +- a **test stack** that caos starts itself and that later conversations drive, +- assorted small servers whose state must outlive any one job. + +Today a job that needs a daemon starts it as a child of its own container +(`std/caos-test`, `tests/remote-ref`); the daemon dies when the job returns. + +## Model + +An **actor** is a worker with a durable name and its state in a Git branch. +Cloudflare Durable Objects are the closest analogue. Three points define it: + +1. **No Start message.** The container comes alive on the first request and + exits after an idle period. So every request names the actor, and the + worker finds the state itself. +2. **State lives on a branch.** The request carries the branch name. The worker + reads the branch, handles the request, and pushes the new head. +3. **Concurrency control is Git's.** A push is a compare-and-swap on the ref + (`--force-with-lease=:`). A writer that loses the race fails + and retries, or fails the request. This is optimistic STM with commits as + the transaction log. + +Nothing in the server changes for this. An actor is a convention for workers +(see [What caos changes](#what-caos-changes)). Three flavours: + +| flavour | process | when | +|---|---|---| +| **pure** | one-shot worker per request | state is data; handler has no external effects | +| **effectful** | one-shot worker per request | handler acts on the outside world; needs a write-ahead claim | +| **daemonic** | container that registers as a runner and stays up until idle | performance, or a live process (a test stack) | + +## Research: what the existing code already gives us + +### 1. Compare-and-swap on the server's Git transport — supported, in use + +- The server delegates smart-HTTP to `git http-backend` + (`rust/crates/server/src/git.rs`). Its repo config is set at startup in + `main.rs` (`http.receivepack`, `receive.fsckObjects`, …). It does **not** set + `receive.denyNonFastForwards`. +- That is fine. The expected-old-value check comes from the push command, which + receive-pack applies under the ref lock. `--force-with-lease=:` + therefore gives a server-side atomic CAS. **Fast-forward-ness is not enforced + by the server**; the actor enforces it by always building its commit on the + observed head. This matches the stance of + [client-owned conversation refs](client-owned-conversation-refs.md): "the + server provides transport, not policy." +- That design already specifies the exact client protocol we need: a scratch + repo whose `origin` is `CAOS_SERVER_URL`; read with an exact-ref fetch; + append with `git push --force-with-lease=: :`; several + refs at once with `--atomic`; after an ambiguous failure, **fetch again** and + treat "my object is visible" as success, "head changed" as a lost race, and + "head unchanged" as an infrastructure failure. Actors reuse this verbatim. +- Git is opt-in: only workers bound to the `git-runner` image have `git`. + Actor workers select it in their `.caos-expr`, so it shows up in the + ArgTree like any other dependency. +- `/git/push` (`push.rs`) is a different thing: it publishes a pinned commit to + an *external* remote, with `expected` and `Complete/Conflict/Uncertain` + receipts. Actors do not use it, but `Uncertain` is the same ambiguity we have + to handle. + +**Caveats.** + +- The Git paths are **unauthenticated**. `handle()` in `main.rs` routes them + before anything else, and only `/runner/*` checks a token. Any worker (or + anyone who can reach the server) can rewrite an actor branch. We accept that + today for conversation refs. Actors inherit it, so an actor branch is + integrity-protected only against *accidents*, not against a hostile worker. +- Git advertises every ref on every push and fetch. Stale `refs/caos/req/*` + are swept every 10 minutes for exactly this reason. Actor refs are + permanent, so the number of actors is a (soft) scaling limit. +- GC is deliberately off, so actor history is never reclaimed. See + [Open questions](#open-questions). + +### 2. Are failures cached? — no; successes are, forever + +In `compute.rs` (`run_dispatch_inner`), only an `Ok` result is passed to +`cache_set`. An `Err` is returned without caching, and a result that folded in +a caught sub-run failure is explicitly not cached. So: + +- **Retrying a failed request with the same request ID re-runs it.** Good: a + lost CAS race can simply be a failed job that the caller retries unchanged. +- **Retrying a succeeded request with the same ID is a cache hit** and replays + the reply without running the worker. That is the idempotent replay we + wanted — but it is **best-effort only**. The cache is Redis `SET` with no + expiry, but a lookup error "just means we run uncached", and the key is + namespaced by `cache_namespace`, so a stack change silently empties it. + **Exactly-once therefore must not depend on the cache.** The actor records + the request IDs it has applied, in its own state (see below). +- Single-flight (in-memory, per server) coalesces *concurrent* identical + requests into one run, and a waiter never re-runs "merely because a valid run + is slow". That is useful for duplicate delivery but is not a substitute for + the state-based dedupe, since it does not survive a server restart. + +### 3. Credentials for the branch — none needed in-stack + +Because Git transport is unauthenticated (caveat above), an actor worker needs +only `CAOS_SERVER_URL`, which every worker already has +(`run-and-update-ref/src/refs.rs` reads it). Credentials are needed only for +*external* remotes or services, and those use the existing secret store +(SPEC.md "secrets": injected only when the worker's arg tree is a superset of +the secret's reader *and* carries the matching `secret-hash`). Two consequences: + +- Secrets are injected out of band and never enter the cache key, so an actor's + external credentials do not perturb request caching. +- If we later authenticate Git pushes, actor branches should be the first + namespace to get per-branch writers. + +### 4. Runner routing — already symmetric + +`runner.rs::matches` is pure oid equality in both directions. A job entry whose +name starts with `REQUIRED_ARG_PREFIX` must equal the runner's entry of the +same name; conversely every entry a runner requires must equal the job's. A +job carrying `required-actor=` therefore reaches only a runner that polls +with `required: {required-actor: }`, and never leaks to the generic pool. +This is the routing half of daemonic actors and needs no server change. (Note +the header of `runner-protocol.md` still says "not yet implemented"; the +server side is in `runner.rs`.) + +## Request contract + +Every request to an actor carries, in its ArgTree: + +| arg | meaning | +|---|---| +| `actor` | the branch: `refs/heads/actors/` | +| `request-id` | minted **once per logical request by the caller** and reused on every retry | +| `payload` | the message | + +`request-id` doubles as the cache discriminator (two distinct requests never +collide) and the idempotency key (a retry is recognisably the same request). +Do not generate a fresh random nonce per *attempt*: that would defeat both. + +No caching switch is needed. Caching a reply is correct exactly when the +request is a replay. + +## State layout + +``` +actor.json { "schema": 1, "kind": "pure" | "effectful" | "daemon" } +gen integer; incremented by every commit on the chain +state/ the actor's own data (opaque to caos) +applied/ reply blob for each recently applied request-id (bounded window) +claim present only while an effect or a daemon is in flight: + { "request-id", "owner", "gen", "lease-until" } +``` + +Every update is **one commit whose parent is the observed head** — a linear +chain. `gen` is the fencing token: strictly increasing along the chain, and +available to external systems that can reject stale tokens. + +## Pure actors + +``` +loop: + head = fetch(actor) # observed head + if head.applied[request-id]: return it # replay: already applied + (state', reply) = handle(head.state, payload) + new = commit(parent=head, state', applied+={request-id: reply}, gen+1) + push --force-with-lease=actor:head new:actor + ok -> return reply + rejected -> continue # lost the race: re-read and re-apply + ambiguous -> fetch; if new visible return reply, else continue +``` + +- The handler is pure, so the worker retries **internally**; the caller never + sees contention, only latency. +- The crash window (push succeeded, reply never delivered) is closed by the + `applied/` lookup: the retry finds its own request-id and returns the recorded + reply without applying it twice. +- `applied/` is a bounded window (oldest pruned in the same commit). A retry + older than the window is the caller's problem; choose the window to exceed + any realistic retry horizon. + +## Effectful actors + +Effects cannot be retried by re-running the handler, so the worker first wins a +**write-ahead claim**: + +``` +head = fetch(actor) +if head.applied[request-id]: return it +if head.claim and not expired(head.claim): + if head.claim.request-id == request-id: # our own earlier attempt, see below + else: fail(retry-later) # someone else is mid-effect +claim = commit(parent=head, claim={request-id, owner, gen+1, lease-until}) +push --force-with-lease=actor:head claim:actor # FAILS -> abandon; do NOT run the effect +perform effect (idempotency key = request-id; fencing token = gen) +done = commit(parent=claim, state', applied+=..., claim removed, gen+1) +push --force-with-lease=actor:claim done:actor +``` + +- Only one worker can win the claim push for a given head, so only one performs + the effect. That is the single-writer guarantee for effects. +- The claim carries `lease-until`. A later worker that finds an **expired** + claim may take it over with a new commit on top; it judges expiry by its own + clock. Clock skew only affects *when a takeover is attempted*. Safety still + comes from the CAS chain: a zombie's `done` push fails because the head moved. +- **Crash between claim and done** leaves the tip at "claimed, not done". The + takeover path must reconcile. Which policy applies is the actor's choice: + - *at-most-once*: never redo; mark the request failed and surface it; + - *at-least-once*: redo, relying on the request-id as the external + idempotency key, or first ask the external system what happened. +- A zombie that already performed the effect cannot be undone by the CAS. + Where the external system can check it, pass `gen` as a fencing token so it + rejects the zombie. + +## Daemonic actors + +A daemonic actor is the same worker, except it stays up: + +1. On its first request it takes a **claim commit** as `owner=` with + a lease, then registers as a runner polling with + `required: {required-actor: }`. +2. It serves requests from memory. Every state change is a normal commit + pushed with the lease. Commit cadence is the actor's choice — per request + (durable, slower) or batched (fast, bounded loss on crash) — and the lease + must be renewed (by any commit) before `lease-until`. +3. When its poll returns `idle`, it pushes a **release commit** (state flushed, + claim removed) and exits. This is the existing ski-rental rule: the poll TTL + is the idle budget. No new caos op is needed to "persist after an idle + period"; the release commit is the persist. +4. A second container for the same actor fails its claim push and exits. A + crashed daemon's claim simply expires, and the next request takes over. + +There is no checkpoint operation in caos. The chain *is* the checkpoint log; an +actor that wants stronger durability pushes more often. + +### Routing and cold start (needs a decision) + +A job with a `required-actor` arg matches **only** that actor's poll; if no +daemon is parked it waits, then fails at the pending deadline. So callers +cannot send such requests blindly. Sketch: callers send a **front request** +without the required arg. A one-shot front worker reads the branch: + +- no live claim: it handles the request itself (pure or effectful), or starts + the daemon and claims on its behalf; +- live claim: it forwards the same request as a sub-run with the + `required-actor` arg set, then returns the reply. + +The forwarding hop costs a worker start per request, which defeats part of the +point of a warm daemon. The alternative is callers that know a daemon is up +(from the actor's own state) and address it directly, falling back to the +front request on failure. See open question 1. + +## Use case: the test stack + +Today `caos-test` brings a stack up as children of one job and it dies with +that job. As an actor: + +- **State** is a small manifest: the stack's address, the image digest it runs, + a generation. It is *not* the stack's data (Redis, volumes); those are + rebuilt or left in the persistent volume the runner already mounts. +- **The daemon** is the container running `stack/serve`. It claims the actor, + publishes its address into `state/`, and serves "run this" requests. +- **Conversations driving the stack** read the address from the branch, then + talk to the stack over the runner network. Only the coordination (who owns + the stack, where it is) is in Git. + +This decouples the stack's lifetime from any single test job. + +## What caos changes + +| change | needed for | notes | +|---|---|---| +| **None** in the server | pure, effectful | CAS push, routing and non-caching of failures already exist | +| Bind actor workers to `git-runner` in their `.caos-expr` | all | same as `llm-step`, `run-and-update-ref` | +| An `actor` helper in `worker-common` (fetch, claim, lease, `applied/` window, ambiguous-push handling) | all | so authors do not each reimplement the loop above | +| A worker can register as a runner (poll/result with the runner token the job payload carries) | daemonic | confirm what `caos runner` exposes to a worker; `runner-protocol.md` describes the nesting rule | +| Refresh the `runner-protocol.md` status line | docs | | + +## Non-goals + +- Server-side ordering, leases or ref policy. The server stays transport. +- A built-in checkpoint or Start message. +- Multi-ref atomic actor updates (possible with `git push --atomic` later). +- Strong isolation between actors (Git transport is unauthenticated today). + +## Open questions + +1. **Cold start and routing** for daemons (above): front request with a + forwarding hop, or caller-side addressing? Does forwarding via `/sub-run` + work when the target is a parked runner? +2. **Lease source.** Workers judge expiry by their own clocks. Is that enough, + or should the server expose a time/lease primitive? The runner protocol + already lists lease-based dead-worker detection as future work; actors make + it more valuable. +3. **History growth.** Every request is a commit and GC is off. Pure actors + with a hot path will grow the store. Options: periodic squash into a new + root (resets the chain, which breaks `gen` monotonicity unless it is carried + over), or a per-actor compaction worker. +4. **`applied/` window sizing**, and what a caller sees for a retry that + falls outside it. +5. **Authorization.** Do we want per-namespace write control on Git pushes + before actors hold anything sensitive? +6. **Crash policy default** for effectful actors: at-most-once or + at-least-once? I would make the actor choose explicitly in `actor.json`. diff --git a/main b/main new file mode 160000 index 00000000..8a49c0da --- /dev/null +++ b/main @@ -0,0 +1 @@ +Subproject commit 8a49c0da9845a45e377b25bf426424cb80db46fe From a02995d81553359c001ebaf438b83183af7e3a4d Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 02/92] Edit source tree --- main | 1 - 1 file changed, 1 deletion(-) delete mode 160000 main diff --git a/main b/main deleted file mode 160000 index 8a49c0da..00000000 --- a/main +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8a49c0da9845a45e377b25bf426424cb80db46fe From 92fb52747040aba8fd2f8e25922759747182fed5 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 03/92] Edit source tree --- design/actors.md | 315 ++++++++++++++++++++--------------------------- 1 file changed, 131 insertions(+), 184 deletions(-) diff --git a/design/actors.md b/design/actors.md index 224f83f9..55f5da02 100644 --- a/design/actors.md +++ b/design/actors.md @@ -23,222 +23,150 @@ Today a job that needs a daemon starts it as a child of its own container ## Model An **actor** is a worker with a durable name and its state in a Git branch. -Cloudflare Durable Objects are the closest analogue. Three points define it: +Cloudflare Durable Objects are the closest analogue. Four rules define it: 1. **No Start message.** The container comes alive on the first request and exits after an idle period. So every request names the actor, and the worker finds the state itself. 2. **State lives on a branch.** The request carries the branch name. The worker reads the branch, handles the request, and pushes the new head. -3. **Concurrency control is Git's.** A push is a compare-and-swap on the ref - (`--force-with-lease=:`). A writer that loses the race fails - and retries, or fails the request. This is optimistic STM with commits as - the transaction log. +3. **A lost race fails the request.** A push is a compare-and-swap on the ref + (`--force-with-lease=:`). If it loses, the request fails and + the caller retries. No retry loop inside the actor. +4. **Messages are idempotent.** Applying a message twice has the same effect as + applying it once. That is the whole duplicate-delivery story: caos does not + dedupe for the actor, and the actor does not dedupe for itself. Nothing in the server changes for this. An actor is a convention for workers -(see [What caos changes](#what-caos-changes)). Three flavours: +(see [What caos changes](#what-caos-changes)). | flavour | process | when | |---|---|---| -| **pure** | one-shot worker per request | state is data; handler has no external effects | -| **effectful** | one-shot worker per request | handler acts on the outside world; needs a write-ahead claim | +| **pure** | one-shot worker per request | state is data; the rules above are all it needs | | **daemonic** | container that registers as a runner and stays up until idle | performance, or a live process (a test stack) | +An actor whose messages cannot be made idempotent, or whose handler has +external effects, layers a pattern from [Patterns](#patterns) on top. None is +built in. + ## Research: what the existing code already gives us ### 1. Compare-and-swap on the server's Git transport — supported, in use - The server delegates smart-HTTP to `git http-backend` - (`rust/crates/server/src/git.rs`). Its repo config is set at startup in - `main.rs` (`http.receivepack`, `receive.fsckObjects`, …). It does **not** set - `receive.denyNonFastForwards`. -- That is fine. The expected-old-value check comes from the push command, which - receive-pack applies under the ref lock. `--force-with-lease=:` - therefore gives a server-side atomic CAS. **Fast-forward-ness is not enforced - by the server**; the actor enforces it by always building its commit on the - observed head. This matches the stance of + (`rust/crates/server/src/git.rs`). It does **not** set + `receive.denyNonFastForwards`, and does not need to. The expected-old-value + check comes from the push command, which receive-pack applies under the ref + lock, so `--force-with-lease=:` is a server-side atomic CAS. + **Fast-forward-ness is not enforced by the server**; the actor keeps a linear + chain by always committing on the head it observed. This matches [client-owned conversation refs](client-owned-conversation-refs.md): "the server provides transport, not policy." -- That design already specifies the exact client protocol we need: a scratch - repo whose `origin` is `CAOS_SERVER_URL`; read with an exact-ref fetch; - append with `git push --force-with-lease=: :`; several - refs at once with `--atomic`; after an ambiguous failure, **fetch again** and - treat "my object is visible" as success, "head changed" as a lost race, and - "head unchanged" as an infrastructure failure. Actors reuse this verbatim. +- That design already specifies the client protocol: a scratch repo whose + `origin` is `CAOS_SERVER_URL`; read with an exact-ref fetch; append with + `git push --force-with-lease=: :`; after an + ambiguous failure, **fetch again** and treat "my object is visible" as + success, "head changed" as a lost race, and "head unchanged" as an + infrastructure failure. Actors reuse this verbatim. - Git is opt-in: only workers bound to the `git-runner` image have `git`. - Actor workers select it in their `.caos-expr`, so it shows up in the - ArgTree like any other dependency. -- `/git/push` (`push.rs`) is a different thing: it publishes a pinned commit to - an *external* remote, with `expected` and `Complete/Conflict/Uncertain` - receipts. Actors do not use it, but `Uncertain` is the same ambiguity we have - to handle. + Actor workers select it in their `.caos-expr`. **Caveats.** - The Git paths are **unauthenticated**. `handle()` in `main.rs` routes them - before anything else, and only `/runner/*` checks a token. Any worker (or - anyone who can reach the server) can rewrite an actor branch. We accept that - today for conversation refs. Actors inherit it, so an actor branch is - integrity-protected only against *accidents*, not against a hostile worker. -- Git advertises every ref on every push and fetch. Stale `refs/caos/req/*` - are swept every 10 minutes for exactly this reason. Actor refs are - permanent, so the number of actors is a (soft) scaling limit. + before anything else, and only `/runner/*` checks a token. Any worker can + rewrite an actor branch. We accept that today for conversation refs; actors + inherit it. +- Git advertises every ref on every push and fetch. Actor refs are permanent, + so the number of actors is a (soft) scaling limit. - GC is deliberately off, so actor history is never reclaimed. See [Open questions](#open-questions). -### 2. Are failures cached? — no; successes are, forever - -In `compute.rs` (`run_dispatch_inner`), only an `Ok` result is passed to -`cache_set`. An `Err` is returned without caching, and a result that folded in -a caught sub-run failure is explicitly not cached. So: - -- **Retrying a failed request with the same request ID re-runs it.** Good: a - lost CAS race can simply be a failed job that the caller retries unchanged. -- **Retrying a succeeded request with the same ID is a cache hit** and replays - the reply without running the worker. That is the idempotent replay we - wanted — but it is **best-effort only**. The cache is Redis `SET` with no - expiry, but a lookup error "just means we run uncached", and the key is - namespaced by `cache_namespace`, so a stack change silently empties it. - **Exactly-once therefore must not depend on the cache.** The actor records - the request IDs it has applied, in its own state (see below). -- Single-flight (in-memory, per server) coalesces *concurrent* identical - requests into one run, and a waiter never re-runs "merely because a valid run - is slow". That is useful for duplicate delivery but is not a substitute for - the state-based dedupe, since it does not survive a server restart. +### 2. Failures are not cached; successes are -### 3. Credentials for the branch — none needed in-stack +In `compute.rs` (`run_dispatch_inner`), only an `Ok` result reaches +`cache_set`. An `Err` is returned uncached, so **re-requesting a failed +request re-runs it.** That is what makes "lose the race, fail, retry" work. + +A successful result is cached under the ArgTree hash with no expiry (Redis is +best-effort). That matters for one reason: **an identical request would be +answered from the cache without reaching the actor**, so a read-style message +would return a stale reply. Every request therefore carries a `nonce` to keep +the ArgTree unique (see the contract below). -Because Git transport is unauthenticated (caveat above), an actor worker needs -only `CAOS_SERVER_URL`, which every worker already has -(`run-and-update-ref/src/refs.rs` reads it). Credentials are needed only for -*external* remotes or services, and those use the existing secret store -(SPEC.md "secrets": injected only when the worker's arg tree is a superset of -the secret's reader *and* carries the matching `secret-hash`). Two consequences: +I did not find the server retrying a failed job by itself. The retry in rule 3 +is the **caller's**, and this design assumes callers retry failed requests. + +### 3. Credentials for the branch — none needed in-stack -- Secrets are injected out of band and never enter the cache key, so an actor's - external credentials do not perturb request caching. -- If we later authenticate Git pushes, actor branches should be the first - namespace to get per-branch writers. +Because Git transport is unauthenticated, an actor worker needs only +`CAOS_SERVER_URL`, which every worker already has. Credentials are needed only +for *external* remotes or services, and those use the existing secret store +(SPEC.md "secrets"), which injects out of band and never enters the cache key. ### 4. Runner routing — already symmetric `runner.rs::matches` is pure oid equality in both directions. A job entry whose name starts with `REQUIRED_ARG_PREFIX` must equal the runner's entry of the -same name; conversely every entry a runner requires must equal the job's. A -job carrying `required-actor=` therefore reaches only a runner that polls -with `required: {required-actor: }`, and never leaks to the generic pool. -This is the routing half of daemonic actors and needs no server change. (Note -the header of `runner-protocol.md` still says "not yet implemented"; the -server side is in `runner.rs`.) +same name, and every entry a runner requires must equal the job's. A job +carrying `required-actor=` therefore reaches only a runner polling with +`required: {required-actor: }`, and never leaks to the generic pool. This +is the routing half of daemonic actors and needs no server change. (The header +of `runner-protocol.md` still says "not yet implemented"; the server side is in +`runner.rs`.) ## Request contract -Every request to an actor carries, in its ArgTree: - | arg | meaning | |---|---| | `actor` | the branch: `refs/heads/actors/` | -| `request-id` | minted **once per logical request by the caller** and reused on every retry | -| `payload` | the message | - -`request-id` doubles as the cache discriminator (two distinct requests never -collide) and the idempotency key (a retry is recognisably the same request). -Do not generate a fresh random nonce per *attempt*: that would defeat both. - -No caching switch is needed. Caching a reply is correct exactly when the -request is a replay. - -## State layout +| `nonce` | any value that makes this ArgTree unique, so the cache does not answer it | +| `payload` | the message; **must be idempotent** | -``` -actor.json { "schema": 1, "kind": "pure" | "effectful" | "daemon" } -gen integer; incremented by every commit on the chain -state/ the actor's own data (opaque to caos) -applied/ reply blob for each recently applied request-id (bounded window) -claim present only while an effect or a daemon is in flight: - { "request-id", "owner", "gen", "lease-until" } -``` - -Every update is **one commit whose parent is the observed head** — a linear -chain. `gen` is the fencing token: strictly increasing along the chain, and -available to external systems that can reject stale tokens. +Because messages are idempotent, the nonce is just a cache-buster. Reusing it +across retries or minting a fresh one per attempt are both correct. ## Pure actors ``` -loop: - head = fetch(actor) # observed head - if head.applied[request-id]: return it # replay: already applied - (state', reply) = handle(head.state, payload) - new = commit(parent=head, state', applied+={request-id: reply}, gen+1) - push --force-with-lease=actor:head new:actor - ok -> return reply - rejected -> continue # lost the race: re-read and re-apply - ambiguous -> fetch; if new visible return reply, else continue -``` - -- The handler is pure, so the worker retries **internally**; the caller never - sees contention, only latency. -- The crash window (push succeeded, reply never delivered) is closed by the - `applied/` lookup: the retry finds its own request-id and returns the recorded - reply without applying it twice. -- `applied/` is a bounded window (oldest pruned in the same commit). A retry - older than the window is the caller's problem; choose the window to exceed - any realistic retry horizon. - -## Effectful actors - -Effects cannot be retried by re-running the handler, so the worker first wins a -**write-ahead claim**: - -``` -head = fetch(actor) -if head.applied[request-id]: return it -if head.claim and not expired(head.claim): - if head.claim.request-id == request-id: # our own earlier attempt, see below - else: fail(retry-later) # someone else is mid-effect -claim = commit(parent=head, claim={request-id, owner, gen+1, lease-until}) -push --force-with-lease=actor:head claim:actor # FAILS -> abandon; do NOT run the effect -perform effect (idempotency key = request-id; fencing token = gen) -done = commit(parent=claim, state', applied+=..., claim removed, gen+1) -push --force-with-lease=actor:claim done:actor +head = fetch(actor) # observed head +(state', reply) = handle(head.state, payload) +new = commit(parent=head, state') +push --force-with-lease=actor:head new:actor + ok -> return reply + rejected -> fail the request # lost the race; the caller retries + ambiguous -> fetch; if new visible return reply, else fail ``` -- Only one worker can win the claim push for a given head, so only one performs - the effect. That is the single-writer guarantee for effects. -- The claim carries `lease-until`. A later worker that finds an **expired** - claim may take it over with a new commit on top; it judges expiry by its own - clock. Clock skew only affects *when a takeover is attempted*. Safety still - comes from the CAS chain: a zombie's `done` push fails because the head moved. -- **Crash between claim and done** leaves the tip at "claimed, not done". The - takeover path must reconcile. Which policy applies is the actor's choice: - - *at-most-once*: never redo; mark the request failed and surface it; - - *at-least-once*: redo, relying on the request-id as the external - idempotency key, or first ask the external system what happened. -- A zombie that already performed the effect cannot be undone by the CAS. - Where the external system can check it, pass `gen` as a fencing token so it - rejects the zombie. +- The branch tree is the actor's own data. caos reserves nothing in it. +- A duplicate delivery re-applies an idempotent message. A crash after the push + but before the reply is posted is the same thing: the retry re-applies and + reaches the same state. +- If the handler reads state that another request just changed, the loser fails + and retries against the new head. That is the entire concurrency story. ## Daemonic actors -A daemonic actor is the same worker, except it stays up: +A daemonic actor is the same worker, except it stays up, so it needs a **lock** +to keep a second container from also serving. The lock is a commit: -1. On its first request it takes a **claim commit** as `owner=` with - a lease, then registers as a runner polling with - `required: {required-actor: }`. -2. It serves requests from memory. Every state change is a normal commit - pushed with the lease. Commit cadence is the actor's choice — per request - (durable, slower) or batched (fast, bounded loss on crash) — and the lease - must be renewed (by any commit) before `lease-until`. +1. On its first request it pushes a **claim commit** (with lease) that records + `owner=` and `lease-until`, then registers as a runner polling + with `required: {required-actor: }`. If the claim push loses, it exits. +2. It serves requests from memory. Every state change is a normal commit pushed + with the lease, and any commit renews the claim before `lease-until`. Commit + cadence is the actor's choice: per request (durable, slower) or batched + (fast, bounded loss on crash). 3. When its poll returns `idle`, it pushes a **release commit** (state flushed, claim removed) and exits. This is the existing ski-rental rule: the poll TTL - is the idle budget. No new caos op is needed to "persist after an idle - period"; the release commit is the persist. -4. A second container for the same actor fails its claim push and exits. A - crashed daemon's claim simply expires, and the next request takes over. + is the idle budget. The release commit is the persist; caos needs no + checkpoint operation. +4. A crashed daemon's claim simply expires, and a later request takes over by + pushing a new claim on top. A zombie's next push then fails because the head + moved. -There is no checkpoint operation in caos. The chain *is* the checkpoint log; an -actor that wants stronger durability pushes more often. +The claim lives in one reserved file, `.actor/claim` +(`{"owner", "lease-until"}`), so pure actors never see it. ### Routing and cold start (needs a decision) @@ -247,26 +175,47 @@ daemon is parked it waits, then fails at the pending deadline. So callers cannot send such requests blindly. Sketch: callers send a **front request** without the required arg. A one-shot front worker reads the branch: -- no live claim: it handles the request itself (pure or effectful), or starts - the daemon and claims on its behalf; +- no live claim: it handles the request itself as a pure actor, or starts the + daemon and claims on its behalf; - live claim: it forwards the same request as a sub-run with the `required-actor` arg set, then returns the reply. The forwarding hop costs a worker start per request, which defeats part of the point of a warm daemon. The alternative is callers that know a daemon is up -(from the actor's own state) and address it directly, falling back to the -front request on failure. See open question 1. +(from the actor's own state) and address it directly, falling back to the front +request on failure. See open question 1. + +## Patterns + +These are conventions an actor may adopt. caos provides none of them. + +**Dedupe by request ID.** For a message that is not naturally idempotent, put +the request ID in each commit message and have the worker scan the last few +commits for its own ID before applying. A retry that finds it returns +"already applied". This closes the crash window between push and reply without +a stored reply cache. + +**Write-ahead claim for external effects.** If the handler acts on the outside +world, push a claim commit (request ID, owner, lease) **first** and abandon if +the push loses, perform the effect, then push a done commit on top of the claim. +Only one worker can win the claim for a given head, so only one performs the +effect. After a crash the tip is "claimed, not done", and a later worker may +take over an expired claim and either redo the effect (at-least-once, with the +request ID as the external idempotency key) or surface it as failed +(at-most-once). Keep a monotonic counter in the chain and pass it as a fencing +token where the external system can check it, since the CAS cannot undo an +effect a zombie already performed. ## Use case: the test stack Today `caos-test` brings a stack up as children of one job and it dies with -that job. As an actor: +that job. As a daemonic actor: -- **State** is a small manifest: the stack's address, the image digest it runs, - a generation. It is *not* the stack's data (Redis, volumes); those are - rebuilt or left in the persistent volume the runner already mounts. +- **State** is a small manifest: the stack's address and the image digest it + runs. It is *not* the stack's data (Redis, volumes); those are rebuilt or + left in the persistent volume the runner already mounts. - **The daemon** is the container running `stack/serve`. It claims the actor, - publishes its address into `state/`, and serves "run this" requests. + publishes its address into the branch, and serves "run this" requests. - **Conversations driving the stack** read the address from the branch, then talk to the stack over the runner network. Only the coordination (who owns the stack, where it is) is in Git. @@ -277,9 +226,9 @@ This decouples the stack's lifetime from any single test job. | change | needed for | notes | |---|---|---| -| **None** in the server | pure, effectful | CAS push, routing and non-caching of failures already exist | +| **None** in the server | pure | CAS push, routing and non-caching of failures already exist | | Bind actor workers to `git-runner` in their `.caos-expr` | all | same as `llm-step`, `run-and-update-ref` | -| An `actor` helper in `worker-common` (fetch, claim, lease, `applied/` window, ambiguous-push handling) | all | so authors do not each reimplement the loop above | +| A small `actor` helper in `worker-common` (fetch, CAS push, ambiguous-push handling, claim/lease for daemons) | all | so authors do not each reimplement it | | A worker can register as a runner (poll/result with the runner token the job payload carries) | daemonic | confirm what `caos runner` exposes to a worker; `runner-protocol.md` describes the nesting rule | | Refresh the `runner-protocol.md` status line | docs | | @@ -287,7 +236,7 @@ This decouples the stack's lifetime from any single test job. - Server-side ordering, leases or ref policy. The server stays transport. - A built-in checkpoint or Start message. -- Multi-ref atomic actor updates (possible with `git push --atomic` later). +- Built-in dedupe, reply caching or exactly-once delivery. - Strong isolation between actors (Git transport is unauthenticated today). ## Open questions @@ -295,17 +244,15 @@ This decouples the stack's lifetime from any single test job. 1. **Cold start and routing** for daemons (above): front request with a forwarding hop, or caller-side addressing? Does forwarding via `/sub-run` work when the target is a parked runner? -2. **Lease source.** Workers judge expiry by their own clocks. Is that enough, - or should the server expose a time/lease primitive? The runner protocol - already lists lease-based dead-worker detection as future work; actors make - it more valuable. -3. **History growth.** Every request is a commit and GC is off. Pure actors - with a hot path will grow the store. Options: periodic squash into a new - root (resets the chain, which breaks `gen` monotonicity unless it is carried - over), or a per-actor compaction worker. -4. **`applied/` window sizing**, and what a caller sees for a retry that - falls outside it. +2. **Lease source.** Workers judge expiry by their own clocks. Clock skew only + affects *when a takeover is attempted*; safety comes from the CAS chain. Is + that enough, or should the server expose a time/lease primitive? The runner + protocol already lists lease-based dead-worker detection as future work. +3. **History growth.** Every state change is a commit and GC is off. A hot actor + will grow the store. Options: periodic squash into a new root, or a + per-actor compaction worker. +4. **Caller retry.** The design assumes callers retry a failed request. Which + layer owns that for conversations and `map-then`, and does it back off under + contention on a hot actor? 5. **Authorization.** Do we want per-namespace write control on Git pushes before actors hold anything sensitive? -6. **Crash policy default** for effectful actors: at-most-once or - at-least-once? I would make the actor choose explicitly in `actor.json`. From 2fca7025a99ec71e5ee74f1360e70ea705cb6f57 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 04/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index 55f5da02..aa8c72cd 100644 --- a/design/actors.md +++ b/design/actors.md @@ -40,7 +40,7 @@ Cloudflare Durable Objects are the closest analogue. Four rules define it: Nothing in the server changes for this. An actor is a convention for workers (see [What caos changes](#what-caos-changes)). -| flavour | process | when | +| flavor | process | when | |---|---|---| | **pure** | one-shot worker per request | state is data; the rules above are all it needs | | **daemonic** | container that registers as a runner and stays up until idle | performance, or a live process (a test stack) | From ccc39fa8664c553ac415b5bbea75052365135b15 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 05/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index aa8c72cd..e0f0f634 100644 --- a/design/actors.md +++ b/design/actors.md @@ -23,7 +23,7 @@ Today a job that needs a daemon starts it as a child of its own container ## Model An **actor** is a worker with a durable name and its state in a Git branch. -Cloudflare Durable Objects are the closest analogue. Four rules define it: +Cloudflare Durable Objects are the closest analog. Four rules define it: 1. **No Start message.** The container comes alive on the first request and exits after an idle period. So every request names the actor, and the From dd834a2123fc65f1cd3bedea6505c51fbd9b3e7c Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 06/92] Edit source tree --- design/actors.md | 431 ++++++++++++++++++++++++----------------------- 1 file changed, 217 insertions(+), 214 deletions(-) diff --git a/design/actors.md b/design/actors.md index e0f0f634..66a7f0bf 100644 --- a/design/actors.md +++ b/design/actors.md @@ -1,258 +1,261 @@ -# Actors and daemons — persistent state in Git, single writer by compare-and-swap +# Actors — persistent state in Git, single writer by compare-and-swap -**Status:** proposal. Nothing here is implemented. +**Status:** proposal. Nothing here is implemented. Daemons are deliberately set +aside; see [Deferred: daemons](#deferred-daemons). Builds on [client-owned conversation refs](client-owned-conversation-refs.md) -(the Git protocol workers already use for conversation heads) and the -[runner protocol](runner-protocol.md) (warm runners, and the deferred "resident -worker daemon"). +(the Git protocol workers already use for conversation heads) and follows the +start/finish shape of `std/run-and-update-ref`. --- ## Problem -caos runs pure, cached, hermetic jobs to completion. We also want things that -live across requests: - -- a **test stack** that caos starts itself and that later conversations drive, -- assorted small servers whose state must outlive any one job. - -Today a job that needs a daemon starts it as a child of its own container -(`std/caos-test`, `tests/remote-ref`); the daemon dies when the job returns. +caos runs pure, cached, hermetic jobs to completion. We also want named things +whose state outlives any one job: small servers, and eventually a test stack +that conversations drive. Today the only way to keep state is to hand it back +through a caller. ## Model -An **actor** is a worker with a durable name and its state in a Git branch. -Cloudflare Durable Objects are the closest analog. Four rules define it: - -1. **No Start message.** The container comes alive on the first request and - exits after an idle period. So every request names the actor, and the - worker finds the state itself. -2. **State lives on a branch.** The request carries the branch name. The worker - reads the branch, handles the request, and pushes the new head. -3. **A lost race fails the request.** A push is a compare-and-swap on the ref - (`--force-with-lease=:`). If it loses, the request fails and - the caller retries. No retry loop inside the actor. +An **actor** is a pure function from `(state, message)` to `(state', reply)`, +with its `state` kept on a Git branch. Cloudflare Durable Objects are the +closest analog. Four rules define it: + +1. **No Start message.** Nothing is created or started. The first request for a + name finds an empty branch and runs against empty state. +2. **State lives on a branch.** Each request names the branch. The head's tree + is the state. +3. **A lost race fails the request.** Publishing the new state is a + compare-and-swap on the ref (`--force-with-lease=:`). If it + loses, the request fails and the caller retries. caos already treats failures + as retryable: they are never cached. 4. **Messages are idempotent.** Applying a message twice has the same effect as applying it once. That is the whole duplicate-delivery story: caos does not - dedupe for the actor, and the actor does not dedupe for itself. - -Nothing in the server changes for this. An actor is a convention for workers -(see [What caos changes](#what-caos-changes)). + dedupe for the actor and the actor does not dedupe for itself. -| flavor | process | when | -|---|---|---| -| **pure** | one-shot worker per request | state is data; the rules above are all it needs | -| **daemonic** | container that registers as a runner and stays up until idle | performance, or a live process (a test stack) | +Nothing in the server changes. An actor is a std tool plus a convention for the +inner worker. -An actor whose messages cannot be made idempotent, or whose handler has -external effects, layers a pattern from [Patterns](#patterns) on top. None is -built in. +### Principle: never manifest the whole tree -## Research: what the existing code already gives us +caos never materializes a whole Git tree, and actors follow that. The state +reaches the inner worker as a **tree oid** that it reads lazily +(`caos get /cas/args/state/foo`), and the new state leaves as an oid it staged +with `caos put`. Neither the wrapper nor the inner ever checks the state out. -### 1. Compare-and-swap on the server's Git transport — supported, in use +## Design -- The server delegates smart-HTTP to `git http-backend` - (`rust/crates/server/src/git.rs`). It does **not** set - `receive.denyNonFastForwards`, and does not need to. The expected-old-value - check comes from the push command, which receive-pack applies under the ref - lock, so `--force-with-lease=:` is a server-side atomic CAS. - **Fast-forward-ness is not enforced by the server**; the actor keeps a linear - chain by always committing on the head it observed. This matches - [client-owned conversation refs](client-owned-conversation-refs.md): "the - server provides transport, not policy." -- That design already specifies the client protocol: a scratch repo whose - `origin` is `CAOS_SERVER_URL`; read with an exact-ref fetch; append with - `git push --force-with-lease=: :`; after an - ambiguous failure, **fetch again** and treat "my object is visible" as - success, "head changed" as a lost race, and "head unchanged" as an - infrastructure failure. Actors reuse this verbatim. -- Git is opt-in: only workers bound to the `git-runner` image have `git`. - Actor workers select it in their `.caos-expr`. - -**Caveats.** - -- The Git paths are **unauthenticated**. `handle()` in `main.rs` routes them - before anything else, and only `/runner/*` checks a token. Any worker can - rewrite an actor branch. We accept that today for conversation refs; actors - inherit it. -- Git advertises every ref on every push and fetch. Actor refs are permanent, - so the number of actors is a (soft) scaling limit. -- GC is deliberately off, so actor history is never reclaimed. See - [Open questions](#open-questions). +### Request contract -### 2. Failures are not cached; successes are +An actor request is a call to the `actor` wrapper tool with these args: -In `compute.rs` (`run_dispatch_inner`), only an `Ok` result reaches -`cache_set`. An `Err` is returned uncached, so **re-requesting a failed -request re-runs it.** That is what makes "lose the race, fail, retry" work. - -A successful result is cached under the ArgTree hash with no expiry (Redis is -best-effort). That matters for one reason: **an identical request would be -answered from the cache without reaching the actor**, so a read-style message -would return a stale reply. Every request therefore carries a `nonce` to keep -the ArgTree unique (see the contract below). +| arg | meaning | +|---|---| +| `actor` | the branch: `refs/heads/actors/` | +| `inner` | the inner actor: any caos worker request template, in any image | +| `nonce` | any value that makes this ArgTree unique, so the outer request is never answered from the cache | +| message args | whatever the inner expects; passed through to it unchanged | -I did not find the server retrying a failed job by itself. The retry in rule 3 -is the **caller's**, and this design assumes callers retry failed requests. +The nonce is only a cache-buster. Reusing it across retries or minting a new +one per attempt are both correct, because messages are idempotent. -### 3. Credentials for the branch — none needed in-stack +### Branch layout -Because Git transport is unauthenticated, an actor worker needs only -`CAOS_SERVER_URL`, which every worker already has. Credentials are needed only -for *external* remotes or services, and those use the existing secret store -(SPEC.md "secrets"), which injects out of band and never enters the cache key. +``` +state/ the actor's state: an ordinary tree, opaque to caos +.actor/ reserved for actor bookkeeping; empty today +``` -### 4. Runner routing — already symmetric +The branch tree is `{state, .actor}`. The wrapper hands the inner only the +`state/` subtree, so the inner never sees bookkeeping. Every update is **one +commit whose parent is the observed head**, a linear chain. Reserving `.actor/` +now keeps room for later features (claims, counters) without changing the +layout. -`runner.rs::matches` is pure oid equality in both directions. A job entry whose -name starts with `REQUIRED_ARG_PREFIX` must equal the runner's entry of the -same name, and every entry a runner requires must equal the job's. A job -carrying `required-actor=` therefore reaches only a runner polling with -`required: {required-actor: }`, and never leaks to the generic pool. This -is the routing half of daemonic actors and needs no server change. (The header -of `runner-protocol.md` still says "not yet implemented"; the server side is in -`runner.rs`.) +### The inner actor -## Request contract +The inner is an ordinary caos worker with **any image**, including a +`docker://` image or a flake. It is a pure function of its args: -| arg | meaning | +| | | |---|---| -| `actor` | the branch: `refs/heads/actors/` | -| `nonce` | any value that makes this ArgTree unique, so the cache does not answer it | -| `payload` | the message; **must be idempotent** | +| in | `state`: the tree oid of the `state/` subtree (empty for a new actor), read lazily with `caos get /cas/args/state/`; plus the message args | +| out | `/cas/out`: a tree `{state, reply}`. `state` is the new state tree (staged with `caos put`); `reply` is a blob or tree | + +Two choices here differ from a first instinct: + +- **New state goes inside `/cas/out`, not beside it.** The result is what caos + caches, so a separate `/cas/state-out` outside it would be lost on a cache + hit. (`/cas/out-trace` is the precedent for data that deliberately stays out + of the cache key; state must not.) A small helper in `worker-common` can + expose `state-out` as a path to authors. +- **The state is passed as a tree oid, not a commit.** A commit hash includes + its parent, so every update would change the inner's cache key and nothing + would ever be shared. A tree oid is content-only: the same state and message + hit the cache. + +Because the inner is a pure function of `(state tree, message)`, **caching it is +correct**. That is why the nonce goes only on the outer request. A retry after +a lost race reaches a different head and so a different inner request, but a +retry after an unrelated failure with the head unchanged reuses the cached +inner result. + +An inner that finishes with `state` equal to its input makes **no commit and no +push**. Reads therefore never race with writers, and observe the head at some +moment during the request. + +### The wrapper: a start/finish pair + +The wrapper has two positions, like `run-and-update-ref`. It holds no container +while the inner runs, and it needs `git` (selected through `git-runner` in its +`.caos-expr`), which the inner does not. + +**Start** + +1. Read the branch head with an exact-ref, shallow, tree-filtered fetch. Absent + branch means empty state. +2. Take the `state/` subtree oid from the head. +3. Build the inner request R from `inner`, the state oid and the message args. +4. Emit `run-request-then R`, carrying the observed head (and the branch name) + into the callback. + +**Finish**, given R's result `{state, reply}` and the observed head: + +1. If `state` equals the input state, return `reply`. Nothing to publish. +2. Otherwise build the root tree `{state: , .actor: }` + by oid, with no checkout, and a commit on the observed head. +3. Store the commit through the object API, then fetch just that commit into a + throwaway scratch repository (origin `CAOS_SERVER_URL`). +4. Push with `git push --force-with-lease=: :`. + - ok: return `reply`; + - lease rejected: **fail the request**; the caller retries; + - ambiguous: fetch the ref again. Treat "my commit is the head" as success, + "head changed" as a lost race, and "head unchanged" as an infrastructure + failure. + +The window between start and finish is the race window. It is small compared +with the inner's run time, and the compare-and-swap makes it safe. + +### Failure, retry and caching + +| event | what happens | +|---|---| +| lost race | finish fails; not cached; caller retries; retry reads the new head | +| inner fails | the request fails; not cached | +| crash after the push, before the reply is posted | the job fails; a retry re-applies the message, which is idempotent | +| inner succeeds, finish fails | the inner's result stays cached; a retry on an unchanged head reuses it | +| duplicate concurrent requests | single-flight coalesces identical outer requests; distinct nonces both run and one loses the race | -Because messages are idempotent, the nonce is just a cache-buster. Reusing it -across retries or minting a fresh one per attempt are both correct. +## Research: what the existing code gives us -## Pure actors +### 1. Compare-and-swap on the server's Git transport -``` -head = fetch(actor) # observed head -(state', reply) = handle(head.state, payload) -new = commit(parent=head, state') -push --force-with-lease=actor:head new:actor - ok -> return reply - rejected -> fail the request # lost the race; the caller retries - ambiguous -> fetch; if new visible return reply, else fail -``` +- The server delegates smart-HTTP to `git http-backend` + (`rust/crates/server/src/git.rs`). It does not set + `receive.denyNonFastForwards`, and does not need to: the expected old value is + part of the push command, which receive-pack checks under the ref lock, so + `--force-with-lease=:` is a server-side atomic compare-and-swap. + Fast-forward-ness is **not** enforced by the server; the wrapper keeps the + chain linear by always committing on the observed head. This matches the + "transport, not policy" stance of the conversation-refs doc. +- That doc already defines the client protocol used above: a scratch repo whose + origin is `CAOS_SERVER_URL`, an exact-ref fetch, a lease push, and the + re-fetch rule after an ambiguous failure. +- Git is opt-in: only workers bound to `git-runner` have `git`. -- The branch tree is the actor's own data. caos reserves nothing in it. -- A duplicate delivery re-applies an idempotent message. A crash after the push - but before the reply is posted is the same thing: the retry re-applies and - reaches the same state. -- If the handler reads state that another request just changed, the loser fails - and retries against the new head. That is the entire concurrency story. - -## Daemonic actors - -A daemonic actor is the same worker, except it stays up, so it needs a **lock** -to keep a second container from also serving. The lock is a commit: - -1. On its first request it pushes a **claim commit** (with lease) that records - `owner=` and `lease-until`, then registers as a runner polling - with `required: {required-actor: }`. If the claim push loses, it exits. -2. It serves requests from memory. Every state change is a normal commit pushed - with the lease, and any commit renews the claim before `lease-until`. Commit - cadence is the actor's choice: per request (durable, slower) or batched - (fast, bounded loss on crash). -3. When its poll returns `idle`, it pushes a **release commit** (state flushed, - claim removed) and exits. This is the existing ski-rental rule: the poll TTL - is the idle budget. The release commit is the persist; caos needs no - checkpoint operation. -4. A crashed daemon's claim simply expires, and a later request takes over by - pushing a new claim on top. A zombie's next push then fails because the head - moved. - -The claim lives in one reserved file, `.actor/claim` -(`{"owner", "lease-until"}`), so pure actors never see it. - -### Routing and cold start (needs a decision) - -A job with a `required-actor` arg matches **only** that actor's poll; if no -daemon is parked it waits, then fails at the pending deadline. So callers -cannot send such requests blindly. Sketch: callers send a **front request** -without the required arg. A one-shot front worker reads the branch: - -- no live claim: it handles the request itself as a pure actor, or starts the - daemon and claims on its behalf; -- live claim: it forwards the same request as a sub-run with the - `required-actor` arg set, then returns the reply. - -The forwarding hop costs a worker start per request, which defeats part of the -point of a warm daemon. The alternative is callers that know a daemon is up -(from the actor's own state) and address it directly, falling back to the front -request on failure. See open question 1. - -## Patterns - -These are conventions an actor may adopt. caos provides none of them. - -**Dedupe by request ID.** For a message that is not naturally idempotent, put -the request ID in each commit message and have the worker scan the last few -commits for its own ID before applying. A retry that finds it returns -"already applied". This closes the crash window between push and reply without -a stored reply cache. - -**Write-ahead claim for external effects.** If the handler acts on the outside -world, push a claim commit (request ID, owner, lease) **first** and abandon if -the push loses, perform the effect, then push a done commit on top of the claim. -Only one worker can win the claim for a given head, so only one performs the -effect. After a crash the tip is "claimed, not done", and a later worker may -take over an expired claim and either redo the effect (at-least-once, with the -request ID as the external idempotency key) or surface it as failed -(at-most-once). Keep a monotonic counter in the chain and pass it as a fencing -token where the external system can check it, since the CAS cannot undo an -effect a zombie already performed. - -## Use case: the test stack - -Today `caos-test` brings a stack up as children of one job and it dies with -that job. As a daemonic actor: - -- **State** is a small manifest: the stack's address and the image digest it - runs. It is *not* the stack's data (Redis, volumes); those are rebuilt or - left in the persistent volume the runner already mounts. -- **The daemon** is the container running `stack/serve`. It claims the actor, - publishes its address into the branch, and serves "run this" requests. -- **Conversations driving the stack** read the address from the branch, then - talk to the stack over the runner network. Only the coordination (who owns - the stack, where it is) is in Git. - -This decouples the stack's lifetime from any single test job. - -## What caos changes - -| change | needed for | notes | -|---|---|---| -| **None** in the server | pure | CAS push, routing and non-caching of failures already exist | -| Bind actor workers to `git-runner` in their `.caos-expr` | all | same as `llm-step`, `run-and-update-ref` | -| A small `actor` helper in `worker-common` (fetch, CAS push, ambiguous-push handling, claim/lease for daemons) | all | so authors do not each reimplement it | -| A worker can register as a runner (poll/result with the runner token the job payload carries) | daemonic | confirm what `caos runner` exposes to a worker; `runner-protocol.md` describes the nesting rule | -| Refresh the `runner-protocol.md` status line | docs | | +### 2. Failures are not cached; successes are + +In `compute.rs` (`run_dispatch_inner`), only an `Ok` result reaches +`cache_set`; an `Err` is returned uncached. Successes are cached under the +ArgTree hash with no expiry (Redis is best-effort, and the key is namespaced by +`cache_namespace`). I did not find the server retrying a failed job by itself, +so the retry in rule 3 is the **caller's**. + +### 3. No credentials needed + +The Git paths are unauthenticated: `handle()` in `main.rs` routes them before +anything else, and only `/runner/*` checks a token. A wrapper needs only +`CAOS_SERVER_URL`, which every worker has. + +### 4. Existing machinery to reuse + +`run-and-update-ref` already has the start/finish structure (start emits +`run-request-then`, finish updates a ref) and `refs.rs` has the exact-ref fetch +and lease-push logic for conversation refs. The actor wrapper should reuse or +factor out that code rather than duplicate it. I have read only its header and +its use of `CAOS_SERVER_URL`, so how cleanly it separates from conversation +semantics is unverified. + +### Caveats + +- Anyone who can reach the server can rewrite an actor branch. Conversation refs + already accept this; actors inherit it. +- Git advertises every ref on every push and fetch, so the number of actors is a + soft scaling limit. +- GC is deliberately off, so actor history is never reclaimed. + +## Build plan + +The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` +(`rustc` factory, `git-runner` as `--output-runner`). It depends on +`worker-common` and the shared ref code from item 4 above. + +0. **Spike (verify before building).** + - Build a root tree and a commit from oids alone, with no checkout + (`worker-common` has `write_commit` and `write_commit_as`; I have not + confirmed they take a tree oid). + - Confirm the shallow-fetch-then-push path works for a commit built that way. + - Confirm a start/finish pair can carry the observed head through the + callback. +1. **Wrapper and a reference inner.** The inner is a small key-value actor + (`put`, `get`; `put` is idempotent) in a non-runner image, which exercises + the "any image" claim. +2. **Tests.** + - concurrent `put`s to one actor, retried on failure, converge with no lost + update; + - a forced lost race fails the request and is not cached; + - a crash after the push followed by a retry reaches the same state; + - a read makes no commit; + - **laziness:** a state with many entries and a message touching one of them + fetches only that entry's objects (assert on the server's object reads); + - the inner's result is a cache hit when state and message repeat. +3. **Docs.** Refresh the `runner-protocol.md` status line (it still says "not + yet implemented", but `runner.rs` implements it), and link this doc. ## Non-goals - Server-side ordering, leases or ref policy. The server stays transport. -- A built-in checkpoint or Start message. - Built-in dedupe, reply caching or exactly-once delivery. -- Strong isolation between actors (Git transport is unauthenticated today). +- External effects. The inner must be pure; effectful actors are an optional + later pattern (a write-ahead claim commit under `.actor/`). +- Strong isolation between actors. + +## Deferred: daemons + +Daemons are not designed here. The direction I would take when we return to +them: keep **state and liveness** in a pure actor (messages like `claim`, +`renew` and `release` with a lease), run the live process as a **detached +long-running job in the author's own image** whose supervisor only sends caos +requests, and let clients reach it directly at an address recorded in the actor +state. That keeps git and the runner token out of the author's image. The +unverified parts are how a custom image also gets the supervisor, how a detached +job behaves across a server restart, runner-network reachability, and idle +detection. ## Open questions -1. **Cold start and routing** for daemons (above): front request with a - forwarding hop, or caller-side addressing? Does forwarding via `/sub-run` - work when the target is a parked runner? -2. **Lease source.** Workers judge expiry by their own clocks. Clock skew only - affects *when a takeover is attempted*; safety comes from the CAS chain. Is - that enough, or should the server expose a time/lease primitive? The runner - protocol already lists lease-based dead-worker detection as future work. -3. **History growth.** Every state change is a commit and GC is off. A hot actor - will grow the store. Options: periodic squash into a new root, or a - per-actor compaction worker. -4. **Caller retry.** The design assumes callers retry a failed request. Which +1. **Caller retry.** The design assumes callers retry a failed request. Which layer owns that for conversations and `map-then`, and does it back off under contention on a hot actor? +2. **History growth.** Every state change is a commit and GC is off. Options: + periodic squash into a new root, or a per-actor compaction worker. +3. **Read consistency.** A read observes some head during its run and is not + linearized against concurrent writes. Is that acceptable, or should a read + optionally confirm the head at the end? +4. **Large states.** A state change that touches one path rewrites one path's + spine in the tree. Does the inner have an easy way to build a new state from + the old by oid, with no checkout? This is the main usability question for + authors, and the `state-out` helper in `worker-common` is meant to answer it. 5. **Authorization.** Do we want per-namespace write control on Git pushes before actors hold anything sensitive? From 3941c298ad1d0d5c09a970881610e17be9fef844 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 07/92] Edit source tree --- design/actors.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/design/actors.md b/design/actors.md index 66a7f0bf..354ac4c7 100644 --- a/design/actors.md +++ b/design/actors.md @@ -64,14 +64,13 @@ one per attempt are both correct, because messages are idempotent. ``` state/ the actor's state: an ordinary tree, opaque to caos -.actor/ reserved for actor bookkeeping; empty today ``` -The branch tree is `{state, .actor}`. The wrapper hands the inner only the -`state/` subtree, so the inner never sees bookkeeping. Every update is **one -commit whose parent is the observed head**, a linear chain. Reserving `.actor/` -now keeps room for later features (claims, counters) without changing the -layout. +The branch tree is `{state}`. The wrapper hands the inner only the `state/` +subtree, so everything else in the tree is the wrapper's. Nothing else is used +today and nothing is reserved; a later feature (claims, counters) can add a +sibling of `state/` without changing the inner's view. Every update is **one +commit whose parent is the observed head**, a linear chain. ### The inner actor From 0fd30d0a1d36af0de217925b5aaff4965fa7aa41 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 08/92] Edit source tree --- design/actors.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index 354ac4c7..c2819d09 100644 --- a/design/actors.md +++ b/design/actors.md @@ -55,7 +55,11 @@ An actor request is a call to the `actor` wrapper tool with these args: | `actor` | the branch: `refs/heads/actors/` | | `inner` | the inner actor: any caos worker request template, in any image | | `nonce` | any value that makes this ArgTree unique, so the outer request is never answered from the cache | -| message args | whatever the inner expects; passed through to it unchanged | +| `message` | the message: a blob or a tree, opaque to the wrapper and passed to the inner unchanged | + +`message` is one entry so that a message field can never collide with `state` +or with the wrapper's own args, and so the inner's input and output are +symmetric (`{state, message}` in, `{state, reply}` out). The nonce is only a cache-buster. Reusing it across retries or minting a new one per attempt are both correct, because messages are idempotent. From 8dc364ac07e9411da5a9b3afed505a5a2acbe85a Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 09/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index c2819d09..b24e9a0e 100644 --- a/design/actors.md +++ b/design/actors.md @@ -83,7 +83,7 @@ The inner is an ordinary caos worker with **any image**, including a | | | |---|---| -| in | `state`: the tree oid of the `state/` subtree (empty for a new actor), read lazily with `caos get /cas/args/state/`; plus the message args | +| in | `/cas/args` with two entries, read lazily with `caos get`: `state`, the tree oid of the `state/` subtree (empty for a new actor), and `message` | | out | `/cas/out`: a tree `{state, reply}`. `state` is the new state tree (staged with `caos put`); `reply` is a blob or tree | Two choices here differ from a first instinct: From 568422bb87410e1291c7e9ebd425f16f6c12a078 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 10/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index b24e9a0e..2ba055cd 100644 --- a/design/actors.md +++ b/design/actors.md @@ -119,7 +119,7 @@ while the inner runs, and it needs `git` (selected through `git-runner` in its 1. Read the branch head with an exact-ref, shallow, tree-filtered fetch. Absent branch means empty state. 2. Take the `state/` subtree oid from the head. -3. Build the inner request R from `inner`, the state oid and the message args. +3. Build the inner request R from `inner`, `state` (the oid) and `message`. 4. Emit `run-request-then R`, carrying the observed head (and the branch name) into the callback. From 41d6471c20a3eaa6e68c4fb1de1f604d53ee9838 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 11/92] Edit source tree --- design/actors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 2ba055cd..654ea3fe 100644 --- a/design/actors.md +++ b/design/actors.md @@ -126,8 +126,8 @@ while the inner runs, and it needs `git` (selected through `git-runner` in its **Finish**, given R's result `{state, reply}` and the observed head: 1. If `state` equals the input state, return `reply`. Nothing to publish. -2. Otherwise build the root tree `{state: , .actor: }` - by oid, with no checkout, and a commit on the observed head. +2. Otherwise build the root tree `{state: }` by oid, with no + checkout, and a commit on the observed head. 3. Store the commit through the object API, then fetch just that commit into a throwaway scratch repository (origin `CAOS_SERVER_URL`). 4. Push with `git push --force-with-lease=: :`. From 8a5e0069f390719360c9513cfc2743b9dc4b1168 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 12/92] Edit source tree --- design/actors.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/design/actors.md b/design/actors.md index 654ea3fe..311e8ca8 100644 --- a/design/actors.md +++ b/design/actors.md @@ -183,12 +183,22 @@ anything else, and only `/runner/*` checks a token. A wrapper needs only ### 4. Existing machinery to reuse -`run-and-update-ref` already has the start/finish structure (start emits -`run-request-then`, finish updates a ref) and `refs.rs` has the exact-ref fetch -and lease-push logic for conversation refs. The actor wrapper should reuse or -factor out that code rather than duplicate it. I have read only its header and -its use of `CAOS_SERVER_URL`, so how cleanly it separates from conversation -semantics is unverified. +**`std/run-and-update-ref`** is the async worker behind `llm-step`'s +`run_async` and `spawn_agent` tools (bound in `std/llm-step/.caos-expr`, tested +in `tests/run-and-update-ref`). For `run_async` it runs an already-built +request and appends the task's terminal status, with the result oid, to the +conversation ref that started it. For `spawn_agent` it checkpoints the child +conversation's head onto the parent. It has the start/finish structure the +actor wrapper wants (start emits `run-request-then`, finish updates a ref), and +`refs.rs` has the exact-ref fetch and lease-push logic for conversation refs. +The actor wrapper should reuse or factor out that code rather than duplicate +it. I have read only its header and its use of `CAOS_SERVER_URL`, so how cleanly +it separates from conversation semantics is unverified. + +**`TreeBuilder`** (`conversation_protocol::v3::tree`) builds trees by oid with +no checkout: `put_oid(path, mode, oid)`, `delete(path)`, `build(store)`. +`llm-step` uses it to seed a child conversation from the parent's tree. The +wrapper can use it to build `{state: }` the same way. ### Caveats From 2d8391533b511df12b429a9108dfa4b16134450b Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 13/92] Edit source tree --- design/actors.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 311e8ca8..24716ef7 100644 --- a/design/actors.md +++ b/design/actors.md @@ -216,8 +216,9 @@ The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` 0. **Spike (verify before building).** - Build a root tree and a commit from oids alone, with no checkout - (`worker-common` has `write_commit` and `write_commit_as`; I have not - confirmed they take a tree oid). + (`TreeBuilder` builds the tree; `worker-common` has `write_commit` and + `write_commit_as`, and I have not confirmed they take a tree oid or that + `TreeBuilder` can write to the store a wrapper has). - Confirm the shallow-fetch-then-push path works for a commit built that way. - Confirm a start/finish pair can carry the observed head through the callback. From 28d7766b4ca1141520949cdd54d994cc2f556b0a Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 14/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index 24716ef7..028863d9 100644 --- a/design/actors.md +++ b/design/actors.md @@ -242,7 +242,7 @@ The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` - Server-side ordering, leases or ref policy. The server stays transport. - Built-in dedupe, reply caching or exactly-once delivery. - External effects. The inner must be pure; effectful actors are an optional - later pattern (a write-ahead claim commit under `.actor/`). + later pattern (a write-ahead claim commit in a sibling of `state/`). - Strong isolation between actors. ## Deferred: daemons From 7bd0408448c9b14766b325a92063478bba54809b Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 15/92] Edit source tree --- design/actors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/actors.md b/design/actors.md index 028863d9..4888cc95 100644 --- a/design/actors.md +++ b/design/actors.md @@ -52,7 +52,7 @@ An actor request is a call to the `actor` wrapper tool with these args: | arg | meaning | |---|---| -| `actor` | the branch: `refs/heads/actors/` | +| `state-ref` | the branch holding the actor's state: `refs/heads/actors/` | | `inner` | the inner actor: any caos worker request template, in any image | | `nonce` | any value that makes this ArgTree unique, so the outer request is never answered from the cache | | `message` | the message: a blob or a tree, opaque to the wrapper and passed to the inner unchanged | From 32fa00daf23e4fa357983a3185807ba2f45dddae Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 16/92] Edit source tree --- design/actors.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 4888cc95..126964c2 100644 --- a/design/actors.md +++ b/design/actors.md @@ -116,8 +116,10 @@ while the inner runs, and it needs `git` (selected through `git-runner` in its **Start** -1. Read the branch head with an exact-ref, shallow, tree-filtered fetch. Absent - branch means empty state. +1. Read the branch head's oid with `git ls-remote`, then fetch **only that + commit** (`--depth=1 --filter=tree:0`; the server allows filters and + fetch-by-oid). The commit names its root tree, and the `state/` entry in that + tree gives the state oid. Absent branch means empty state. 2. Take the `state/` subtree oid from the head. 3. Build the inner request R from `inner`, `state` (the oid) and `message`. 4. Emit `run-request-then R`, carrying the observed head (and the branch name) From 2a0d0c079365047e2128eff2f583609f521d9869 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 17/92] Edit source tree --- design/actors.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 126964c2..9ea16109 100644 --- a/design/actors.md +++ b/design/actors.md @@ -130,8 +130,9 @@ while the inner runs, and it needs `git` (selected through `git-runner` in its 1. If `state` equals the input state, return `reply`. Nothing to publish. 2. Otherwise build the root tree `{state: }` by oid, with no checkout, and a commit on the observed head. -3. Store the commit through the object API, then fetch just that commit into a - throwaway scratch repository (origin `CAOS_SERVER_URL`). +3. Make the commit available to a throwaway scratch repository (origin + `CAOS_SERVER_URL`) **without downloading the new state tree**. The new state + exists only on the server, so this is the main technical risk; see the spike. 4. Push with `git push --force-with-lease=: :`. - ok: return `reply`; - lease rejected: **fail the request**; the caller retries; From 34b851a2c691c96f02776f861a4925b262fdf99b Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 18/92] Edit source tree --- design/actors.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/design/actors.md b/design/actors.md index 9ea16109..ec60865e 100644 --- a/design/actors.md +++ b/design/actors.md @@ -194,9 +194,26 @@ conversation ref that started it. For `spawn_agent` it checkpoints the child conversation's head onto the parent. It has the start/finish structure the actor wrapper wants (start emits `run-request-then`, finish updates a ref), and `refs.rs` has the exact-ref fetch and lease-push logic for conversation refs. -The actor wrapper should reuse or factor out that code rather than duplicate -it. I have read only its header and its use of `CAOS_SERVER_URL`, so how cleanly -it separates from conversation semantics is unverified. +The conversation semantics live in `refs.rs`, but the Git plumbing it uses is +generic and sits in the `conversation-protocol` crate (`git-cli` feature), which +the actor wrapper can depend on directly: + +- `GitStore::scratch(name, remote)` makes a bare scratch repo whose `origin` is + the server. `read_ref` is a cheap `ls-remote`. `push(&[RefUpdate])` pushes + with `--force-with-lease=:` (and `--atomic` for several refs). + `GitStore` also implements `ObjectStore` (`read_tree`, `write_tree`, + `write_commit`, ...), so it can write the commit. +- `cas_append` in `refs.rs` is the right ambiguous-push rule, already tested + with a fake store: after a failed push, re-read the ref; if the candidate is an + ancestor of the observed head the push succeeded; if the head is unchanged the + failure is real; otherwise it was a lost race. + +**One thing not to reuse as is:** `GitStore::fetch_ref` fetches with no depth +and no filter into a scratch repo that is cleared for every job. For a +conversation ref that is cheap by design (its trees hold gitlinks). For an actor +it would download the whole history and the whole state closure on every +request. The wrapper must use `read_ref` plus a depth-1, `tree:0` fetch of the +head commit instead. **`TreeBuilder`** (`conversation_protocol::v3::tree`) builds trees by oid with no checkout: `put_oid(path, mode, oid)`, `delete(path)`, `build(store)`. From 30b2e79cf8f5ac3c470bfeba0b29b438a797de70 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 19/92] Edit source tree --- design/actors.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/design/actors.md b/design/actors.md index ec60865e..880742a1 100644 --- a/design/actors.md +++ b/design/actors.md @@ -234,12 +234,18 @@ The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` (`rustc` factory, `git-runner` as `--output-runner`). It depends on `worker-common` and the shared ref code from item 4 above. -0. **Spike (verify before building).** - - Build a root tree and a commit from oids alone, with no checkout - (`TreeBuilder` builds the tree; `worker-common` has `write_commit` and - `write_commit_as`, and I have not confirmed they take a tree oid or that - `TreeBuilder` can write to the store a wrapper has). - - Confirm the shallow-fetch-then-push path works for a commit built that way. +0. **Spike (verify before building).** An integration test against the test + stack, using real `git`: + - **Push a commit whose new state tree exists only on the server.** Stage a + state tree with `caos put`, build a commit on the observed head that points + at it (`TreeBuilder` plus `write_commit`), and push it with a lease, without + ever fetching that tree into the scratch repo. My best guess is a partial + (promisor) scratch repo, where the missing objects are "promised" and the + push sends nothing the server lacks; I have not tried it. Fallbacks, both + worse: fetch the state closure (fine for small state, but it breaks the + no-manifest rule), or add a small push-by-oid endpoint to the server (which + breaks "no server change"). + - Confirm the depth-1, `tree:0` head fetch reads the state oid cheaply. - Confirm a start/finish pair can carry the observed head through the callback. 1. **Wrapper and a reference inner.** The inner is a small key-value actor From 5affb95c4149bfbd48174a4638128b23f91c5e42 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 20/92] Edit source tree --- std/actor/.caos-expr | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 std/actor/.caos-expr diff --git a/std/actor/.caos-expr b/std/actor/.caos-expr new file mode 100644 index 00000000..55cd7ba9 --- /dev/null +++ b/std/actor/.caos-expr @@ -0,0 +1,5 @@ +# Build the actor wrapper from its Rust project (design/actors.md). One binary, +# two positions like run-and-update-ref: start reads the branch head and tail-calls +# the inner request; finish publishes the new state with a leased push. It selects +# git-runner because both positions use ordinary Git. +run --base:@=DEEP-DEPS/rustc --src:@=. --dep0:@=DEEP-DEPS/conversation-protocol --dep1:@=DEEP-DEPS/git-locator --output-runner:@=DEEP-DEPS/git-runner From 8d811ff993e0f9d3aa6ae346fab6595e67d0068d Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 21/92] Edit source tree --- std/actor/DEPS | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 std/actor/DEPS diff --git a/std/actor/DEPS b/std/actor/DEPS new file mode 100644 index 00000000..d4ed73a0 --- /dev/null +++ b/std/actor/DEPS @@ -0,0 +1,7 @@ +# Same shape as run-and-update-ref: the rustc worker factory compiles this source +# with the shared conversation protocol (for GitStore/TreeBuilder-style plumbing) +# and curries the result onto the opt-in Git runner. +../rustc rustc +../git-runner git-runner +../../rust/crates/conversation-protocol conversation-protocol +../../rust/crates/git-locator git-locator From 1295152ce7b46cbb6b2a9ccdeb2ab2ad9fb96073 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 22/92] Edit source tree --- std/actor/Cargo.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 std/actor/Cargo.toml diff --git a/std/actor/Cargo.toml b/std/actor/Cargo.toml new file mode 100644 index 00000000..5e0ad9db --- /dev/null +++ b/std/actor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "actor" +version = "0.0.0" +edition = "2021" + +[[bin]] +name = "worker" +path = "src/main.rs" + +[dependencies] +worker-common = { path = "worker-common" } +conversation-protocol = { path = "conversation-protocol", features = ["git-cli"] } + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev.build-override] +opt-level = 0 From 7195371d33e851f4ebe4470a4f5d72b0feaa4b24 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 23/92] Edit source tree --- std/actor/src/main.rs | 212 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 std/actor/src/main.rs diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs new file mode 100644 index 00000000..c318bac8 --- /dev/null +++ b/std/actor/src/main.rs @@ -0,0 +1,212 @@ +//! The actor wrapper (design/actors.md): run an inner `(state, message) -> +//! (state', reply)` request against state kept on a Git branch, and publish the +//! new state with a compare-and-swap push. +//! +//! `Q = actor { state-ref, inner, nonce, message }` has two positions: +//! +//! - start reads the branch head (`ls-remote`, then a depth-1 `tree:0` fetch of +//! that one commit), takes the `state/` subtree oid from the head, builds the +//! inner request and tail-calls it with Q (plus the observed head and the +//! input state) as the callback; +//! - finish receives the inner's `{state, reply}`. An unchanged state returns +//! the reply without touching Git; otherwise it commits `{state: }` +//! on the observed head and pushes with `--force-with-lease`. A lost race +//! fails the request, which is never cached, so the caller retries. +//! +//! Neither position checks the state out: it travels as a tree oid. + +use std::path::Path; +use std::process::{Command, ExitCode}; + +use conversation_protocol::v3::{ + CommitInfo, GitStore, Mode, ObjectStore, Oid, RefUpdate, Signature, TreeEntry, +}; +use worker_common::{ + arg, caos, caos_curry, cas_hash, forward, own_args_tree, prepare_request, read_arg, run_worker, + run_request_then, scratch, Arg, +}; + +const STATE_ENTRY: &str = "state"; +const NO_HEAD: &str = "none"; +const GIT_DIR: &str = "/tmp/actor-git"; + +fn main() -> ExitCode { + run_worker("actor", run) +} + +fn run() -> Result<(), String> { + let state_ref = read_arg("state-ref")?; + if !state_ref.starts_with("refs/heads/actors/") || state_ref.contains("..") { + return Err(format!( + "state-ref {state_ref:?} must be under refs/heads/actors/" + )); + } + if Path::new(&arg("result")).exists() { + finish(&state_ref) + } else { + start(&state_ref) + } +} + +fn server_url() -> Result { + let url = std::env::var("CAOS_SERVER_URL").map_err(|_| "CAOS_SERVER_URL not set".to_string())?; + Ok(url.trim_end_matches('/').to_string()) +} + +fn git(args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .arg("-C") + .arg(GIT_DIR) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .map_err(|e| format!("running git: {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "git {}: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +/// A bare scratch repository whose origin is the server and which treats it as +/// a promisor remote, so objects that exist only on the server (the actor's +/// state trees) are "promised" and are neither downloaded nor re-sent. +fn promisor_store() -> Result { + let store = GitStore::scratch("actor-git", &server_url()?)?; + git(&["config", "core.repositoryformatversion", "1"])?; + git(&["config", "extensions.partialClone", "origin"])?; + git(&["config", "remote.origin.promisor", "true"])?; + git(&["config", "remote.origin.partialclonefilter", "tree:0"])?; + Ok(store) +} + +/// The `state/` subtree oid of `head`, reading only the commit and its root tree. +fn state_of(store: &GitStore, head: &Oid) -> Result, String> { + git(&[ + "fetch", + "--quiet", + "--no-tags", + "--no-write-fetch-head", + "--depth=1", + "--filter=tree:0", + "origin", + head.as_str(), + ])?; + let commit = store.read_commit(head).map_err(String::from)?; + let root = store.read_tree(&commit.tree).map_err(String::from)?; + Ok(root + .into_iter() + .find(|entry| entry.name == STATE_ENTRY) + .map(|entry| entry.oid)) +} + +/// The oid of the empty tree, as a CAS object (so it can be bound by path). +fn empty_state() -> Result { + let dir = scratch("actor-empty-state")?; + caos(["put", worker_common::path(&dir), "/cas/empty-state"])?; + cas_hash("/cas/empty-state") +} + +fn start(state_ref: &str) -> Result<(), String> { + let store = promisor_store()?; + let head = store.read_ref(state_ref)?; + let state = match &head { + Some(head) => state_of(&store, head)?, + None => None, + }; + let (state_path, state_oid) = match state { + Some(oid) => { + caos(["get-hash", oid.as_str(), "/cas/state"])?; + ("/cas/state", oid.to_string()) + } + None => ("/cas/empty-state", empty_state()?), + }; + if state_path == "/cas/empty-state" && !Path::new(state_path).exists() { + return Err("empty state was not staged".to_string()); + } + + let request = prepare_request( + Arg::Path(&arg("inner")), + &[ + ("state", Arg::Path(state_path)), + ("message", Arg::Path(&arg("message"))), + ], + )?; + + // The callback is this same Q, carrying what finish needs to publish. + let q = own_args_tree()?; + let head_text = head.as_ref().map(Oid::as_str).unwrap_or(NO_HEAD); + let callback = caos_curry( + Arg::Hash(&q), + &[ + ("head", Arg::Lit(head_text)), + ("old-state", Arg::Lit(&state_oid)), + ], + )?; + run_request_then(&request, Some(Arg::Hash(&callback))) +} + +fn finish(state_ref: &str) -> Result<(), String> { + let result = arg("result"); + // List the result's children as hash-tagged entries; nothing is downloaded. + caos(["get", &result])?; + let new_state = cas_hash(&format!("{result}/{STATE_ENTRY}"))?; + let old_state = read_arg("old-state")?; + if new_state != old_state { + let head = match read_arg("head")?.as_str() { + NO_HEAD => None, + oid => Some(Oid::parse(oid, "head")?), + }; + publish(state_ref, head, Oid::parse(&new_state, "new state")?)?; + } + forward(&format!("{result}/reply"), "/cas/out") +} + +fn publish(state_ref: &str, head: Option, new_state: Oid) -> Result<(), String> { + let mut store = promisor_store()?; + let tree = store + .write_tree(&[TreeEntry { + name: STATE_ENTRY.to_string(), + mode: Mode::Tree, + oid: new_state, + }]) + .map_err(String::from)?; + let identity = Signature { + name: "actor".to_string(), + email: "actor@caos".to_string(), + time: 0, + offset: "+0000".to_string(), + }; + let candidate = store + .write_commit(&CommitInfo { + tree, + parents: head.iter().cloned().collect(), + author: identity.clone(), + committer: identity, + extra_headers: Vec::new(), + message: b"actor state\n".to_vec(), + }) + .map_err(String::from)?; + + let push_error = match store.push(&[RefUpdate { + refname: state_ref.to_string(), + expected: head.clone(), + new: Some(candidate.clone()), + }]) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + // Ambiguous failure: re-read the ref to learn what actually happened. + match store.read_ref(state_ref) { + Ok(Some(observed)) if observed == candidate => Ok(()), + Ok(observed) if observed == head => Err(format!("pushing {state_ref}: {push_error}")), + Ok(_) => Err(format!("lost the race for {state_ref}: {push_error}")), + Err(read_error) => Err(format!( + "pushing {state_ref} failed ({push_error}); rereading it also failed: {read_error}" + )), + } +} From 52b0c303e4fd5e6fb4b4f5b3f15cb0c30230247d Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 24/92] Edit source tree --- tests/actor/kv.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/actor/kv.sh diff --git a/tests/actor/kv.sh b/tests/actor/kv.sh new file mode 100644 index 00000000..f086c420 --- /dev/null +++ b/tests/actor/kv.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Reference inner actor (design/actors.md): a key-value store in plain bash, run +# in std/bash, which is not a runner image. A pure function of +# (state tree, message) -> {state, reply}. Messages are idempotent: +# put set key (applying twice is the same as once) +# get reply with the value, state unchanged +set -euo pipefail + +caos get /cas/args/message +read -r op key value < /cas/args/message +case "$key" in + ''|*/*|.*) echo "kv: bad key: $key" >&2; exit 1 ;; +esac + +# List the state's entries without reading their content. +caos get /cas/args/state + +rm -rf /tmp/out +mkdir -p /tmp/out +case "$op" in +put) + mkdir /tmp/out/state + for entry in /cas/args/state/*; do + [ -e "$entry" ] || continue + name=$(basename "$entry") + if [ "$name" != "$key" ]; then ln -s "$entry" "/tmp/out/state/$name"; fi + done + printf '%s\n' "$value" > "/tmp/out/state/$key" + printf 'ok\n' > /tmp/out/reply + ;; +get) + ln -s /cas/args/state /tmp/out/state + if [ -e "/cas/args/state/$key" ]; then + caos get "/cas/args/state/$key" + cp "/cas/args/state/$key" /tmp/out/reply + else + : > /tmp/out/reply + fi + ;; +*) echo "kv: unknown op: $op" >&2; exit 1 ;; +esac +caos put /tmp/out /cas/out From 926c89c8e907d2c72e4b54dcb9d39b08edd03aba Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 25/92] Edit source tree --- tests/actor/.caos-expr | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 tests/actor/.caos-expr diff --git a/tests/actor/.caos-expr b/tests/actor/.caos-expr new file mode 100644 index 00000000..9e65d31b --- /dev/null +++ b/tests/actor/.caos-expr @@ -0,0 +1,4 @@ +# tests/actor, as an ENTRY: a worker test (dev/worker-test has git, which is how +# it reads the actor's branch on the server) driving std/actor with a reference +# key-value inner that runs in std/bash. +curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh --bash:@=DEEP-DEPS/bash --actor:@=DEEP-DEPS/actor --kv:@=kv.sh From d7abbfbce80842f2a8addc707eb02f91ac23b749 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 26/92] Edit source tree --- tests/actor/DEPS | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/actor/DEPS diff --git a/tests/actor/DEPS b/tests/actor/DEPS new file mode 100644 index 00000000..68ee9871 --- /dev/null +++ b/tests/actor/DEPS @@ -0,0 +1,5 @@ +# What this test reaches for (format ` `). +../../std/bash bash +../../std/actor actor +# A worker test that needs git, to read the actor's branch on the server. +../../dev/worker-test worker-test From 68d883b5bae7e4733bc58fa7b434fa56e9967a3f Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 27/92] Edit source tree --- tests/actor/worker.sh | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/actor/worker.sh diff --git a/tests/actor/worker.sh b/tests/actor/worker.sh new file mode 100644 index 00000000..92c6c2d3 --- /dev/null +++ b/tests/actor/worker.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Actor wrapper + reference kv inner, in stages (a worker cannot block on a run, +# so each stage tail-calls the next with run-request-then): +# start put a=1 -> one commit, state/a == 1 +# after-put get a -> reply 1, head unchanged (a read commits nothing) +# after-get put a=1 again -> head unchanged (same state, no commit, no push) +# after-idem put b=2 -> a second commit whose parent is the first +# after-b verify the chain and state, report +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } + +stage=start +if caos get /cas/args/stage 2>/dev/null; then stage=$(cat /cas/args/stage); fi + +caos get /cas/args/test-salt || fail "reading --test-salt" +SALT=$(cat /cas/args/test-salt) + +: "${CAOS_SERVER_URL:?this test needs CAOS_SERVER_URL from the runner}" +rm -rf /tmp/repo +mkdir -p /tmp/repo +cd /tmp/repo +git init -q . +git config user.email test@caos +git config user.name caos +git config gc.auto 0 +git remote add caos "$CAOS_SERVER_URL" + +next() { + local next_stage=$1 + shift + caos curry --base:@=/cas/args/base --worker1:@=/cas/args/worker1 \ + --stage="$next_stage" --test-salt:@=/cas/args/test-salt \ + --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv "$@" +} + +remote_head() { + local line + line=$(git ls-remote --refs caos "$1") || return 1 + [ -n "$line" ] || return 1 + printf '%s\n' "${line%%[[:space:]]*}" +} + +state_file() { # + git fetch -q caos "$1" || fail "fetching $1" + git show "$1:state/$2" +} + +# actor_request : the complete request for one message. +actor_request() { + local message=$1 nonce=$2 inner + printf '%s\n' "$message" > /tmp/msg + rm -f /cas/msg + caos put /tmp/msg /cas/msg > /dev/null || fail "staging the message" + inner=$(caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv) \ + || fail "currying the inner" + caos prepare-request --base:@=/cas/args/actor --state-ref="$STATE_REF" \ + --inner:hash="$inner" --nonce="$nonce-$SALT" --message:@=/cas/msg +} + +call() { # [next args...] + local message=$1 nonce=$2 next_stage=$3 request + shift 3 + request=$(actor_request "$message" "$nonce") || fail "preparing '$message'" + caos run-request-then "$request" --then:hash="$(next "$next_stage" \ + --state-ref="$STATE_REF" "$@")" +} + +if [ "$stage" = start ]; then + STATE_REF="refs/heads/actors/test-$(date +%s%N)-$$-$RANDOM" +else + caos get /cas/args/state-ref || fail "reading --state-ref" + STATE_REF=$(cat /cas/args/state-ref) +fi + +read_arg() { caos get "/cas/args/$1" || fail "reading --$1"; cat "/cas/args/$1"; } + +case "$stage" in +start) + if remote_head "$STATE_REF" > /dev/null; then fail "fresh ref already exists"; fi + call "put a 1" n1 after-put + ;; + +after-put) + h1=$(remote_head "$STATE_REF") || fail "put created no branch" + [ "$(state_file "$h1" a)" = 1 ] || fail "state/a is not 1" + [ "$(git rev-list --count "$h1")" = 1 ] || fail "first update is not a root commit" + call "get a" n2 after-get --h1="$h1" + ;; + +after-get) + h1=$(read_arg h1) + [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "a read changed the head" + call "put a 1" n3 after-idem --h1="$h1" + ;; + +after-idem) + h1=$(read_arg h1) + [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "an unchanged put made a commit" + call "put b 2" n4 after-b --h1="$h1" + ;; + +after-b) + h1=$(read_arg h1) + h2=$(remote_head "$STATE_REF") || fail "branch vanished" + [ "$h2" != "$h1" ] || fail "put b made no commit" + git fetch -q caos "$h2" || fail "fetching $h2" + [ "$(git rev-parse "$h2^1")" = "$h1" ] || fail "second update is not on the first" + [ "$(git rev-list --count "$h2")" = 2 ] || fail "history is not a linear chain of two" + [ "$(state_file "$h2" a)" = 1 ] || fail "state/a lost" + [ "$(state_file "$h2" b)" = 2 ] || fail "state/b missing" + printf 'actor: ALL PASS\n' > /tmp/report + cat /tmp/report >&2 + caos put /tmp/report /cas/out + ;; + +*) fail "unknown --stage: $stage" ;; +esac From 23249e5fc4966923663949035db2f3e50004dd8c Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 28/92] Edit source tree --- std/actor/src/main.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index c318bac8..f067d715 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -84,8 +84,11 @@ fn promisor_store() -> Result { Ok(store) } -/// The `state/` subtree oid of `head`, reading only the commit and its root tree. -fn state_of(store: &GitStore, head: &Oid) -> Result, String> { +/// Fetch exactly one object from the server through the promisor filter: a +/// commit arrives alone (depth 1, no trees), a tree arrives without its +/// children. Everything it references is then "promised", which is what lets a +/// later push traverse it without downloading the rest. +fn fetch_one(oid: &Oid) -> Result<(), String> { git(&[ "fetch", "--quiet", @@ -94,8 +97,13 @@ fn state_of(store: &GitStore, head: &Oid) -> Result, String> { "--depth=1", "--filter=tree:0", "origin", - head.as_str(), - ])?; + oid.as_str(), + ]) +} + +/// The `state/` subtree oid of `head`, reading only the commit and its root tree. +fn state_of(store: &GitStore, head: &Oid) -> Result, String> { + fetch_one(head)?; let commit = store.read_commit(head).map_err(String::from)?; let root = store.read_tree(&commit.tree).map_err(String::from)?; Ok(root From 78da63d0ffbeed635b43604b699ccd11ebdd71f2 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 29/92] Edit source tree --- std/actor/src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index f067d715..12aee3a7 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -176,6 +176,13 @@ fn finish(state_ref: &str) -> Result<(), String> { fn publish(state_ref: &str, head: Option, new_state: Oid) -> Result<(), String> { let mut store = promisor_store()?; + // This container is not start's, so the scratch repo holds nothing: bring in + // the parent commit and the new state's root tree, one object each, so the + // push can traverse the new commit without reading the state's closure. + if let Some(head) = &head { + fetch_one(head)?; + } + fetch_one(&new_state)?; let tree = store .write_tree(&[TreeEntry { name: STATE_ENTRY.to_string(), From 6675acea0ba2614bb938babc32413f5fcae319b7 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 30/92] Edit source tree --- std/actor/src/main.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index 12aee3a7..f6cc82ef 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -88,17 +88,15 @@ fn promisor_store() -> Result { /// commit arrives alone (depth 1, no trees), a tree arrives without its /// children. Everything it references is then "promised", which is what lets a /// later push traverse it without downloading the rest. -fn fetch_one(oid: &Oid) -> Result<(), String> { - git(&[ - "fetch", - "--quiet", - "--no-tags", - "--no-write-fetch-head", - "--depth=1", - "--filter=tree:0", - "origin", - oid.as_str(), - ]) +fn fetch_one(oid: &Oid, shallow: bool) -> Result<(), String> { + let mut args = vec!["fetch", "--quiet", "--no-tags", "--no-write-fetch-head"]; + // A shallow repository cannot push (the server refuses shallow pushes), so + // only start, which merely reads, may cut the history off. + if shallow { + args.push("--depth=1"); + } + args.extend(["--filter=tree:0", "origin", oid.as_str()]); + git(&args) } /// The `state/` subtree oid of `head`, reading only the commit and its root tree. From f16a0f50bd3c8535a64b06dbc7e24aab521aaf98 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 31/92] Edit source tree --- std/actor/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index f6cc82ef..79306877 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -101,7 +101,7 @@ fn fetch_one(oid: &Oid, shallow: bool) -> Result<(), String> { /// The `state/` subtree oid of `head`, reading only the commit and its root tree. fn state_of(store: &GitStore, head: &Oid) -> Result, String> { - fetch_one(head)?; + fetch_one(head, true)?; let commit = store.read_commit(head).map_err(String::from)?; let root = store.read_tree(&commit.tree).map_err(String::from)?; Ok(root From 65a5fca8cfc8ea3f4d39c917fb55fe37e73093f0 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 32/92] Edit source tree --- std/actor/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index 79306877..7b5213f2 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -178,9 +178,9 @@ fn publish(state_ref: &str, head: Option, new_state: Oid) -> Result<(), Str // the parent commit and the new state's root tree, one object each, so the // push can traverse the new commit without reading the state's closure. if let Some(head) = &head { - fetch_one(head)?; + fetch_one(head, false)?; } - fetch_one(&new_state)?; + fetch_one(&new_state, false)?; let tree = store .write_tree(&[TreeEntry { name: STATE_ENTRY.to_string(), From 27681ba6c45a03d1ab193d6e5e55a62b119dc4ab Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 33/92] Edit source tree --- design/actors.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 880742a1..f4c4fda3 100644 --- a/design/actors.md +++ b/design/actors.md @@ -1,7 +1,10 @@ # Actors — persistent state in Git, single writer by compare-and-swap -**Status:** proposal. Nothing here is implemented. Daemons are deliberately set -aside; see [Deferred: daemons](#deferred-daemons). +**Status:** wrapper (`std/actor`) and a reference key-value inner +(`tests/actor`) implemented; the spike is resolved (see [Spike results](#spike-results)). +Still to do: the lost-race, crash-retry, laziness and cache-hit tests from the +build plan, and the docs item. Daemons are deliberately set aside; see +[Deferred: daemons](#deferred-daemons). Builds on [client-owned conversation refs](client-owned-conversation-refs.md) (the Git protocol workers already use for conversation heads) and follows the From ae4ecec5c34d6944abda1f527e28ad57488709c1 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 34/92] Edit source tree --- design/actors.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/design/actors.md b/design/actors.md index f4c4fda3..fbe21929 100644 --- a/design/actors.md +++ b/design/actors.md @@ -266,6 +266,25 @@ The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` 3. **Docs.** Refresh the `runner-protocol.md` status line (it still says "not yet implemented", but `runner.rs` implements it), and link this doc. +### Spike results + +Measured against the test stack with real `git`: + +- **The promisor guess works, with one addition.** A scratch repo configured as + a partial clone of the server (`extensions.partialClone=origin`, + `remote.origin.promisor=true`, filter `tree:0`) can push a commit whose new + state tree was never downloaded, but only if that tree's *root object* was + fetched through the filter first. A commit pointing at an object the repo has + never seen fails in pack-objects (`Could not read `); one fetched with + `--filter=tree:0 origin ` makes its children "promised" and the + push goes through. Cost: one tree object per update. +- **Shallow fetches cannot push.** The server answers `shallow pushes are not + accepted`. Start may read the head with `--depth=1`; finish, which pushes, + fetches the parent commit without depth (commits only, no trees). That is + linear in history length, so it sharpens open question 2 (history growth). +- The start/finish pair carries the observed head and input state through the + callback by currying them onto the wrapper's own ArgTree. + ## Non-goals - Server-side ordering, leases or ref policy. The server stays transport. From 87b9245b685904b88e9e77b6d254ae7762e04e4a Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 35/92] Edit source tree --- std/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/std/README.md b/std/README.md index 489e3b74..2bba238d 100644 --- a/std/README.md +++ b/std/README.md @@ -45,6 +45,7 @@ test suite exercises them. | `llm-call` | A single model call as an entry, for an expression that wants one without a conversation. | | `rgrep` | The search worker behind the step's `grep` tool. | | `run-and-update-ref` | The async worker: one binary, two stages, behind `run_async` and the subagent tools. | +| `actor` | The actor wrapper: runs an inner `(state, message) -> (state', reply)` request against state on a Git branch, publishing with a leased push (`design/actors.md`). | | `hello` | The smallest possible entry, used by `tests/hello` and by hand when something is deeply broken. | | `llm-stub` | A scripted stand-in for the model, so `tests/llm-*` run with no API key and no network. | | `llm-test` / `llm-test-tool` | Fixtures the llm tests drive: a test harness entry and a tool for it to call. | From 2f9e3501d5debaae3016754dc5c27fc8f7f648aa Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 36/92] Edit source tree --- design/actors.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/design/actors.md b/design/actors.md index fbe21929..ca67efb4 100644 --- a/design/actors.md +++ b/design/actors.md @@ -312,6 +312,24 @@ detection. contention on a hot actor? 2. **History growth.** Every state change is a commit and GC is off. Options: periodic squash into a new root, or a per-actor compaction worker. +6. **Finish fetches every commit on the branch.** The spike showed that a push + cannot come from a shallow repository, so finish fetches the parent commit + without `--depth` (`--filter=tree:0`, so commits only, no trees). That + downloads the actor's whole commit history on every write: cost and latency + grow linearly with the number of updates, and GC is off, so the history + never shrinks. Start is unaffected (it reads the head at depth 1). This is + the same problem as question 2 seen from the write path, and it is the + reason that question matters now rather than later. Options to investigate: + - make the parent promised rather than present: write the commit against a + parent the scratch repo has never seen, if git will push it with the + parent in a promisor pack (not tried); + - drop the `shallow` file after a depth-1 fetch, so the repository is + treated as complete while the grandparents stay absent. Pack-objects + walks parents to mark them uninteresting, so this may fail or lazily + fetch the whole chain anyway (not tried); + - squash periodically (question 2), which bounds the chain; + - add a small server-side push-by-oid endpoint, which breaks "no server + change". 3. **Read consistency.** A read observes some head during its run and is not linearized against concurrent writes. Is that acceptable, or should a read optionally confirm the head at the end? From 68ca5677f9422d64923a0f9366865763d8024e96 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 37/92] Edit source tree --- design/runner-protocol.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/design/runner-protocol.md b/design/runner-protocol.md index 54eb6cae..50bc90f1 100644 --- a/design/runner-protocol.md +++ b/design/runner-protocol.md @@ -1,6 +1,7 @@ # Runner protocol — sequential jobs on warm workers -**Status:** design agreed, not yet implemented. Replaces the existing +**Status:** implemented (`rust/crates/server/src/runner.rs`: `/runner/poll`, +`/runner/result`). Replaces the existing backends outright: `dispatch_docker`/`dispatch_serve`/`dispatch_fly`, the `Backend` enum, the worker-slot semaphore, `caos entrypoint`, and `caos serve` are all deleted, and the dev stack gains `caos runnerd` as a required daemon. From ba9cce81f8f37ccf64eef63213dfa3506f7e7f7f Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 38/92] Edit source tree --- design/runner-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/runner-protocol.md b/design/runner-protocol.md index 50bc90f1..a389d19c 100644 --- a/design/runner-protocol.md +++ b/design/runner-protocol.md @@ -7,7 +7,7 @@ backends outright: `dispatch_docker`/`dispatch_serve`/`dispatch_fly`, the are all deleted, and the dev stack gains `caos runnerd` as a required daemon. Builds on the runner-pool decomposition (`runner-pool-and-cloud-builds.md`): that doc removes the per-worker *image*; this one removes the per-job -*container start*. +*container start*. See also [actors](actors.md) for state that outlives a job. --- From 910464ffd2afb1a1c03aa0e75e920bf0686b7442 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 39/92] Edit source tree --- tests/actor/kv.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/actor/kv.sh b/tests/actor/kv.sh index f086c420..afc8e4ee 100644 --- a/tests/actor/kv.sh +++ b/tests/actor/kv.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Reference inner actor (design/actors.md): a key-value store in plain bash, run -# in std/bash, which is not a runner image. A pure function of -# (state tree, message) -> {state, reply}. Messages are idempotent: +# Reference inner actor (design/actors.md): a key-value store in plain bash. A +# pure function of (state tree, message) -> {state, reply}. Messages are +# idempotent: # put set key (applying twice is the same as once) # get reply with the value, state unchanged +# getcheck like get, and fail if any OTHER entry's content was +# materialized: the inner sees the state lazily set -euo pipefail caos get /cas/args/message @@ -28,7 +30,7 @@ put) printf '%s\n' "$value" > "/tmp/out/state/$key" printf 'ok\n' > /tmp/out/reply ;; -get) +get|getcheck) ln -s /cas/args/state /tmp/out/state if [ -e "/cas/args/state/$key" ]; then caos get "/cas/args/state/$key" @@ -36,6 +38,15 @@ get) else : > /tmp/out/reply fi + if [ "$op" = getcheck ]; then + for entry in /cas/args/state/*; do + name=$(basename "$entry") + if [ "$name" != "$key" ] && [ -s "$entry" ]; then + echo "kv: $name was materialized by a read of $key" >&2 + exit 1 + fi + done + fi ;; *) echo "kv: unknown op: $op" >&2; exit 1 ;; esac From 748cfce541993708a510b5b92df85eedb26b2a87 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 40/92] Edit source tree --- tests/actor/probe.sh | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/actor/probe.sh diff --git a/tests/actor/probe.sh b/tests/actor/probe.sh new file mode 100644 index 00000000..be0f7fd9 --- /dev/null +++ b/tests/actor/probe.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# An IMPURE inner, for tests only: it does what kv.sh does, after a side effect +# on the server that the test then observes. Real inners must be pure. +# --race-ref=R if R does not exist yet, push a competing commit to it, so the +# wrapper's leased push (which observed no head) loses the race +# --count-ref=C push one new commit to C per execution, so the number of +# commits on C is the number of times this inner actually ran +set -euo pipefail + +: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL from the runner}" +race_ref="" +count_ref="" +if caos get /cas/args/race-ref 2>/dev/null; then race_ref=$(cat /cas/args/race-ref); fi +if caos get /cas/args/count-ref 2>/dev/null; then count_ref=$(cat /cas/args/count-ref); fi + +rm -rf /tmp/probe +mkdir -p /tmp/probe +cd /tmp/probe +git init -q . +git config user.email probe@caos +git config user.name probe +git config gc.auto 0 +git remote add caos "$CAOS_SERVER_URL" + +head_of() { + local line + line=$(git ls-remote --refs caos "$1") || return 1 + if [ -n "$line" ]; then printf '%s\n' "${line%%[[:space:]]*}"; fi +} + +if [ -n "$race_ref" ] && [ -z "$(head_of "$race_ref")" ]; then + blob=$(printf '0\n' | git hash-object -w --stdin) + sub=$(printf '100644 blob %s\tx\n' "$blob" | git mktree) + root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) + winner=$(git commit-tree "$root" -m "competing writer") + git push -q --force-with-lease="$race_ref:" caos "$winner:$race_ref" +fi + +if [ -n "$count_ref" ]; then + empty=$(git mktree < /dev/null) + prior=$(head_of "$count_ref") + if [ -n "$prior" ]; then + git fetch -q caos "$prior" + run=$(git commit-tree "$empty" -p "$prior" -m "ran $(date +%s%N)-$RANDOM") + else + run=$(git commit-tree "$empty" -m "ran $(date +%s%N)-$RANDOM") + fi + git push -q --force-with-lease="$count_ref:$prior" caos "$run:$count_ref" +fi + +caos get /cas/args/kv +exec bash /cas/args/kv From 6254d3ee09046929d677cb92da7f33daf0f11ede Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 41/92] Edit source tree --- tests/actor/mapper.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/actor/mapper.sh diff --git a/tests/actor/mapper.sh b/tests/actor/mapper.sh new file mode 100644 index 00000000..9bd5f8e4 --- /dev/null +++ b/tests/actor/mapper.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# One concurrent writer for tests/actor. Used as a map-then `map`, it is called +# with --in=; it sends that message to the actor and, when the +# request loses the race for the branch (the wrapper fails it, uncached), sends +# it again with a new nonce, up to MAX attempts. Its callback is this same +# script with --attempt and --msg curried on and --result or --error supplied. +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } +MAX=24 + +if [ -e /cas/args/result ]; then + caos forward /cas/args/result /cas/out + exit 0 +fi + +attempt=0 +if caos get /cas/args/attempt 2>/dev/null; then attempt=$(cat /cas/args/attempt); fi +if [ -e /cas/args/error ]; then + caos get /cas/args/error + attempt=$((attempt + 1)) + [ "$attempt" -lt "$MAX" ] || fail "still losing the race after $MAX attempts: $(cat /cas/args/error)" +fi + +if [ -e /cas/args/msg ]; then msg=/cas/args/msg; else caos get /cas/args/in; msg=/cas/args/in; fi +caos get /cas/args/state-ref /cas/args/test-salt +state_ref=$(cat /cas/args/state-ref) +nonce="$(caos hash "$msg")-$attempt-$(cat /cas/args/test-salt)" + +inner=$(caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv) +request=$(caos prepare-request --base:@=/cas/args/actor --state-ref="$state_ref" \ + --inner:hash="$inner" --nonce="$nonce" --message:@="$msg") || fail "preparing the request" +callback=$(caos curry --base:@=/cas/args/base --worker1:@=/cas/args/worker1 \ + --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ + --state-ref="$state_ref" --test-salt:@=/cas/args/test-salt \ + --attempt="$attempt" --msg:@="$msg") +caos run-request-then "$request" --then:hash="$callback" --catch From e0babc7f8407835330c632a156fe962f172b1cb7 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 42/92] Edit source tree --- tests/actor/.caos-expr | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/actor/.caos-expr b/tests/actor/.caos-expr index 9e65d31b..a6fd3d0f 100644 --- a/tests/actor/.caos-expr +++ b/tests/actor/.caos-expr @@ -1,4 +1,5 @@ # tests/actor, as an ENTRY: a worker test (dev/worker-test has git, which is how # it reads the actor's branch on the server) driving std/actor with a reference -# key-value inner that runs in std/bash. -curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh --bash:@=DEEP-DEPS/bash --actor:@=DEEP-DEPS/actor --kv:@=kv.sh +# key-value inner that runs in std/bash. `probe` is an impure inner and `mapper` +# a concurrent writer, both for the race and concurrency cases. +curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh --bash:@=DEEP-DEPS/bash --actor:@=DEEP-DEPS/actor --kv:@=kv.sh --probe:@=probe.sh --mapper:@=mapper.sh From bd5c65a53733c9ed8f2e34c43c10990dbff02a20 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 43/92] Edit source tree --- tests/actor/mapper.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/actor/mapper.sh b/tests/actor/mapper.sh index 9bd5f8e4..93525258 100644 --- a/tests/actor/mapper.sh +++ b/tests/actor/mapper.sh @@ -23,7 +23,8 @@ if [ -e /cas/args/error ]; then fi if [ -e /cas/args/msg ]; then msg=/cas/args/msg; else caos get /cas/args/in; msg=/cas/args/in; fi -caos get /cas/args/state-ref /cas/args/test-salt +caos get /cas/args/state-ref +caos get /cas/args/test-salt state_ref=$(cat /cas/args/state-ref) nonce="$(caos hash "$msg")-$attempt-$(cat /cas/args/test-salt)" From 91041844775c63ab779677a5b4e966dbbf64d0a8 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 44/92] Edit source tree --- tests/actor/worker.sh | 135 +++++++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 22 deletions(-) diff --git a/tests/actor/worker.sh b/tests/actor/worker.sh index 92c6c2d3..dfdf0a2f 100644 --- a/tests/actor/worker.sh +++ b/tests/actor/worker.sh @@ -3,9 +3,18 @@ # so each stage tail-calls the next with run-request-then): # start put a=1 -> one commit, state/a == 1 # after-put get a -> reply 1, head unchanged (a read commits nothing) -# after-get put a=1 again -> head unchanged (same state, no commit, no push) +# after-get put a=1 again -> head unchanged (same state, no commit, no push). +# This is also the crash-after-push case: a retry +# re-applies the message and reaches the same head. # after-idem put b=2 -> a second commit whose parent is the first -# after-b verify the chain and state, report +# after-b fresh branch, impure inner pushes a competing commit mid-request +# raced the request FAILED (lost race, not cached); the competing head stands; +# send the identical request again +# retried the retry succeeded on top of the winner: state has x (winner) and a +# after-conc 12 concurrent puts (map-then, retried on a lost race) all landed +# after-lazy a read touched one entry and the inner saw the others unmaterialized +# after-hit1/after-hit2 +# the same read twice with different nonces ran the inner once set -euo pipefail fail() { echo "FAIL: $*" >&2; exit 1; } @@ -31,7 +40,9 @@ next() { shift caos curry --base:@=/cas/args/base --worker1:@=/cas/args/worker1 \ --stage="$next_stage" --test-salt:@=/cas/args/test-salt \ - --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv "$@" + --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ + --probe:@=/cas/args/probe --mapper:@=/cas/args/mapper \ + --state-ref="$STATE_REF" "$@" } remote_head() { @@ -46,58 +57,71 @@ state_file() { # git show "$1:state/$2" } -# actor_request : the complete request for one message. +kv_inner() { + caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv +} + +# probe_inner [--race-ref=R] [--count-ref=C]: the impure inner, in this image. +probe_inner() { + caos curry --base:@=/cas/args/base --worker1:@=/cas/args/probe \ + --kv:@=/cas/args/kv "$@" +} + +# actor_request : the complete request for one message. actor_request() { - local message=$1 nonce=$2 inner + local message=$1 nonce=$2 inner=$3 printf '%s\n' "$message" > /tmp/msg rm -f /cas/msg caos put /tmp/msg /cas/msg > /dev/null || fail "staging the message" - inner=$(caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv) \ - || fail "currying the inner" caos prepare-request --base:@=/cas/args/actor --state-ref="$STATE_REF" \ --inner:hash="$inner" --nonce="$nonce-$SALT" --message:@=/cas/msg } -call() { # [next args...] - local message=$1 nonce=$2 next_stage=$3 request - shift 3 - request=$(actor_request "$message" "$nonce") || fail "preparing '$message'" - caos run-request-then "$request" --then:hash="$(next "$next_stage" \ - --state-ref="$STATE_REF" "$@")" +# call [next args...]; CATCH=1 delivers a +# failed request to the next stage as --error instead of failing the test. +call() { + local message=$1 nonce=$2 inner=$3 next_stage=$4 request catch=() + shift 4 + request=$(actor_request "$message" "$nonce" "$inner") || fail "preparing '$message'" + if [ "${CATCH:-}" = 1 ]; then catch=(--catch); fi + caos run-request-then "$request" --then:hash="$(next "$next_stage" "$@")" "${catch[@]}" } +fresh_ref() { printf 'refs/heads/actors/test-%s-%s-%s-%s' "$1" "$(date +%s%N)" "$$" "$RANDOM"; } + +read_arg() { caos get "/cas/args/$1" || fail "reading --$1"; cat "/cas/args/$1"; } + if [ "$stage" = start ]; then - STATE_REF="refs/heads/actors/test-$(date +%s%N)-$$-$RANDOM" + STATE_REF=$(fresh_ref main) else - caos get /cas/args/state-ref || fail "reading --state-ref" - STATE_REF=$(cat /cas/args/state-ref) + STATE_REF=$(read_arg state-ref) fi -read_arg() { caos get "/cas/args/$1" || fail "reading --$1"; cat "/cas/args/$1"; } - case "$stage" in start) if remote_head "$STATE_REF" > /dev/null; then fail "fresh ref already exists"; fi - call "put a 1" n1 after-put + call "put a 1" n1 "$(kv_inner)" after-put ;; after-put) h1=$(remote_head "$STATE_REF") || fail "put created no branch" [ "$(state_file "$h1" a)" = 1 ] || fail "state/a is not 1" [ "$(git rev-list --count "$h1")" = 1 ] || fail "first update is not a root commit" - call "get a" n2 after-get --h1="$h1" + call "get a" n2 "$(kv_inner)" after-get --h1="$h1" ;; after-get) h1=$(read_arg h1) [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "a read changed the head" - call "put a 1" n3 after-idem --h1="$h1" + caos get /cas/args/result || fail "reading the reply" + [ "$(cat /cas/args/result)" = 1 ] || fail "get a replied '$(cat /cas/args/result)'" + call "put a 1" n3 "$(kv_inner)" after-idem --h1="$h1" ;; after-idem) h1=$(read_arg h1) [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "an unchanged put made a commit" - call "put b 2" n4 after-b --h1="$h1" + call "put b 2" n4 "$(kv_inner)" after-b --h1="$h1" ;; after-b) @@ -109,6 +133,73 @@ after-b) [ "$(git rev-list --count "$h2")" = 2 ] || fail "history is not a linear chain of two" [ "$(state_file "$h2" a)" = 1 ] || fail "state/a lost" [ "$(state_file "$h2" b)" = 2 ] || fail "state/b missing" + # A forced lost race: on a fresh branch the impure inner pushes a competing + # commit while the request is in flight, so the wrapper's lease (no head) fails. + STATE_REF=$(fresh_ref race) + CATCH=1 call "put a 1" n5 "$(probe_inner --race-ref="$STATE_REF")" raced + ;; + +raced) + caos get /cas/args/error 2>/dev/null || fail "the raced request did not fail (--error missing)" + winner=$(remote_head "$STATE_REF") || fail "the competing writer left no branch" + [ "$(state_file "$winner" x)" = 0 ] || fail "the head is not the competing commit" + git cat-file -e "$winner:state/a" 2>/dev/null && fail "the lost request published anyway" + # The identical request again (same nonce): a cached failure would replay the + # failure; instead it re-runs against the new head and succeeds. + call "put a 1" n5 "$(probe_inner --race-ref="$STATE_REF")" retried --winner="$winner" + ;; + +retried) + winner=$(read_arg winner) + head=$(remote_head "$STATE_REF") || fail "branch vanished" + [ "$head" != "$winner" ] || fail "the retry published nothing" + git fetch -q caos "$head" || fail "fetching $head" + [ "$(git rev-parse "$head^1")" = "$winner" ] || fail "the retry is not on top of the winner" + [ "$(state_file "$head" x)" = 0 ] || fail "the winner's entry was lost" + [ "$(state_file "$head" a)" = 1 ] || fail "state/a missing after the retry" + # Concurrent writers, each retrying a lost race, must converge with no lost update. + STATE_REF=$(fresh_ref conc) + rm -rf /tmp/msgs + mkdir -p /tmp/msgs + for n in 1 2 3 4 5 6 7 8 9 10 11 12; do printf 'put c%s v%s\n' "$n" "$n" > "/tmp/msgs/m$n"; done + caos put /tmp/msgs /cas/msgs > /dev/null || fail "staging the messages" + mapper=$(caos curry --base:@=/cas/args/base --worker1:@=/cas/args/mapper \ + --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ + --state-ref="$STATE_REF" --test-salt:@=/cas/args/test-salt) + caos map-then /cas/msgs --map:hash="$mapper" --then:hash="$(next after-conc)" + ;; + +after-conc) + head=$(remote_head "$STATE_REF") || fail "no branch after the concurrent puts" + for n in 1 2 3 4 5 6 7 8 9 10 11 12; do + [ "$(state_file "$head" "c$n")" = "v$n" ] || fail "update c$n was lost" + done + [ "$(git rev-list --count "$head")" = 12 ] || fail "expected a linear chain of 12 commits" + call "getcheck c3" n6 "$(kv_inner)" after-lazy --h="$head" + ;; + +after-lazy) + head=$(read_arg h) + [ "$(remote_head "$STATE_REF")" = "$head" ] || fail "a read changed the head" + caos get /cas/args/result || fail "reading the reply" + [ "$(cat /cas/args/result)" = v3 ] || fail "getcheck replied '$(cat /cas/args/result)'" + # The same read twice, different nonces: the inner (pure, so cached) runs once. + count_ref="refs/heads/actors-count/$(date +%s%N)-$$-$RANDOM" + call "get c4" n7 "$(probe_inner --count-ref="$count_ref")" after-hit1 --count-ref="$count_ref" + ;; + +after-hit1) + count_ref=$(read_arg count-ref) + remote_head "$count_ref" > /dev/null || fail "the inner did not run" + call "get c4" n8 "$(probe_inner --count-ref="$count_ref")" after-hit2 --count-ref="$count_ref" + ;; + +after-hit2) + count_ref=$(read_arg count-ref) + last=$(remote_head "$count_ref") || fail "count ref vanished" + git fetch -q caos "$last" || fail "fetching $last" + [ "$(git rev-list --count "$last")" = 1 ] \ + || fail "the inner ran $(git rev-list --count "$last") times; the repeat should hit the cache" printf 'actor: ALL PASS\n' > /tmp/report cat /tmp/report >&2 caos put /tmp/report /cas/out From 43a02e7d21a278373bc05ae6e1584253c210608b Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 45/92] Edit source tree --- design/actors.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index ca67efb4..8db44138 100644 --- a/design/actors.md +++ b/design/actors.md @@ -2,8 +2,14 @@ **Status:** wrapper (`std/actor`) and a reference key-value inner (`tests/actor`) implemented; the spike is resolved (see [Spike results](#spike-results)). -Still to do: the lost-race, crash-retry, laziness and cache-hit tests from the -build plan, and the docs item. Daemons are deliberately set aside; see +`tests/actor` covers the build plan's cases: concurrent writers converging, a +forced lost race that fails uncached and succeeds on retry, an idempotent +re-apply (which is also the crash-after-push retry), a read making no commit, +the inner's lazy view of the state, and the inner's cache hit. Two caveats: a +real crash between push and reply is not injected (a re-applied message is the +same observable), and laziness is checked from inside the inner, not by +counting server object reads. Open question 6 (history fetched on every write) +is unresolved. Daemons are deliberately set aside; see [Deferred: daemons](#deferred-daemons). Builds on [client-owned conversation refs](client-owned-conversation-refs.md) From 52f8771e0a79afaf8f0db33f237dc79e2a3ee048 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 46/92] Edit source tree --- tests/actor-parents/worker.sh | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/actor-parents/worker.sh diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh new file mode 100644 index 00000000..35c14ee0 --- /dev/null +++ b/tests/actor-parents/worker.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# SPIKE (design/actors.md, open question 6): can finish push a commit whose parent +# the scratch repo does not hold, so it need not fetch the whole commit chain? +# Pure git against the server, no actor code. Always fails at the end so the +# report shows in the suite output. +set -uo pipefail + +: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL}" +id="$(date +%s%N)-$$-$RANDOM" +ref="refs/heads/actors/spike-$id" +aux="refs/heads/actors-spike/aux-$id" +N=30 +report=/tmp/report +: > "$report" +say() { echo "$*" | tee -a "$report" >&2; } + +# Seed: a chain of N commits on $ref, and an aux commit whose state/ tree stands +# for "new state that exists only on the server". +rm -rf /tmp/seed +git init -q /tmp/seed +cd /tmp/seed +git config user.email s@caos +git config user.name s +git remote add caos "$CAOS_SERVER_URL" +prev="" +for i in $(seq 1 $N); do + blob=$(printf '%s\n' "$i" | git hash-object -w --stdin) + sub=$(printf '100644 blob %s\tf%s\n' "$blob" "$i" | git mktree) + root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) + if [ -n "$prev" ]; then prev=$(git commit-tree "$root" -p "$prev" -m "c$i"); else prev=$(git commit-tree "$root" -m "c$i"); fi +done +head=$prev +blob=$(printf 'new\n' | git hash-object -w --stdin) +sub=$(printf '100644 blob %s\tnew\n' "$blob" | git mktree) +newroot=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) +auxc=$(git commit-tree "$newroot" -m aux) +git push -q caos "$head:$ref" "$auxc:$aux" || { say "seed push failed"; cat "$report"; exit 1; } +newstate=$(git rev-parse "$newroot:state") +say "seeded: chain of $N, head=$head, new state tree=$newstate" + +# variant : a fresh promisor scratch repo, the same steps +# finish takes, parent handled as described. Prints OK/FAIL and local commit count. +variant() { + local name=$1 dir=/tmp/v-$1 + rm -rf "$dir" + git init -q --bare "$dir" + cd "$dir" + git config user.email v@caos + git config user.name v + git remote add origin "$CAOS_SERVER_URL" + git config core.repositoryformatversion 1 + git config extensions.partialClone origin + git config remote.origin.promisor true + git config remote.origin.partialclonefilter tree:0 + git fetch -q --no-tags --no-write-fetch-head --filter=tree:0 origin "$newstate" 2>/dev/null \ + || { say "[$name] fetching the new state tree failed"; return; } + case $name in + full) git fetch -q --no-tags --no-write-fetch-head --filter=tree:0 origin "$head" ;; + noshallowfile) + git fetch -q --no-tags --no-write-fetch-head --depth=1 --filter=tree:0 origin "$head" + rm -f shallow ;; + rawparent) : ;; # parent never fetched at all + esac + local tree commit + tree=$(printf '040000 tree %s\tstate\n' "$newstate" | git mktree --missing 2>&1) \ + || { say "[$name] mktree: $tree"; return; } + commit=$(printf 'tree %s\nparent %s\nauthor a 0 +0000\ncommitter a 0 +0000\n\nactor state\n' \ + "$tree" "$head" | git hash-object -t commit -w --stdin --literally 2>&1) \ + || { say "[$name] hash-object: $commit"; return; } + local out + if out=$(git push --force-with-lease="$ref:$head" origin "$commit:$ref" 2>&1); then + say "[$name] OK commits held locally: $(git rev-list --count --all --missing=allow-any 2>/dev/null || echo '?')" + # restore the ref so the next variant starts from the same head + git push -q --force origin "$head:$ref" 2>/dev/null + else + say "[$name] FAIL: $(echo "$out" | tr '\n' ' ' | cut -c1-300)" + fi +} + +variant full +variant noshallowfile +variant rawparent +echo "---- spike report ----" >&2 +cat "$report" >&2 +exit 1 From 1bb0fed88a7e9a4d1d9a106f058f2a0e5c65ee2a Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 47/92] Edit source tree --- tests/actor-parents/.caos-expr | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/actor-parents/.caos-expr diff --git a/tests/actor-parents/.caos-expr b/tests/actor-parents/.caos-expr new file mode 100644 index 00000000..538621e8 --- /dev/null +++ b/tests/actor-parents/.caos-expr @@ -0,0 +1,3 @@ +# SPIKE for design/actors.md open question 6 (see worker.sh). Pure git, in the +# worker-test image. +curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh From 6fe01844515df7c0b7308191c3897990de44e8d6 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 48/92] Edit source tree --- tests/actor-parents/DEPS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/actor-parents/DEPS diff --git a/tests/actor-parents/DEPS b/tests/actor-parents/DEPS new file mode 100644 index 00000000..93b4135e --- /dev/null +++ b/tests/actor-parents/DEPS @@ -0,0 +1,2 @@ +# What this test reaches for (format ` `). +../../dev/worker-test worker-test From e462012aa4b9a2055921bc9d56d21850d57e5068 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 49/92] Edit source tree --- tests/actor-parents/worker.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh index 35c14ee0..4866dc41 100644 --- a/tests/actor-parents/worker.sh +++ b/tests/actor-parents/worker.sh @@ -61,11 +61,15 @@ variant() { rm -f shallow ;; rawparent) : ;; # parent never fetched at all esac - local tree commit + local tree commit parentline="parent $head +" + # An orphan names the previous head in its message instead of as a parent: + # the lease still compares against $head, but git ancestry is not involved. + if [ "$name" = orphan ]; then parentline=""; fi tree=$(printf '040000 tree %s\tstate\n' "$newstate" | git mktree --missing 2>&1) \ || { say "[$name] mktree: $tree"; return; } - commit=$(printf 'tree %s\nparent %s\nauthor a 0 +0000\ncommitter a 0 +0000\n\nactor state\n' \ - "$tree" "$head" | git hash-object -t commit -w --stdin --literally 2>&1) \ + commit=$(printf 'tree %s\n%sauthor a 0 +0000\ncommitter a 0 +0000\n\nactor state\n\nPrevious: %s\n' \ + "$tree" "$parentline" "$head" | git hash-object -t commit -w --stdin --literally 2>&1) \ || { say "[$name] hash-object: $commit"; return; } local out if out=$(git push --force-with-lease="$ref:$head" origin "$commit:$ref" 2>&1); then From 4d6d5e9fa1edde34ef0d9365c3ce4dd0e9512f60 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 50/92] Edit source tree --- tests/actor-parents/worker.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh index 4866dc41..4efa8f92 100644 --- a/tests/actor-parents/worker.sh +++ b/tests/actor-parents/worker.sh @@ -59,7 +59,7 @@ variant() { noshallowfile) git fetch -q --no-tags --no-write-fetch-head --depth=1 --filter=tree:0 origin "$head" rm -f shallow ;; - rawparent) : ;; # parent never fetched at all + rawparent|orphan) : ;; # parent never fetched at all esac local tree commit parentline="parent $head " From f5ee09ebd6d063363f6390349cb090ed5c198446 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 51/92] Edit source tree --- tests/actor-parents/worker.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh index 4efa8f92..bde41e41 100644 --- a/tests/actor-parents/worker.sh +++ b/tests/actor-parents/worker.sh @@ -84,6 +84,7 @@ variant() { variant full variant noshallowfile variant rawparent +variant orphan echo "---- spike report ----" >&2 cat "$report" >&2 exit 1 From ccd47c0216017e686e300dd455648aded7f6f5ec Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 52/92] Edit source tree --- tests/actor-parents/.caos-expr | 3 -- tests/actor-parents/DEPS | 2 - tests/actor-parents/worker.sh | 90 ---------------------------------- 3 files changed, 95 deletions(-) delete mode 100644 tests/actor-parents/.caos-expr delete mode 100644 tests/actor-parents/DEPS delete mode 100644 tests/actor-parents/worker.sh diff --git a/tests/actor-parents/.caos-expr b/tests/actor-parents/.caos-expr deleted file mode 100644 index 538621e8..00000000 --- a/tests/actor-parents/.caos-expr +++ /dev/null @@ -1,3 +0,0 @@ -# SPIKE for design/actors.md open question 6 (see worker.sh). Pure git, in the -# worker-test image. -curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh diff --git a/tests/actor-parents/DEPS b/tests/actor-parents/DEPS deleted file mode 100644 index 93b4135e..00000000 --- a/tests/actor-parents/DEPS +++ /dev/null @@ -1,2 +0,0 @@ -# What this test reaches for (format ` `). -../../dev/worker-test worker-test diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh deleted file mode 100644 index bde41e41..00000000 --- a/tests/actor-parents/worker.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash -# SPIKE (design/actors.md, open question 6): can finish push a commit whose parent -# the scratch repo does not hold, so it need not fetch the whole commit chain? -# Pure git against the server, no actor code. Always fails at the end so the -# report shows in the suite output. -set -uo pipefail - -: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL}" -id="$(date +%s%N)-$$-$RANDOM" -ref="refs/heads/actors/spike-$id" -aux="refs/heads/actors-spike/aux-$id" -N=30 -report=/tmp/report -: > "$report" -say() { echo "$*" | tee -a "$report" >&2; } - -# Seed: a chain of N commits on $ref, and an aux commit whose state/ tree stands -# for "new state that exists only on the server". -rm -rf /tmp/seed -git init -q /tmp/seed -cd /tmp/seed -git config user.email s@caos -git config user.name s -git remote add caos "$CAOS_SERVER_URL" -prev="" -for i in $(seq 1 $N); do - blob=$(printf '%s\n' "$i" | git hash-object -w --stdin) - sub=$(printf '100644 blob %s\tf%s\n' "$blob" "$i" | git mktree) - root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) - if [ -n "$prev" ]; then prev=$(git commit-tree "$root" -p "$prev" -m "c$i"); else prev=$(git commit-tree "$root" -m "c$i"); fi -done -head=$prev -blob=$(printf 'new\n' | git hash-object -w --stdin) -sub=$(printf '100644 blob %s\tnew\n' "$blob" | git mktree) -newroot=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) -auxc=$(git commit-tree "$newroot" -m aux) -git push -q caos "$head:$ref" "$auxc:$aux" || { say "seed push failed"; cat "$report"; exit 1; } -newstate=$(git rev-parse "$newroot:state") -say "seeded: chain of $N, head=$head, new state tree=$newstate" - -# variant : a fresh promisor scratch repo, the same steps -# finish takes, parent handled as described. Prints OK/FAIL and local commit count. -variant() { - local name=$1 dir=/tmp/v-$1 - rm -rf "$dir" - git init -q --bare "$dir" - cd "$dir" - git config user.email v@caos - git config user.name v - git remote add origin "$CAOS_SERVER_URL" - git config core.repositoryformatversion 1 - git config extensions.partialClone origin - git config remote.origin.promisor true - git config remote.origin.partialclonefilter tree:0 - git fetch -q --no-tags --no-write-fetch-head --filter=tree:0 origin "$newstate" 2>/dev/null \ - || { say "[$name] fetching the new state tree failed"; return; } - case $name in - full) git fetch -q --no-tags --no-write-fetch-head --filter=tree:0 origin "$head" ;; - noshallowfile) - git fetch -q --no-tags --no-write-fetch-head --depth=1 --filter=tree:0 origin "$head" - rm -f shallow ;; - rawparent|orphan) : ;; # parent never fetched at all - esac - local tree commit parentline="parent $head -" - # An orphan names the previous head in its message instead of as a parent: - # the lease still compares against $head, but git ancestry is not involved. - if [ "$name" = orphan ]; then parentline=""; fi - tree=$(printf '040000 tree %s\tstate\n' "$newstate" | git mktree --missing 2>&1) \ - || { say "[$name] mktree: $tree"; return; } - commit=$(printf 'tree %s\n%sauthor a 0 +0000\ncommitter a 0 +0000\n\nactor state\n\nPrevious: %s\n' \ - "$tree" "$parentline" "$head" | git hash-object -t commit -w --stdin --literally 2>&1) \ - || { say "[$name] hash-object: $commit"; return; } - local out - if out=$(git push --force-with-lease="$ref:$head" origin "$commit:$ref" 2>&1); then - say "[$name] OK commits held locally: $(git rev-list --count --all --missing=allow-any 2>/dev/null || echo '?')" - # restore the ref so the next variant starts from the same head - git push -q --force origin "$head:$ref" 2>/dev/null - else - say "[$name] FAIL: $(echo "$out" | tr '\n' ' ' | cut -c1-300)" - fi -} - -variant full -variant noshallowfile -variant rawparent -variant orphan -echo "---- spike report ----" >&2 -cat "$report" >&2 -exit 1 From 3f9a9d4ac0f869cc58f3b3c2ff18bf88e115a708 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 53/92] Edit source tree --- tests/actor-parents/worker.sh | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/actor-parents/worker.sh diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh new file mode 100644 index 00000000..5299c4b4 --- /dev/null +++ b/tests/actor-parents/worker.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# SPIKE (design/actors.md, open question 6): can finish push a child commit +# without downloading the branch's whole history? Builds a 40-commit branch on +# the server, then for each variant starts from an EMPTY partial-clone scratch +# repo, fetches only the head, and tries to push a child of it. Each variant +# reports whether the push worked and how many commits it had to hold locally. +# The test always fails at the end so that the report is printed. +set -uo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } + +: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL from the runner}" +caos get /cas/args/test-salt || fail "reading --test-salt" +SALT=$(cat /cas/args/test-salt) +N=40 +REPORT="" +note() { REPORT="$REPORT$*"$'\n'; } + +# A 40-commit chain on a fresh server branch; prints the branch ref. +mkchain() { # + local ref="refs/heads/actors/parents-$SALT-$1-$(date +%s%N)-$RANDOM" prev="" i blob sub root c + rm -rf /tmp/setup + mkdir -p /tmp/setup + ( + cd /tmp/setup || exit 1 + git init -q . + git config user.email t@caos + git config user.name t + git remote add caos "$CAOS_SERVER_URL" + for i in $(seq 1 $N); do + blob=$(printf '%s\n' "$i" | git hash-object -w --stdin) + sub=$(printf '100644 blob %s\tv\n' "$blob" | git mktree) + root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) + if [ -n "$prev" ]; then c=$(git commit-tree "$root" -p "$prev" -m "c$i"); else c=$(git commit-tree "$root" -m "c$i"); fi + prev=$c + done + git push -q caos "$prev:$ref" || exit 1 + echo "$prev" > /tmp/setup-head + ) || fail "building the chain for $1" + printf '%s\n' "$ref" +} + +scratch() { # : an empty partial-clone scratch repo, like std/actor's + rm -rf "$1" + mkdir -p "$1" + cd "$1" || exit 1 + git init -q --bare . + git config user.email t@caos + git config user.name t + git config gc.auto 0 + git remote add origin "$CAOS_SERVER_URL" + git config core.repositoryformatversion 1 + git config extensions.partialClone origin + git config remote.origin.promisor true + git config remote.origin.partialclonefilter tree:0 +} + +local_commits() { git cat-file --batch-all-objects --batch-check 2>/dev/null | grep -c ' commit '; } + +# child : a commit on whose state subtree is brand new. +child() { + local blob sub root + blob=$(printf 'next\n' | git hash-object -w --stdin) + sub=$(printf '100644 blob %s\tv\n' "$blob" | git mktree) + root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) + git commit-tree "$root" -p "$1" -m "child" +} + +variant() { # ; then optionally VARIANT_HOOK runs before the push + local name=$1 ref head c out + shift + ref=$(mkchain "$name") + head=$(cat /tmp/setup-head) + scratch "/tmp/scratch-$name" + if ! git fetch -q --no-tags --no-write-fetch-head "$@" origin "$head" 2>/tmp/err; then + note "$name: fetch FAILED: $(tr '\n' ' ' < /tmp/err)" + return + fi + if [ -n "${VARIANT_HOOK:-}" ]; then eval "$VARIANT_HOOK"; fi + c=$(child "$head") || { note "$name: could not write the child commit"; return; } + if out=$(git push --force-with-lease="$ref:$head" origin "$c:$ref" 2>&1); then + note "$name: PUSH OK; commits held locally: $(local_commits) of $N" + else + note "$name: PUSH FAILED ($(printf '%s' "$out" | tr '\n' ' ' | cut -c1-300)); commits held locally: $(local_commits) of $N" + fi +} + +# 1. Baseline, what std/actor does now: the head fetched without --depth. +VARIANT_HOOK="" variant full --filter=tree:0 +# 2. Depth 1 and leave the shallow file in place. +VARIANT_HOOK="" variant shallow --depth=1 --filter=tree:0 +# 3. Depth 1, then drop the shallow file: the head's parent is "promised" (the +# head came in a promisor pack) rather than present. +VARIANT_HOOK='rm -f shallow' variant noshallow --depth=1 --filter=tree:0 + +fail $'report\n'"$REPORT" From 45934bca7786e83d716a77ba02b385fbb7621f86 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 54/92] Edit source tree --- tests/actor-parents/.caos-expr | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/actor-parents/.caos-expr diff --git a/tests/actor-parents/.caos-expr b/tests/actor-parents/.caos-expr new file mode 100644 index 00000000..b246a098 --- /dev/null +++ b/tests/actor-parents/.caos-expr @@ -0,0 +1,3 @@ +# SPIKE for design/actors.md open question 6 (see worker.sh): a worker test with +# git, pushing children of a branch head from a scratch repo that lacks history. +curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh From 61771cdedfc93b267bc8b00fc0f42ae85823640d Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 55/92] Edit source tree --- tests/actor-parents/DEPS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/actor-parents/DEPS diff --git a/tests/actor-parents/DEPS b/tests/actor-parents/DEPS new file mode 100644 index 00000000..93b4135e --- /dev/null +++ b/tests/actor-parents/DEPS @@ -0,0 +1,2 @@ +# What this test reaches for (format ` `). +../../dev/worker-test worker-test From 866752d63b3f898fe3abff9bd889ef626609a395 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 56/92] Edit source tree --- tests/actor-parents/worker.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh index 5299c4b4..5dab4cf9 100644 --- a/tests/actor-parents/worker.sh +++ b/tests/actor-parents/worker.sh @@ -93,4 +93,9 @@ VARIANT_HOOK="" variant shallow --depth=1 --filter=tree:0 # head came in a promisor pack) rather than present. VARIANT_HOOK='rm -f shallow' variant noshallow --depth=1 --filter=tree:0 +# 4. As 3, plus a diagnostic: is the depth-1 pack marked as a promisor pack? +VARIANT_HOOK='note " packs: $(ls objects/pack | tr "\n" " ")"; note " fsck-ish: $(git rev-list --missing=allow-promisor --count --all 2>&1 | tr "\n" " ")"; rm -f shallow' variant diag --depth=1 --filter=tree:0 +# 5. As 3, pushed with --no-thin. +VARIANT_HOOK='rm -f shallow; PUSH_EXTRA=--no-thin' variant nothin --depth=1 --filter=tree:0 + fail $'report\n'"$REPORT" From 285224dfe029dc8a76629d07ff898a96759cd5fa Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 57/92] Edit source tree --- tests/actor-parents/worker.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh index 5dab4cf9..0c7e7cda 100644 --- a/tests/actor-parents/worker.sh +++ b/tests/actor-parents/worker.sh @@ -78,7 +78,7 @@ variant() { # ; then optionally VARIANT_HOOK runs before t fi if [ -n "${VARIANT_HOOK:-}" ]; then eval "$VARIANT_HOOK"; fi c=$(child "$head") || { note "$name: could not write the child commit"; return; } - if out=$(git push --force-with-lease="$ref:$head" origin "$c:$ref" 2>&1); then + if out=$(git push ${PUSH_EXTRA:-} --force-with-lease="$ref:$head" origin "$c:$ref" 2>&1); then note "$name: PUSH OK; commits held locally: $(local_commits) of $N" else note "$name: PUSH FAILED ($(printf '%s' "$out" | tr '\n' ' ' | cut -c1-300)); commits held locally: $(local_commits) of $N" From 2e56fde040435598fde088d5b5a40b7fccf7fabe Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 58/92] Edit source tree --- design/actors.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/design/actors.md b/design/actors.md index 8db44138..e81aa7ca 100644 --- a/design/actors.md +++ b/design/actors.md @@ -326,13 +326,19 @@ detection. never shrinks. Start is unaffected (it reads the head at depth 1). This is the same problem as question 2 seen from the write path, and it is the reason that question matters now rather than later. Options to investigate: - - make the parent promised rather than present: write the commit against a - parent the scratch repo has never seen, if git will push it with the - parent in a promisor pack (not tried); - - drop the `shallow` file after a depth-1 fetch, so the repository is - treated as complete while the grandparents stay absent. Pack-objects - walks parents to mark them uninteresting, so this may fail or lazily - fetch the whole chain anyway (not tried); + - make the parent promised rather than present, by dropping the `shallow` + file after a depth-1 `tree:0` fetch so the head is the only commit held + and its parent is absent but listed by a `.promisor` pack. **Tried; it + does not work with `git push`** (40-commit branch, empty scratch repo): + the push fails with `Could not read ` / `could not parse commit + `, with or without `--no-thin`. The depth-1 pack is marked + promisor, but the pack-objects that `send-pack` starts walks the head's + parents to mark them uninteresting and does not tolerate a missing one. + With the `shallow` file kept, the server refuses the push instead + (`shallow pushes are not accepted`). Only the full-history fetch + pushes. Still untried: bypass `git push` by building the pack from an + explicit object list (`git pack-objects`, no revision walk) and speaking + receive-pack directly; - squash periodically (question 2), which bounds the chain; - add a small server-side push-by-oid endpoint, which breaks "no server change". From 6f6639fc4d53fac8f6a85df0bac98f690668c614 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 59/92] Edit source tree --- tests/actor-parents/.caos-expr | 3 - tests/actor-parents/DEPS | 2 - tests/actor-parents/worker.sh | 101 --------------------------------- 3 files changed, 106 deletions(-) delete mode 100644 tests/actor-parents/.caos-expr delete mode 100644 tests/actor-parents/DEPS delete mode 100644 tests/actor-parents/worker.sh diff --git a/tests/actor-parents/.caos-expr b/tests/actor-parents/.caos-expr deleted file mode 100644 index b246a098..00000000 --- a/tests/actor-parents/.caos-expr +++ /dev/null @@ -1,3 +0,0 @@ -# SPIKE for design/actors.md open question 6 (see worker.sh): a worker test with -# git, pushing children of a branch head from a scratch repo that lacks history. -curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh diff --git a/tests/actor-parents/DEPS b/tests/actor-parents/DEPS deleted file mode 100644 index 93b4135e..00000000 --- a/tests/actor-parents/DEPS +++ /dev/null @@ -1,2 +0,0 @@ -# What this test reaches for (format ` `). -../../dev/worker-test worker-test diff --git a/tests/actor-parents/worker.sh b/tests/actor-parents/worker.sh deleted file mode 100644 index 0c7e7cda..00000000 --- a/tests/actor-parents/worker.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash -# SPIKE (design/actors.md, open question 6): can finish push a child commit -# without downloading the branch's whole history? Builds a 40-commit branch on -# the server, then for each variant starts from an EMPTY partial-clone scratch -# repo, fetches only the head, and tries to push a child of it. Each variant -# reports whether the push worked and how many commits it had to hold locally. -# The test always fails at the end so that the report is printed. -set -uo pipefail - -fail() { echo "FAIL: $*" >&2; exit 1; } - -: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL from the runner}" -caos get /cas/args/test-salt || fail "reading --test-salt" -SALT=$(cat /cas/args/test-salt) -N=40 -REPORT="" -note() { REPORT="$REPORT$*"$'\n'; } - -# A 40-commit chain on a fresh server branch; prints the branch ref. -mkchain() { # - local ref="refs/heads/actors/parents-$SALT-$1-$(date +%s%N)-$RANDOM" prev="" i blob sub root c - rm -rf /tmp/setup - mkdir -p /tmp/setup - ( - cd /tmp/setup || exit 1 - git init -q . - git config user.email t@caos - git config user.name t - git remote add caos "$CAOS_SERVER_URL" - for i in $(seq 1 $N); do - blob=$(printf '%s\n' "$i" | git hash-object -w --stdin) - sub=$(printf '100644 blob %s\tv\n' "$blob" | git mktree) - root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) - if [ -n "$prev" ]; then c=$(git commit-tree "$root" -p "$prev" -m "c$i"); else c=$(git commit-tree "$root" -m "c$i"); fi - prev=$c - done - git push -q caos "$prev:$ref" || exit 1 - echo "$prev" > /tmp/setup-head - ) || fail "building the chain for $1" - printf '%s\n' "$ref" -} - -scratch() { # : an empty partial-clone scratch repo, like std/actor's - rm -rf "$1" - mkdir -p "$1" - cd "$1" || exit 1 - git init -q --bare . - git config user.email t@caos - git config user.name t - git config gc.auto 0 - git remote add origin "$CAOS_SERVER_URL" - git config core.repositoryformatversion 1 - git config extensions.partialClone origin - git config remote.origin.promisor true - git config remote.origin.partialclonefilter tree:0 -} - -local_commits() { git cat-file --batch-all-objects --batch-check 2>/dev/null | grep -c ' commit '; } - -# child : a commit on whose state subtree is brand new. -child() { - local blob sub root - blob=$(printf 'next\n' | git hash-object -w --stdin) - sub=$(printf '100644 blob %s\tv\n' "$blob" | git mktree) - root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) - git commit-tree "$root" -p "$1" -m "child" -} - -variant() { # ; then optionally VARIANT_HOOK runs before the push - local name=$1 ref head c out - shift - ref=$(mkchain "$name") - head=$(cat /tmp/setup-head) - scratch "/tmp/scratch-$name" - if ! git fetch -q --no-tags --no-write-fetch-head "$@" origin "$head" 2>/tmp/err; then - note "$name: fetch FAILED: $(tr '\n' ' ' < /tmp/err)" - return - fi - if [ -n "${VARIANT_HOOK:-}" ]; then eval "$VARIANT_HOOK"; fi - c=$(child "$head") || { note "$name: could not write the child commit"; return; } - if out=$(git push ${PUSH_EXTRA:-} --force-with-lease="$ref:$head" origin "$c:$ref" 2>&1); then - note "$name: PUSH OK; commits held locally: $(local_commits) of $N" - else - note "$name: PUSH FAILED ($(printf '%s' "$out" | tr '\n' ' ' | cut -c1-300)); commits held locally: $(local_commits) of $N" - fi -} - -# 1. Baseline, what std/actor does now: the head fetched without --depth. -VARIANT_HOOK="" variant full --filter=tree:0 -# 2. Depth 1 and leave the shallow file in place. -VARIANT_HOOK="" variant shallow --depth=1 --filter=tree:0 -# 3. Depth 1, then drop the shallow file: the head's parent is "promised" (the -# head came in a promisor pack) rather than present. -VARIANT_HOOK='rm -f shallow' variant noshallow --depth=1 --filter=tree:0 - -# 4. As 3, plus a diagnostic: is the depth-1 pack marked as a promisor pack? -VARIANT_HOOK='note " packs: $(ls objects/pack | tr "\n" " ")"; note " fsck-ish: $(git rev-list --missing=allow-promisor --count --all 2>&1 | tr "\n" " ")"; rm -f shallow' variant diag --depth=1 --filter=tree:0 -# 5. As 3, pushed with --no-thin. -VARIANT_HOOK='rm -f shallow; PUSH_EXTRA=--no-thin' variant nothin --depth=1 --filter=tree:0 - -fail $'report\n'"$REPORT" From 4d6ad8e65435b62680d408d8916e995dcae9921e Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 60/92] Edit source tree --- std/actor/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index 7b5213f2..6657ccc5 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -92,6 +92,14 @@ fn fetch_one(oid: &Oid, shallow: bool) -> Result<(), String> { let mut args = vec!["fetch", "--quiet", "--no-tags", "--no-write-fetch-head"]; // A shallow repository cannot push (the server refuses shallow pushes), so // only start, which merely reads, may cut the history off. + // + // KNOWN PROBLEM (design/actors.md, open question 6): finish therefore needs + // the branch's whole commit history, a "deep checkout", on every write. + // Pushing from a partial clone does not get around it. Even with a promisor + // remote and a depth-1 pack that git marks `.promisor`, deleting the `shallow` + // file and pushing a child fails with `Could not read `: the + // pack-objects that `git push` starts walks the head's parents and does not + // tolerate a promised (absent) one. `--no-thin` makes no difference. if shallow { args.push("--depth=1"); } From 327b00f01965963144bd5746de6d22ad6c9efd5e Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 61/92] Edit source tree --- std/actor/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs index 6657ccc5..198b4734 100644 --- a/std/actor/src/main.rs +++ b/std/actor/src/main.rs @@ -185,6 +185,10 @@ fn publish(state_ref: &str, head: Option, new_state: Oid) -> Result<(), Str // This container is not start's, so the scratch repo holds nothing: bring in // the parent commit and the new state's root tree, one object each, so the // push can traverse the new commit without reading the state's closure. + // + // The parent is fetched WITHOUT --depth, which pulls in every ancestor + // commit (trees excluded): the deep checkout described in `fetch_one`. A + // promisor remote does not avoid this for a push; see open question 6. if let Some(head) = &head { fetch_one(head, false)?; } From 939de3cbcc61e0d714223a4c4668a9636af109a4 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 62/92] Edit source tree --- tests/actor-ref/worker.go | 136 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/actor-ref/worker.go diff --git a/tests/actor-ref/worker.go b/tests/actor-ref/worker.go new file mode 100644 index 00000000..84837da6 --- /dev/null +++ b/tests/actor-ref/worker.go @@ -0,0 +1,136 @@ +// tests/actor-ref — SPIKE for design/actors.md, open question 6. +// +// Can a worker move a branch with a compare-and-swap WITHOUT any scratch +// repository and without fetching any history? A git push is a command line +// " " plus a pack, and the pack may be empty if the server +// already has the new object. `caos put-commit` puts the commit on the server +// without a push, so this speaks git-receive-pack directly with an empty pack. +// +// Proves, against the test stack: +// 1. creating a ref (old = zeros) at a commit made by `caos put-commit`; +// 2. updating it with the right to a child commit; +// 3. a stale is REJECTED and the ref does not move; +// 4. the result is a normal branch: git can fetch it and see both commits. +package main + +import ( + "bytes" + "crypto/sha1" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + + "caos/w" +) + +const zeros = "0000000000000000000000000000000000000000" + +func run(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + w.True(err == nil, "%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + return strings.TrimSpace(string(out)) +} + +func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } + +// emptyPack is a valid pack holding no objects: "PACK", version 2, count 0, +// and the SHA-1 of those twelve bytes. +func emptyPack() []byte { + header := []byte("PACK\x00\x00\x00\x02\x00\x00\x00\x00") + sum := sha1.Sum(header) + return append(header, sum[:]...) +} + +// setRef asks git-receive-pack to move ref from old to new, sending no +// objects. It returns the server's status lines. +func setRef(url, ref, old, new string) string { + var body bytes.Buffer + body.WriteString(pkt(fmt.Sprintf("%s %s %s\x00 report-status agent=caos-spike\n", old, new, ref))) + body.WriteString("0000") + body.Write(emptyPack()) + req := w.Check(http.NewRequest("POST", url+"/git-receive-pack", &body)) + req.Header.Set("Content-Type", "application/x-git-receive-pack-request") + req.Header.Set("Accept", "application/x-git-receive-pack-result") + resp := w.Check(http.DefaultClient.Do(req)) + defer resp.Body.Close() + text := string(w.Check(io.ReadAll(resp.Body))) + w.True(resp.StatusCode == 200, "git-receive-pack answered %s: %s", resp.Status, text) + return text +} + +// commit mints a commit over a state subtree holding v=, via +// `caos put` and `caos put-commit`, and returns its hash. Nothing is pushed. +func commit(tag, value, parent string) string { + dir := "/tmp/root-" + tag + w.Must(os.RemoveAll(dir)) + w.Must(os.MkdirAll(dir+"/state", 0o755)) + w.Must(os.WriteFile(dir+"/state/v", []byte(value+"\n"), 0o644)) + run("caos", "put", dir, "/cas/root-"+tag) + tree := run("caos", "hash", "/cas/root-"+tag) + text := "tree " + tree + "\n" + if parent != "" { + text += "parent " + parent + "\n" + } + text += "author actor 0 +0000\ncommitter actor 0 +0000\n\nactor state " + tag + "\n" + file := "/tmp/commit-" + tag + w.Must(os.WriteFile(file, []byte(text), 0o644)) + return run("caos", "put-commit", file, "/cas/commit-"+tag) +} + +func remoteHead(url, ref string) string { + out := run("git", "ls-remote", "--refs", url, ref) + if out == "" { + return "" + } + return strings.Fields(out)[0] +} + +func main() { + w.Main(func() { + url := strings.TrimRight(os.Getenv("CAOS_SERVER_URL"), "/") + w.True(url != "", "this test needs CAOS_SERVER_URL from the runner") + w.Do(scriptCat("/cas/args/test-salt")) + salt := strings.TrimSpace(string(w.Check(os.ReadFile("/cas/args/test-salt")))) + ref := fmt.Sprintf("refs/heads/actors/ref-%s-%d", salt, os.Getpid()) + + w.Step("mint two commits with caos put-commit (no push)") + c1 := commit("one", "1", "") + c2 := commit("two", "2", c1) + w.True(remoteHead(url, ref) == "", "fresh ref already exists") + + w.Step("create the ref with an empty pack") + resp := setRef(url, ref, zeros, c1) + fmt.Fprintf(os.Stderr, "create response: %q\n", resp) + w.True(strings.Contains(resp, "ok "+ref), "create was not accepted: %q", resp) + w.True(remoteHead(url, ref) == c1, "ref is %q, want %s", remoteHead(url, ref), c1) + + w.Step("a STALE is rejected and the ref does not move") + resp = setRef(url, ref, zeros, c2) + fmt.Fprintf(os.Stderr, "stale response: %q\n", resp) + w.True(strings.Contains(resp, "ng "+ref), "a stale was not rejected: %q", resp) + w.True(remoteHead(url, ref) == c1, "the ref moved on a stale update") + + w.Step("update with the right ") + resp = setRef(url, ref, c1, c2) + fmt.Fprintf(os.Stderr, "update response: %q\n", resp) + w.True(strings.Contains(resp, "ok "+ref), "update was not accepted: %q", resp) + w.True(remoteHead(url, ref) == c2, "ref is %q, want %s", remoteHead(url, ref), c2) + + w.Step("it is an ordinary branch") + w.Must(os.RemoveAll("/tmp/check")) + run("git", "init", "-q", "--bare", "/tmp/check") + run("git", "-C", "/tmp/check", "fetch", "-q", url, ref) + w.True(run("git", "-C", "/tmp/check", "rev-list", "--count", "FETCH_HEAD") == "2", "history is not two commits") + w.True(run("git", "-C", "/tmp/check", "show", "FETCH_HEAD:state/v") == "2", "state/v is not 2") + w.True(run("git", "-C", "/tmp/check", "show", "FETCH_HEAD~1:state/v") == "1", "parent state/v is not 1") + + w.Report("actor-ref: ALL PASS\n") + }) +} From eeabee24fb66ba83fb6d1ea7931e7ac087eb93f5 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 63/92] Edit source tree --- tests/actor-ref/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor-ref/worker.go b/tests/actor-ref/worker.go index 84837da6..259526cb 100644 --- a/tests/actor-ref/worker.go +++ b/tests/actor-ref/worker.go @@ -96,7 +96,7 @@ func main() { w.Main(func() { url := strings.TrimRight(os.Getenv("CAOS_SERVER_URL"), "/") w.True(url != "", "this test needs CAOS_SERVER_URL from the runner") - w.Do(scriptCat("/cas/args/test-salt")) + run("caos", "get", "/cas/args/test-salt") salt := strings.TrimSpace(string(w.Check(os.ReadFile("/cas/args/test-salt")))) ref := fmt.Sprintf("refs/heads/actors/ref-%s-%d", salt, os.Getpid()) From 53ef55475b436bae1a900d00833d927a33a3576e Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 64/92] Edit source tree --- tests/actor-ref/.caos-expr | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/actor-ref/.caos-expr diff --git a/tests/actor-ref/.caos-expr b/tests/actor-ref/.caos-expr new file mode 100644 index 00000000..eec9eebd --- /dev/null +++ b/tests/actor-ref/.caos-expr @@ -0,0 +1,3 @@ +# SPIKE for design/actors.md open question 6 (see worker.go): a std/go worker +# that moves a branch by speaking git-receive-pack with an empty pack. +curry --base:@=DEEP-DEPS/go --worker1:@=worker.go From 2db3aed22d0609f5ee1944ca107c8b8e537c042c Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 65/92] Edit source tree --- tests/actor-ref/DEPS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/actor-ref/DEPS diff --git a/tests/actor-ref/DEPS b/tests/actor-ref/DEPS new file mode 100644 index 00000000..dbcdefbb --- /dev/null +++ b/tests/actor-ref/DEPS @@ -0,0 +1,2 @@ +# What this test reaches for (format ` `). +../../std/go go From f1dfbb7da312154aae9eb43da5f249380c8e7384 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 66/92] Edit source tree --- design/actors.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/design/actors.md b/design/actors.md index e81aa7ca..18f517e9 100644 --- a/design/actors.md +++ b/design/actors.md @@ -336,9 +336,19 @@ detection. parents to mark them uninteresting and does not tolerate a missing one. With the `shallow` file kept, the server refuses the push instead (`shallow pushes are not accepted`). Only the full-history fetch - pushes. Still untried: bypass `git push` by building the pack from an - explicit object list (`git pack-objects`, no revision walk) and speaking - receive-pack directly; + pushes; + - **bypass `git push` and speak receive-pack directly. Tried; it works** + (`tests/actor-ref`). A push is a command ` ` plus a pack, + and the pack may be empty when the server already has the new object. A + commit made with `caos put-commit` is already on the server, so finish + POSTs one pkt-line command and an empty pack to + `$CAOS_SERVER_URL/git-receive-pack`: no scratch repository, no promisor + setup, no fetch of the parent or of any history. The server does the + compare-and-swap: a stale `` is answered `ng ` and the ref does + not move, and the right `` is accepted. The result is an ordinary + branch that git can fetch. This resolves the history cost of this + question for the write path, and needs no server change. `std/actor` + should use it; - squash periodically (question 2), which bounds the chain; - add a small server-side push-by-oid endpoint, which breaks "no server change". From ee79ef34d0e35313936010cf8443f645c9686485 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 67/92] Edit source tree --- std/actor/worker.go | 283 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 std/actor/worker.go diff --git a/std/actor/worker.go b/std/actor/worker.go new file mode 100644 index 00000000..56d9d0bf --- /dev/null +++ b/std/actor/worker.go @@ -0,0 +1,283 @@ +// The actor wrapper (design/actors.md): run an inner `(state, message) -> +// (state', reply)` request against state kept on a Git branch, and publish the +// new state with a compare-and-swap. +// +// `Q = actor { state-ref, inner, nonce, message }` has two positions: +// +// - start reads the branch head (`git ls-remote`, then a depth-1 `tree:0` +// fetch of that one commit), takes the `state/` subtree oid from the head, +// builds the inner request and tail-calls it with Q (plus the observed head +// and the input state) as the callback; +// - finish receives the inner's `{state, reply}`. An unchanged state returns +// the reply without touching Git; otherwise it mints `{state: }` +// as a commit on the observed head with `caos put-commit` and moves the +// branch with a compare-and-swap. A lost race fails the request, which is +// never cached, so the caller retries. +// +// Neither position checks the state out: it travels as a tree oid. +// +// THE BRANCH IS MOVED WITHOUT `git push`. A push is a command line +// " " plus a pack, and the pack may be empty when the server +// already has the new object, which it does: `caos put-commit` put it there. +// So finish POSTs that one command and an empty pack to git-receive-pack, and +// the server does the compare-and-swap (a stale is answered `ng`). No +// scratch repository, no fetch of the parent, no history. `git push` cannot +// do this: it resolves the new commit in a local repository and walks its +// ancestry to build a pack, which needs every ancestor commit ("a deep +// checkout"), and a partial clone with a promisor remote does not avoid that +// (design/actors.md, open question 6; tests/actor-ref proves the direct route). +package main + +import ( + "bytes" + "crypto/sha1" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "caos/w" +) + +const ( + stateEntry = "state" + noHead = "none" + zeros = "0000000000000000000000000000000000000000" + gitDir = "/tmp/actor-git" +) + +// run runs a command and returns its trimmed stdout, failing with its stderr. +func run(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + w.True(err == nil, "%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + return strings.TrimSpace(string(out)) +} + +func caos(args ...string) string { return run("caos", args...) } + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +// readArg fetches a blob argument and returns it trimmed. +func readArg(name string) string { + path := "/cas/args/" + name + caos("get", path) + return strings.TrimSpace(string(w.Check(os.ReadFile(path)))) +} + +func serverURL() string { + url := strings.TrimRight(os.Getenv("CAOS_SERVER_URL"), "/") + w.True(url != "", "CAOS_SERVER_URL not set") + return url +} + +// readRef is the branch's head on the server, or "" if the branch is absent. +func readRef(ref string) string { + out := run("git", "ls-remote", "--refs", serverURL(), ref) + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == ref { + return fields[0] + } + } + return "" +} + +// stateOf is the `state/` subtree oid of head, reading only the commit and its +// root tree: a depth-1 `tree:0` fetch into a throwaway partial-clone repository. +// Start only reads, so cutting the history off is fine here. +func stateOf(head string) string { + w.Must(os.RemoveAll(gitDir)) + run("git", "init", "-q", "--bare", gitDir) + git := func(args ...string) string { return run("git", append([]string{"-C", gitDir}, args...)...) } + git("config", "core.repositoryformatversion", "1") + git("config", "extensions.partialClone", "origin") + git("config", "remote.origin.url", serverURL()) + git("config", "remote.origin.promisor", "true") + git("config", "remote.origin.partialclonefilter", "tree:0") + git("fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--depth=1", "--filter=tree:0", "origin", head) + for _, line := range strings.Split(git("ls-tree", head), "\n") { + // "040000 tree \t" + meta, name, ok := strings.Cut(line, "\t") + if ok && name == stateEntry { + return strings.Fields(meta)[2] + } + } + return "" +} + +// emptyState is the empty tree, as a CAS path (so it can be bound by path). +func emptyState() string { + dir := "/tmp/actor-empty-state" + w.Must(os.RemoveAll(dir)) + w.Must(os.MkdirAll(dir, 0o755)) + caos("put", dir, "/cas/empty-state") + return "/cas/empty-state" +} + +func main() { + w.Main(func() { + stateRef := readArg("state-ref") + w.True(strings.HasPrefix(stateRef, "refs/heads/actors/") && !strings.Contains(stateRef, ".."), + "state-ref %q must be under refs/heads/actors/", stateRef) + if exists("/cas/args/result") { + finish(stateRef) + } else { + start(stateRef) + } + }) +} + +func start(stateRef string) { + head := readRef(stateRef) + statePath, stateOid := "", "" + if head != "" { + stateOid = stateOf(head) + } + if stateOid != "" { + caos("get-hash", stateOid, "/cas/state") + statePath = "/cas/state" + } else { + statePath = emptyState() + stateOid = caos("hash", statePath) + } + + request := caos("prepare-request", "--base:@=/cas/args/inner", + "--state:@="+statePath, "--message:@=/cas/args/message") + + // The callback is this same Q, carrying what finish needs to publish. + q := caos("hash", "/cas/args") + headText := head + if headText == "" { + headText = noHead + } + callback := caos("curry", "--base:hash="+q, "--head="+headText, "--old-state="+stateOid) + caos("run-request-then", request, "--then:hash="+callback) +} + +func finish(stateRef string) { + result := "/cas/args/result" + // List the result's children as hash-tagged entries; nothing is downloaded. + caos("get", result) + newState := caos("hash", filepath.Join(result, stateEntry)) + if newState != readArg("old-state") { + head := readArg("head") + if head == noHead { + head = "" + } + publish(stateRef, head, newState) + } + caos("forward", filepath.Join(result, "reply"), "/cas/out") +} + +// publish mints the commit {state: newState} on head and moves the branch. +func publish(stateRef, head, newState string) { + // The root tree {state: }: a symlink to the already-fetched + // result entry, which `caos put` resolves to its recorded hash. + root := "/tmp/actor-root" + w.Must(os.RemoveAll(root)) + w.Must(os.MkdirAll(root, 0o755)) + w.Must(os.Symlink("/cas/args/result/"+stateEntry, filepath.Join(root, stateEntry))) + caos("put", root, "/cas/new-root") + tree := caos("hash", "/cas/new-root") + + text := "tree " + tree + "\n" + if head != "" { + text += "parent " + head + "\n" + } + text += "author actor 0 +0000\ncommitter actor 0 +0000\n\nactor state\n" + w.Must(os.WriteFile("/tmp/actor-commit", []byte(text), 0o644)) + candidate := caos("put-commit", "/tmp/actor-commit", "/cas/new-commit") + + old := head + if old == "" { + old = zeros + } + status, err := setRef(serverURL(), stateRef, old, candidate) + if err == nil && status == "" { + return + } + // Ambiguous or refused: re-read the ref to learn what actually happened. + switch observed := readRef(stateRef); { + case observed == candidate: + return + case observed == head: + w.True(false, "moving %s: %s %v", stateRef, status, err) + default: + w.True(false, "lost the race for %s: %s %v", stateRef, status, err) + } +} + +func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } + +// emptyPack is a valid pack holding no objects: "PACK", version 2, count 0, +// and the SHA-1 of those twelve bytes. +func emptyPack() []byte { + header := []byte("PACK\x00\x00\x00\x02\x00\x00\x00\x00") + sum := sha1.Sum(header) + return append(header, sum[:]...) +} + +// setRef asks git-receive-pack to move ref from old to new, sending no objects. +// It returns "" when the server accepted the update, else the server's +// complaint (a stale arrives as `ng `). +func setRef(url, ref, old, new string) (string, error) { + var body bytes.Buffer + body.WriteString(pkt(fmt.Sprintf("%s %s %s\x00 report-status agent=caos-actor\n", old, new, ref))) + body.WriteString("0000") + body.Write(emptyPack()) + req, err := http.NewRequest("POST", url+"/git-receive-pack", &body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-git-receive-pack-request") + req.Header.Set("Accept", "application/x-git-receive-pack-result") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("git-receive-pack answered %s: %s", resp.Status, data) + } + unpacked, accepted := false, false + var complaint []string + for rest := string(data); len(rest) >= 4; { + n, err := strconv.ParseUint(rest[:4], 16, 16) + if err != nil || n < 4 && n != 0 || int(n) > len(rest) { + return "", fmt.Errorf("malformed report-status: %q", data) + } + if n == 0 { + rest = rest[4:] + continue + } + line := strings.TrimSpace(rest[4:n]) + rest = rest[n:] + switch { + case line == "unpack ok": + unpacked = true + case line == "ok "+ref: + accepted = true + default: + complaint = append(complaint, line) + } + } + if unpacked && accepted && len(complaint) == 0 { + return "", nil + } + return strings.Join(complaint, "; "), nil +} From d59fc8f073db9e0c54f45a17438b87714d26eaeb Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 68/92] Edit source tree --- std/actor/.caos-expr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/std/actor/.caos-expr b/std/actor/.caos-expr index 55cd7ba9..920ca4fa 100644 --- a/std/actor/.caos-expr +++ b/std/actor/.caos-expr @@ -1,5 +1,5 @@ -# Build the actor wrapper from its Rust project (design/actors.md). One binary, -# two positions like run-and-update-ref: start reads the branch head and tail-calls -# the inner request; finish publishes the new state with a leased push. It selects -# git-runner because both positions use ordinary Git. -run --base:@=DEEP-DEPS/rustc --src:@=. --dep0:@=DEEP-DEPS/conversation-protocol --dep1:@=DEEP-DEPS/git-locator --output-runner:@=DEEP-DEPS/git-runner +# The actor wrapper (design/actors.md), a std/go worker. One program, two +# positions like run-and-update-ref: start reads the branch head and tail-calls +# the inner request; finish publishes the new state by moving the branch with a +# compare-and-swap. std/go has git, which start uses to read the head. +curry --base:@=DEEP-DEPS/go --worker1:@=worker.go From c3525da135c3ba950349ba031c0837115f9210e0 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 69/92] Edit source tree --- std/actor/DEPS | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/std/actor/DEPS b/std/actor/DEPS index d4ed73a0..b70714ab 100644 --- a/std/actor/DEPS +++ b/std/actor/DEPS @@ -1,7 +1,2 @@ -# Same shape as run-and-update-ref: the rustc worker factory compiles this source -# with the shared conversation protocol (for GitStore/TreeBuilder-style plumbing) -# and curries the result onto the opt-in Git runner. -../rustc rustc -../git-runner git-runner -../../rust/crates/conversation-protocol conversation-protocol -../../rust/crates/git-locator git-locator +# The image this worker runs on (format ` `). +../go go From 148a8c5664ea9e7d1514274df8135c15aa49ced1 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 70/92] Edit source tree --- std/actor/Cargo.toml | 18 ---- std/actor/src/main.rs | 237 ------------------------------------------ 2 files changed, 255 deletions(-) delete mode 100644 std/actor/Cargo.toml delete mode 100644 std/actor/src/main.rs diff --git a/std/actor/Cargo.toml b/std/actor/Cargo.toml deleted file mode 100644 index 5e0ad9db..00000000 --- a/std/actor/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "actor" -version = "0.0.0" -edition = "2021" - -[[bin]] -name = "worker" -path = "src/main.rs" - -[dependencies] -worker-common = { path = "worker-common" } -conversation-protocol = { path = "conversation-protocol", features = ["git-cli"] } - -[profile.dev.package."*"] -opt-level = 2 - -[profile.dev.build-override] -opt-level = 0 diff --git a/std/actor/src/main.rs b/std/actor/src/main.rs deleted file mode 100644 index 198b4734..00000000 --- a/std/actor/src/main.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! The actor wrapper (design/actors.md): run an inner `(state, message) -> -//! (state', reply)` request against state kept on a Git branch, and publish the -//! new state with a compare-and-swap push. -//! -//! `Q = actor { state-ref, inner, nonce, message }` has two positions: -//! -//! - start reads the branch head (`ls-remote`, then a depth-1 `tree:0` fetch of -//! that one commit), takes the `state/` subtree oid from the head, builds the -//! inner request and tail-calls it with Q (plus the observed head and the -//! input state) as the callback; -//! - finish receives the inner's `{state, reply}`. An unchanged state returns -//! the reply without touching Git; otherwise it commits `{state: }` -//! on the observed head and pushes with `--force-with-lease`. A lost race -//! fails the request, which is never cached, so the caller retries. -//! -//! Neither position checks the state out: it travels as a tree oid. - -use std::path::Path; -use std::process::{Command, ExitCode}; - -use conversation_protocol::v3::{ - CommitInfo, GitStore, Mode, ObjectStore, Oid, RefUpdate, Signature, TreeEntry, -}; -use worker_common::{ - arg, caos, caos_curry, cas_hash, forward, own_args_tree, prepare_request, read_arg, run_worker, - run_request_then, scratch, Arg, -}; - -const STATE_ENTRY: &str = "state"; -const NO_HEAD: &str = "none"; -const GIT_DIR: &str = "/tmp/actor-git"; - -fn main() -> ExitCode { - run_worker("actor", run) -} - -fn run() -> Result<(), String> { - let state_ref = read_arg("state-ref")?; - if !state_ref.starts_with("refs/heads/actors/") || state_ref.contains("..") { - return Err(format!( - "state-ref {state_ref:?} must be under refs/heads/actors/" - )); - } - if Path::new(&arg("result")).exists() { - finish(&state_ref) - } else { - start(&state_ref) - } -} - -fn server_url() -> Result { - let url = std::env::var("CAOS_SERVER_URL").map_err(|_| "CAOS_SERVER_URL not set".to_string())?; - Ok(url.trim_end_matches('/').to_string()) -} - -fn git(args: &[&str]) -> Result<(), String> { - let output = Command::new("git") - .arg("-C") - .arg(GIT_DIR) - .env("GIT_TERMINAL_PROMPT", "0") - .args(args) - .output() - .map_err(|e| format!("running git: {e}"))?; - if output.status.success() { - Ok(()) - } else { - Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr).trim() - )) - } -} - -/// A bare scratch repository whose origin is the server and which treats it as -/// a promisor remote, so objects that exist only on the server (the actor's -/// state trees) are "promised" and are neither downloaded nor re-sent. -fn promisor_store() -> Result { - let store = GitStore::scratch("actor-git", &server_url()?)?; - git(&["config", "core.repositoryformatversion", "1"])?; - git(&["config", "extensions.partialClone", "origin"])?; - git(&["config", "remote.origin.promisor", "true"])?; - git(&["config", "remote.origin.partialclonefilter", "tree:0"])?; - Ok(store) -} - -/// Fetch exactly one object from the server through the promisor filter: a -/// commit arrives alone (depth 1, no trees), a tree arrives without its -/// children. Everything it references is then "promised", which is what lets a -/// later push traverse it without downloading the rest. -fn fetch_one(oid: &Oid, shallow: bool) -> Result<(), String> { - let mut args = vec!["fetch", "--quiet", "--no-tags", "--no-write-fetch-head"]; - // A shallow repository cannot push (the server refuses shallow pushes), so - // only start, which merely reads, may cut the history off. - // - // KNOWN PROBLEM (design/actors.md, open question 6): finish therefore needs - // the branch's whole commit history, a "deep checkout", on every write. - // Pushing from a partial clone does not get around it. Even with a promisor - // remote and a depth-1 pack that git marks `.promisor`, deleting the `shallow` - // file and pushing a child fails with `Could not read `: the - // pack-objects that `git push` starts walks the head's parents and does not - // tolerate a promised (absent) one. `--no-thin` makes no difference. - if shallow { - args.push("--depth=1"); - } - args.extend(["--filter=tree:0", "origin", oid.as_str()]); - git(&args) -} - -/// The `state/` subtree oid of `head`, reading only the commit and its root tree. -fn state_of(store: &GitStore, head: &Oid) -> Result, String> { - fetch_one(head, true)?; - let commit = store.read_commit(head).map_err(String::from)?; - let root = store.read_tree(&commit.tree).map_err(String::from)?; - Ok(root - .into_iter() - .find(|entry| entry.name == STATE_ENTRY) - .map(|entry| entry.oid)) -} - -/// The oid of the empty tree, as a CAS object (so it can be bound by path). -fn empty_state() -> Result { - let dir = scratch("actor-empty-state")?; - caos(["put", worker_common::path(&dir), "/cas/empty-state"])?; - cas_hash("/cas/empty-state") -} - -fn start(state_ref: &str) -> Result<(), String> { - let store = promisor_store()?; - let head = store.read_ref(state_ref)?; - let state = match &head { - Some(head) => state_of(&store, head)?, - None => None, - }; - let (state_path, state_oid) = match state { - Some(oid) => { - caos(["get-hash", oid.as_str(), "/cas/state"])?; - ("/cas/state", oid.to_string()) - } - None => ("/cas/empty-state", empty_state()?), - }; - if state_path == "/cas/empty-state" && !Path::new(state_path).exists() { - return Err("empty state was not staged".to_string()); - } - - let request = prepare_request( - Arg::Path(&arg("inner")), - &[ - ("state", Arg::Path(state_path)), - ("message", Arg::Path(&arg("message"))), - ], - )?; - - // The callback is this same Q, carrying what finish needs to publish. - let q = own_args_tree()?; - let head_text = head.as_ref().map(Oid::as_str).unwrap_or(NO_HEAD); - let callback = caos_curry( - Arg::Hash(&q), - &[ - ("head", Arg::Lit(head_text)), - ("old-state", Arg::Lit(&state_oid)), - ], - )?; - run_request_then(&request, Some(Arg::Hash(&callback))) -} - -fn finish(state_ref: &str) -> Result<(), String> { - let result = arg("result"); - // List the result's children as hash-tagged entries; nothing is downloaded. - caos(["get", &result])?; - let new_state = cas_hash(&format!("{result}/{STATE_ENTRY}"))?; - let old_state = read_arg("old-state")?; - if new_state != old_state { - let head = match read_arg("head")?.as_str() { - NO_HEAD => None, - oid => Some(Oid::parse(oid, "head")?), - }; - publish(state_ref, head, Oid::parse(&new_state, "new state")?)?; - } - forward(&format!("{result}/reply"), "/cas/out") -} - -fn publish(state_ref: &str, head: Option, new_state: Oid) -> Result<(), String> { - let mut store = promisor_store()?; - // This container is not start's, so the scratch repo holds nothing: bring in - // the parent commit and the new state's root tree, one object each, so the - // push can traverse the new commit without reading the state's closure. - // - // The parent is fetched WITHOUT --depth, which pulls in every ancestor - // commit (trees excluded): the deep checkout described in `fetch_one`. A - // promisor remote does not avoid this for a push; see open question 6. - if let Some(head) = &head { - fetch_one(head, false)?; - } - fetch_one(&new_state, false)?; - let tree = store - .write_tree(&[TreeEntry { - name: STATE_ENTRY.to_string(), - mode: Mode::Tree, - oid: new_state, - }]) - .map_err(String::from)?; - let identity = Signature { - name: "actor".to_string(), - email: "actor@caos".to_string(), - time: 0, - offset: "+0000".to_string(), - }; - let candidate = store - .write_commit(&CommitInfo { - tree, - parents: head.iter().cloned().collect(), - author: identity.clone(), - committer: identity, - extra_headers: Vec::new(), - message: b"actor state\n".to_vec(), - }) - .map_err(String::from)?; - - let push_error = match store.push(&[RefUpdate { - refname: state_ref.to_string(), - expected: head.clone(), - new: Some(candidate.clone()), - }]) { - Ok(()) => return Ok(()), - Err(error) => error, - }; - // Ambiguous failure: re-read the ref to learn what actually happened. - match store.read_ref(state_ref) { - Ok(Some(observed)) if observed == candidate => Ok(()), - Ok(observed) if observed == head => Err(format!("pushing {state_ref}: {push_error}")), - Ok(_) => Err(format!("lost the race for {state_ref}: {push_error}")), - Err(read_error) => Err(format!( - "pushing {state_ref} failed ({push_error}); rereading it also failed: {read_error}" - )), - } -} From b1fee8771517ddcdcb5ad939496eccc5f555e0ad Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 71/92] Edit source tree --- tests/actor/worker.go | 273 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 tests/actor/worker.go diff --git a/tests/actor/worker.go b/tests/actor/worker.go new file mode 100644 index 00000000..47a5c5f4 --- /dev/null +++ b/tests/actor/worker.go @@ -0,0 +1,273 @@ +// tests/actor: the actor wrapper + a reference key-value inner, in stages (a +// worker cannot block on a run, so each stage tail-calls the next with +// run-request-then). Every program here runs on std/go, which has git. +// +// start put a=1 -> one commit, state/a == 1 +// after-put get a -> reply 1, head unchanged (a read commits nothing) +// after-get put a=1 again -> head unchanged (same state, no commit, no push). +// This is also the crash-after-push case: a retry +// re-applies the message and reaches the same head. +// after-idem put b=2 -> a second commit whose parent is the first +// after-b fresh branch, impure inner pushes a competing commit mid-request +// raced the request FAILED (lost race, not cached); the competing head stands; +// send the identical request again +// retried the retry succeeded on top of the winner: state has x (winner) and a +// after-conc 12 concurrent puts (map-then, retried on a lost race) all landed +// after-lazy a read touched one entry and the inner saw the others unmaterialized +// after-hit1/after-hit2 +// the same read twice with different nonces ran the inner once +package main + +import ( + "bytes" + "fmt" + "math/rand" + "os" + "os/exec" + "strings" + "time" + + "caos/w" +) + +var ( + salt string + stateRef string + url string +) + +// try runs a command, returning its trimmed stdout and whether it succeeded. +func try(dir, name string, args ...string) (string, bool) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err == nil +} + +func run(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + w.True(err == nil, "%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + return strings.TrimSpace(string(out)) +} + +func caos(args ...string) string { return run("caos", args...) } + +func git(args ...string) string { return run("git", append([]string{"-C", "/tmp/repo"}, args...)...) } + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func readArg(name string) string { + path := "/cas/args/" + name + w.True(exists(path), "reading --%s", name) + caos("get", path) + return strings.TrimSpace(string(w.Check(os.ReadFile(path)))) +} + +// remoteHead is the branch's head on the server, or "". +func remoteHead(ref string) string { + out, ok := try("", "git", "ls-remote", "--refs", url, ref) + w.True(ok, "ls-remote %s", ref) + if out == "" { + return "" + } + return strings.Fields(out)[0] +} + +func fetch(oid string) { + git("fetch", "-q", "caos", oid) +} + +func stateFile(commit, name string) string { + fetch(commit) + return git("show", commit+":state/"+name) +} + +// next is the ArgTree of the following stage; extra are more --name=value args. +func next(stage string, extra ...string) string { + args := []string{"curry", "--base:@=/cas/args/base", "--worker1:@=/cas/args/worker1", + "--stage=" + stage, "--test-salt:@=/cas/args/test-salt", + "--actor:@=/cas/args/actor", "--kv:@=/cas/args/kv", + "--probe:@=/cas/args/probe", "--mapper:@=/cas/args/mapper", + "--state-ref=" + stateRef} + return caos(append(args, extra...)...) +} + +func kvInner() string { + return caos("curry", "--base:@=/cas/args/base", "--worker1:@=/cas/args/kv") +} + +// probeInner is the impure inner, in this image: --race-ref=R, --count-ref=C. +func probeInner(opts ...string) string { + return caos(append([]string{"curry", "--base:@=/cas/args/base", + "--worker1:@=/cas/args/probe", "--kv:@=/cas/args/kv"}, opts...)...) +} + +// actorRequest is the complete request for one message. +func actorRequest(message, nonce, inner string) string { + w.Must(os.WriteFile("/tmp/msg", []byte(message+"\n"), 0o644)) + _ = os.Remove("/cas/msg") + caos("put", "/tmp/msg", "/cas/msg") + return caos("prepare-request", "--base:@=/cas/args/actor", "--state-ref="+stateRef, + "--inner:hash="+inner, "--nonce="+nonce+"-"+salt, "--message:@=/cas/msg") +} + +// call sends message and continues at nextStage. With catch, a failed request +// reaches the next stage as --error instead of failing the test. +func call(message, nonce, inner, nextStage string, catch bool, extra ...string) { + request := actorRequest(message, nonce, inner) + args := []string{"run-request-then", request, "--then:hash=" + next(nextStage, extra...)} + if catch { + args = append(args, "--catch") + } + caos(args...) +} + +func freshRef(tag string) string { + return fmt.Sprintf("refs/heads/actors/test-%s-%d-%d-%d", tag, time.Now().UnixNano(), os.Getpid(), rand.Intn(32768)) +} + +func main() { + w.Main(func() { + stage := "start" + if exists("/cas/args/stage") { + stage = readArg("stage") + } + salt = readArg("test-salt") + url = strings.TrimRight(os.Getenv("CAOS_SERVER_URL"), "/") + w.True(url != "", "this test needs CAOS_SERVER_URL from the runner") + + w.Must(os.RemoveAll("/tmp/repo")) + run("git", "init", "-q", "/tmp/repo") + git("config", "user.email", "test@caos") + git("config", "user.name", "caos") + git("config", "gc.auto", "0") + git("remote", "add", "caos", url) + + if stage == "start" { + stateRef = freshRef("main") + } else { + stateRef = readArg("state-ref") + } + + switch stage { + case "start": + w.True(remoteHead(stateRef) == "", "fresh ref already exists") + call("put a 1", "n1", kvInner(), "after-put", false) + + case "after-put": + h1 := remoteHead(stateRef) + w.True(h1 != "", "put created no branch") + w.True(stateFile(h1, "a") == "1", "state/a is not 1") + w.True(git("rev-list", "--count", h1) == "1", "first update is not a root commit") + call("get a", "n2", kvInner(), "after-get", false, "--h1="+h1) + + case "after-get": + h1 := readArg("h1") + w.True(remoteHead(stateRef) == h1, "a read changed the head") + reply := readArg("result") + w.True(reply == "1", "get a replied '%s'", reply) + call("put a 1", "n3", kvInner(), "after-idem", false, "--h1="+h1) + + case "after-idem": + h1 := readArg("h1") + w.True(remoteHead(stateRef) == h1, "an unchanged put made a commit") + call("put b 2", "n4", kvInner(), "after-b", false, "--h1="+h1) + + case "after-b": + h1 := readArg("h1") + h2 := remoteHead(stateRef) + w.True(h2 != "", "branch vanished") + w.True(h2 != h1, "put b made no commit") + fetch(h2) + w.True(git("rev-parse", h2+"^1") == h1, "second update is not on the first") + w.True(git("rev-list", "--count", h2) == "2", "history is not a linear chain of two") + w.True(stateFile(h2, "a") == "1", "state/a lost") + w.True(stateFile(h2, "b") == "2", "state/b missing") + // A forced lost race: on a fresh branch the impure inner pushes a + // competing commit while the request is in flight, so the wrapper's + // lease (no head) fails. + stateRef = freshRef("race") + call("put a 1", "n5", probeInner("--race-ref="+stateRef), "raced", true) + + case "raced": + w.True(exists("/cas/args/error"), "the raced request did not fail (--error missing)") + winner := remoteHead(stateRef) + w.True(winner != "", "the competing writer left no branch") + w.True(stateFile(winner, "x") == "0", "the head is not the competing commit") + _, has := try("/tmp/repo", "git", "cat-file", "-e", winner+":state/a") + w.True(!has, "the lost request published anyway") + // The identical request again (same nonce): a cached failure would + // replay the failure; instead it re-runs against the new head. + call("put a 1", "n5", probeInner("--race-ref="+stateRef), "retried", false, "--winner="+winner) + + case "retried": + winner := readArg("winner") + head := remoteHead(stateRef) + w.True(head != "", "branch vanished") + w.True(head != winner, "the retry published nothing") + fetch(head) + w.True(git("rev-parse", head+"^1") == winner, "the retry is not on top of the winner") + w.True(stateFile(head, "x") == "0", "the winner's entry was lost") + w.True(stateFile(head, "a") == "1", "state/a missing after the retry") + // Concurrent writers, each retrying a lost race, must converge with + // no lost update. + stateRef = freshRef("conc") + w.Must(os.RemoveAll("/tmp/msgs")) + w.Must(os.MkdirAll("/tmp/msgs", 0o755)) + for n := 1; n <= 12; n++ { + w.Must(os.WriteFile(fmt.Sprintf("/tmp/msgs/m%d", n), []byte(fmt.Sprintf("put c%d v%d\n", n, n)), 0o644)) + } + caos("put", "/tmp/msgs", "/cas/msgs") + mapper := caos("curry", "--base:@=/cas/args/base", "--worker1:@=/cas/args/mapper", + "--actor:@=/cas/args/actor", "--kv:@=/cas/args/kv", + "--state-ref="+stateRef, "--test-salt:@=/cas/args/test-salt") + caos("map-then", "/cas/msgs", "--map:hash="+mapper, "--then:hash="+next("after-conc")) + + case "after-conc": + head := remoteHead(stateRef) + w.True(head != "", "no branch after the concurrent puts") + for n := 1; n <= 12; n++ { + w.True(stateFile(head, fmt.Sprintf("c%d", n)) == fmt.Sprintf("v%d", n), "update c%d was lost", n) + } + w.True(git("rev-list", "--count", head) == "12", "expected a linear chain of 12 commits") + call("getcheck c3", "n6", kvInner(), "after-lazy", false, "--h="+head) + + case "after-lazy": + head := readArg("h") + w.True(remoteHead(stateRef) == head, "a read changed the head") + reply := readArg("result") + w.True(reply == "v3", "getcheck replied '%s'", reply) + // The same read twice, different nonces: the inner (pure, so + // cached) runs once. + countRef := fmt.Sprintf("refs/heads/actors-count/%d-%d-%d", time.Now().UnixNano(), os.Getpid(), rand.Intn(32768)) + call("get c4", "n7", probeInner("--count-ref="+countRef), "after-hit1", false, "--count-ref="+countRef) + + case "after-hit1": + countRef := readArg("count-ref") + w.True(remoteHead(countRef) != "", "the inner did not run") + call("get c4", "n8", probeInner("--count-ref="+countRef), "after-hit2", false, "--count-ref="+countRef) + + case "after-hit2": + countRef := readArg("count-ref") + last := remoteHead(countRef) + w.True(last != "", "count ref vanished") + fetch(last) + runs := git("rev-list", "--count", last) + w.True(runs == "1", "the inner ran %s times; the repeat should hit the cache", runs) + w.Report("actor: ALL PASS\n") + + default: + w.True(false, "unknown --stage: %s", stage) + } + }) +} From 58f2e366a0b1b30f07f56c3e70097a6320cb96cb Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 72/92] Edit source tree --- tests/actor/kv.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/actor/kv.go diff --git a/tests/actor/kv.go b/tests/actor/kv.go new file mode 100644 index 00000000..8f7bc8f3 --- /dev/null +++ b/tests/actor/kv.go @@ -0,0 +1,79 @@ +// Reference inner actor (design/actors.md): a key-value store. A pure function +// of (state tree, message) -> {state, reply}. Messages are idempotent: +// +// put set key (applying twice is the same as once) +// get reply with the value, state unchanged +// getcheck like get, and fail if any OTHER entry's content was +// materialized: the inner sees the state lazily +// +// It runs as an actor's inner on std/go, and probe.go runs it too: probe copies +// this file into the prelude module as its own command, so keep it a single +// self-contained `package main`. +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + + "caos/w" +) + +func caos(args ...string) { + cmd := exec.Command("caos", args...) + cmd.Stderr = os.Stderr + w.True(cmd.Run() == nil, "caos %s failed", strings.Join(args, " ")) +} + +func main() { + w.Main(func() { + caos("get", "/cas/args/message") + line := strings.TrimRight(string(w.Check(os.ReadFile("/cas/args/message"))), "\n") + fields := strings.SplitN(line, " ", 3) + w.True(len(fields) >= 2, "kv: bad message: %q", line) + op, key, value := fields[0], fields[1], "" + if len(fields) == 3 { + value = strings.TrimSpace(fields[2]) + } + w.True(key != "" && !strings.Contains(key, "/") && !strings.HasPrefix(key, "."), "kv: bad key: %s", key) + + // List the state's entries without reading their content. + caos("get", "/cas/args/state") + entries := w.Check(os.ReadDir("/cas/args/state")) + + w.Must(os.RemoveAll("/tmp/out")) + w.Must(os.MkdirAll("/tmp/out", 0o755)) + switch op { + case "put": + w.Must(os.Mkdir("/tmp/out/state", 0o755)) + for _, e := range entries { + if e.Name() != key { + w.Must(os.Symlink(filepath.Join("/cas/args/state", e.Name()), filepath.Join("/tmp/out/state", e.Name()))) + } + } + w.Must(os.WriteFile(filepath.Join("/tmp/out/state", key), []byte(value+"\n"), 0o644)) + w.Must(os.WriteFile("/tmp/out/reply", []byte("ok\n"), 0o644)) + case "get", "getcheck": + w.Must(os.Symlink("/cas/args/state", "/tmp/out/state")) + reply := []byte{} + if _, err := os.Lstat(filepath.Join("/cas/args/state", key)); err == nil { + caos("get", filepath.Join("/cas/args/state", key)) + reply = w.Check(os.ReadFile(filepath.Join("/cas/args/state", key))) + } + w.Must(os.WriteFile("/tmp/out/reply", reply, 0o644)) + if op == "getcheck" { + for _, e := range entries { + if e.Name() == key { + continue + } + info := w.Check(os.Stat(filepath.Join("/cas/args/state", e.Name()))) + w.True(info.Size() == 0, "kv: %s was materialized by a read of %s", e.Name(), key) + } + } + default: + w.True(false, "kv: unknown op: %s", op) + } + caos("put", "/tmp/out", "/cas/out") + }) +} From 5aee24046fc0b76d6c803a96dd38e049096c44b0 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 73/92] Edit source tree --- tests/actor/probe.go | 103 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/actor/probe.go diff --git a/tests/actor/probe.go b/tests/actor/probe.go new file mode 100644 index 00000000..140d7704 --- /dev/null +++ b/tests/actor/probe.go @@ -0,0 +1,103 @@ +// An IMPURE inner, for tests only: it does what kv.go does, after a side effect +// on the server that the test then observes. Real inners must be pure. +// +// --race-ref=R if R does not exist yet, push a competing commit to it, so the +// wrapper's leased push (which observed no head) loses the race +// --count-ref=C push one new commit to C per execution, so the number of +// commits on C is the number of times this inner actually ran +package main + +import ( + "bytes" + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "caos/w" +) + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func optArg(name string) string { + path := "/cas/args/" + name + if !exists(path) { + return "" + } + cmd := exec.Command("caos", "get", path) + w.True(cmd.Run() == nil, "reading --%s", name) + return strings.TrimSpace(string(w.Check(os.ReadFile(path)))) +} + +// git runs git in the scratch repository with stdin and returns trimmed stdout. +func git(stdin string, args ...string) string { + cmd := exec.Command("git", append([]string{"-C", "/tmp/probe"}, args...)...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + cmd.Stdin = strings.NewReader(stdin) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + w.True(err == nil, "git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + return strings.TrimSpace(string(out)) +} + +func headOf(ref string) string { + out := git("", "ls-remote", "--refs", "caos", ref) + if out == "" { + return "" + } + return strings.Fields(out)[0] +} + +func main() { + w.Main(func() { + url := strings.TrimRight(os.Getenv("CAOS_SERVER_URL"), "/") + w.True(url != "", "needs CAOS_SERVER_URL from the runner") + raceRef, countRef := optArg("race-ref"), optArg("count-ref") + + w.Must(os.RemoveAll("/tmp/probe")) + w.Must(os.MkdirAll("/tmp/probe", 0o755)) + git("", "init", "-q", ".") + git("", "config", "user.email", "probe@caos") + git("", "config", "user.name", "probe") + git("", "config", "gc.auto", "0") + git("", "remote", "add", "caos", url) + + if raceRef != "" && headOf(raceRef) == "" { + blob := git("0\n", "hash-object", "-w", "--stdin") + sub := git(fmt.Sprintf("100644 blob %s\tx\n", blob), "mktree") + root := git(fmt.Sprintf("040000 tree %s\tstate\n", sub), "mktree") + winner := git("", "commit-tree", root, "-m", "competing writer") + git("", "push", "-q", "--force-with-lease="+raceRef+":", "caos", winner+":"+raceRef) + } + + if countRef != "" { + empty := git("", "mktree") + prior := headOf(countRef) + msg := fmt.Sprintf("ran %d-%d", time.Now().UnixNano(), rand.Intn(32768)) + var run string + if prior != "" { + git("", "fetch", "-q", "caos", prior) + run = git("", "commit-tree", empty, "-p", prior, "-m", msg) + } else { + run = git("", "commit-tree", empty, "-m", msg) + } + git("", "push", "-q", "--force-with-lease="+countRef+":"+prior, "caos", run+":"+countRef) + } + + // Then behave as kv: build the --kv program as a command inside the + // prelude module (this worker runs there, in /tmp/run) and run it. + w.Do(execCmd("", "caos", "get", "/cas/args/kv")) + dir := "/tmp/run/kvcmd" + w.Must(os.RemoveAll(dir)) + w.Must(os.MkdirAll(dir, 0o755)) + w.Must(os.WriteFile(filepath.Join(dir, "main.go"), w.Check(os.ReadFile("/cas/args/kv")), 0o644)) + w.Do(execCmd("/tmp/run", "go", "run", "./kvcmd")) + }) +} From f2d5ceb975c50215e9938226b93fc32058955ba6 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 74/92] Edit source tree --- tests/actor/probe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor/probe.go b/tests/actor/probe.go index 140d7704..e8673042 100644 --- a/tests/actor/probe.go +++ b/tests/actor/probe.go @@ -93,7 +93,7 @@ func main() { // Then behave as kv: build the --kv program as a command inside the // prelude module (this worker runs there, in /tmp/run) and run it. - w.Do(execCmd("", "caos", "get", "/cas/args/kv")) + w.Must(execCmd("", "caos", "get", "/cas/args/kv")) dir := "/tmp/run/kvcmd" w.Must(os.RemoveAll(dir)) w.Must(os.MkdirAll(dir, 0o755)) From 2483cd133836c9f3a52e8f5efe7a5c53f92b5cb9 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 75/92] Edit source tree --- tests/actor/probe.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/actor/probe.go b/tests/actor/probe.go index e8673042..bda6505a 100644 --- a/tests/actor/probe.go +++ b/tests/actor/probe.go @@ -98,6 +98,14 @@ func main() { w.Must(os.RemoveAll(dir)) w.Must(os.MkdirAll(dir, 0o755)) w.Must(os.WriteFile(filepath.Join(dir, "main.go"), w.Check(os.ReadFile("/cas/args/kv")), 0o644)) - w.Do(execCmd("/tmp/run", "go", "run", "./kvcmd")) + w.Must(execCmd("/tmp/run", "go", "run", "./kvcmd")) }) } + +// execCmd runs a command with this worker's stdio, in dir ("" for the current one). +func execCmd(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} From 8eae0fe87ff3b5a3d4872f3de857eb36d2e18855 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 76/92] Edit source tree --- tests/actor/mapper.go | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/actor/mapper.go diff --git a/tests/actor/mapper.go b/tests/actor/mapper.go new file mode 100644 index 00000000..fffeee0c --- /dev/null +++ b/tests/actor/mapper.go @@ -0,0 +1,72 @@ +// One concurrent writer for tests/actor. Used as a map-then `map`, it is called +// with --in=; it sends that message to the actor and, when the +// request loses the race for the branch (the wrapper fails it, uncached), sends +// it again with a new nonce, up to maxAttempts. Its callback is this same +// program with --attempt and --msg curried on and --result or --error supplied. +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "caos/w" +) + +const maxAttempts = 24 + +func caos(args ...string) string { + cmd := exec.Command("caos", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + w.True(err == nil, "caos %s: %v", strings.Join(args, " "), err) + return strings.TrimSpace(string(out)) +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func readArg(name string) string { + path := "/cas/args/" + name + caos("get", path) + return strings.TrimSpace(string(w.Check(os.ReadFile(path)))) +} + +func main() { + w.Main(func() { + if exists("/cas/args/result") { + caos("forward", "/cas/args/result", "/cas/out") + return + } + + attempt := 0 + if exists("/cas/args/attempt") { + attempt = w.Check(strconv.Atoi(readArg("attempt"))) + } + if exists("/cas/args/error") { + attempt++ + w.True(attempt < maxAttempts, "still losing the race after %d attempts: %s", maxAttempts, readArg("error")) + } + + msg := "/cas/args/msg" + if !exists(msg) { + caos("get", "/cas/args/in") + msg = "/cas/args/in" + } + stateRef, salt := readArg("state-ref"), readArg("test-salt") + nonce := fmt.Sprintf("%s-%d-%s", caos("hash", msg), attempt, salt) + + inner := caos("curry", "--base:@=/cas/args/base", "--worker1:@=/cas/args/kv") + request := caos("prepare-request", "--base:@=/cas/args/actor", "--state-ref="+stateRef, + "--inner:hash="+inner, "--nonce="+nonce, "--message:@="+msg) + callback := caos("curry", "--base:@=/cas/args/base", "--worker1:@=/cas/args/worker1", + "--actor:@=/cas/args/actor", "--kv:@=/cas/args/kv", + "--state-ref="+stateRef, "--test-salt:@=/cas/args/test-salt", + "--attempt="+strconv.Itoa(attempt), "--msg:@="+msg) + caos("run-request-then", request, "--then:hash="+callback, "--catch") + }) +} From 9c2f3c1ce76180ddfda748cd4f5dc652135d3222 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 77/92] Edit source tree --- tests/actor/.caos-expr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/actor/.caos-expr b/tests/actor/.caos-expr index a6fd3d0f..fe5246ab 100644 --- a/tests/actor/.caos-expr +++ b/tests/actor/.caos-expr @@ -1,5 +1,5 @@ -# tests/actor, as an ENTRY: a worker test (dev/worker-test has git, which is how -# it reads the actor's branch on the server) driving std/actor with a reference -# key-value inner that runs in std/bash. `probe` is an impure inner and `mapper` -# a concurrent writer, both for the race and concurrency cases. -curry --base:@=DEEP-DEPS/worker-test --worker1:@=worker.sh --bash:@=DEEP-DEPS/bash --actor:@=DEEP-DEPS/actor --kv:@=kv.sh --probe:@=probe.sh --mapper:@=mapper.sh +# tests/actor, as an ENTRY: a std/go worker test (std/go has git, which is how it +# reads the actor's branch on the server) driving std/actor with a reference +# key-value inner. `probe` is an impure inner and `mapper` a concurrent writer, +# both for the race and concurrency cases. Every program runs on std/go. +curry --base:@=DEEP-DEPS/go --worker1:@=worker.go --actor:@=DEEP-DEPS/actor --kv:@=kv.go --probe:@=probe.go --mapper:@=mapper.go From d667f09a6e6a503000724eeac23971922f9e21fe Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 78/92] Edit source tree --- tests/actor/DEPS | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/actor/DEPS b/tests/actor/DEPS index 68ee9871..c1ec3469 100644 --- a/tests/actor/DEPS +++ b/tests/actor/DEPS @@ -1,5 +1,3 @@ # What this test reaches for (format ` `). -../../std/bash bash +../../std/go go ../../std/actor actor -# A worker test that needs git, to read the actor's branch on the server. -../../dev/worker-test worker-test From e39092c97abd12ae07449a0743ee4a96369fdc86 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 79/92] Edit source tree --- tests/actor/kv.sh | 53 ----------- tests/actor/mapper.sh | 38 -------- tests/actor/probe.sh | 52 ----------- tests/actor/worker.sh | 209 ------------------------------------------ 4 files changed, 352 deletions(-) delete mode 100644 tests/actor/kv.sh delete mode 100644 tests/actor/mapper.sh delete mode 100644 tests/actor/probe.sh delete mode 100644 tests/actor/worker.sh diff --git a/tests/actor/kv.sh b/tests/actor/kv.sh deleted file mode 100644 index afc8e4ee..00000000 --- a/tests/actor/kv.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# Reference inner actor (design/actors.md): a key-value store in plain bash. A -# pure function of (state tree, message) -> {state, reply}. Messages are -# idempotent: -# put set key (applying twice is the same as once) -# get reply with the value, state unchanged -# getcheck like get, and fail if any OTHER entry's content was -# materialized: the inner sees the state lazily -set -euo pipefail - -caos get /cas/args/message -read -r op key value < /cas/args/message -case "$key" in - ''|*/*|.*) echo "kv: bad key: $key" >&2; exit 1 ;; -esac - -# List the state's entries without reading their content. -caos get /cas/args/state - -rm -rf /tmp/out -mkdir -p /tmp/out -case "$op" in -put) - mkdir /tmp/out/state - for entry in /cas/args/state/*; do - [ -e "$entry" ] || continue - name=$(basename "$entry") - if [ "$name" != "$key" ]; then ln -s "$entry" "/tmp/out/state/$name"; fi - done - printf '%s\n' "$value" > "/tmp/out/state/$key" - printf 'ok\n' > /tmp/out/reply - ;; -get|getcheck) - ln -s /cas/args/state /tmp/out/state - if [ -e "/cas/args/state/$key" ]; then - caos get "/cas/args/state/$key" - cp "/cas/args/state/$key" /tmp/out/reply - else - : > /tmp/out/reply - fi - if [ "$op" = getcheck ]; then - for entry in /cas/args/state/*; do - name=$(basename "$entry") - if [ "$name" != "$key" ] && [ -s "$entry" ]; then - echo "kv: $name was materialized by a read of $key" >&2 - exit 1 - fi - done - fi - ;; -*) echo "kv: unknown op: $op" >&2; exit 1 ;; -esac -caos put /tmp/out /cas/out diff --git a/tests/actor/mapper.sh b/tests/actor/mapper.sh deleted file mode 100644 index 93525258..00000000 --- a/tests/actor/mapper.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# One concurrent writer for tests/actor. Used as a map-then `map`, it is called -# with --in=; it sends that message to the actor and, when the -# request loses the race for the branch (the wrapper fails it, uncached), sends -# it again with a new nonce, up to MAX attempts. Its callback is this same -# script with --attempt and --msg curried on and --result or --error supplied. -set -euo pipefail - -fail() { echo "FAIL: $*" >&2; exit 1; } -MAX=24 - -if [ -e /cas/args/result ]; then - caos forward /cas/args/result /cas/out - exit 0 -fi - -attempt=0 -if caos get /cas/args/attempt 2>/dev/null; then attempt=$(cat /cas/args/attempt); fi -if [ -e /cas/args/error ]; then - caos get /cas/args/error - attempt=$((attempt + 1)) - [ "$attempt" -lt "$MAX" ] || fail "still losing the race after $MAX attempts: $(cat /cas/args/error)" -fi - -if [ -e /cas/args/msg ]; then msg=/cas/args/msg; else caos get /cas/args/in; msg=/cas/args/in; fi -caos get /cas/args/state-ref -caos get /cas/args/test-salt -state_ref=$(cat /cas/args/state-ref) -nonce="$(caos hash "$msg")-$attempt-$(cat /cas/args/test-salt)" - -inner=$(caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv) -request=$(caos prepare-request --base:@=/cas/args/actor --state-ref="$state_ref" \ - --inner:hash="$inner" --nonce="$nonce" --message:@="$msg") || fail "preparing the request" -callback=$(caos curry --base:@=/cas/args/base --worker1:@=/cas/args/worker1 \ - --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ - --state-ref="$state_ref" --test-salt:@=/cas/args/test-salt \ - --attempt="$attempt" --msg:@="$msg") -caos run-request-then "$request" --then:hash="$callback" --catch diff --git a/tests/actor/probe.sh b/tests/actor/probe.sh deleted file mode 100644 index be0f7fd9..00000000 --- a/tests/actor/probe.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# An IMPURE inner, for tests only: it does what kv.sh does, after a side effect -# on the server that the test then observes. Real inners must be pure. -# --race-ref=R if R does not exist yet, push a competing commit to it, so the -# wrapper's leased push (which observed no head) loses the race -# --count-ref=C push one new commit to C per execution, so the number of -# commits on C is the number of times this inner actually ran -set -euo pipefail - -: "${CAOS_SERVER_URL:?needs CAOS_SERVER_URL from the runner}" -race_ref="" -count_ref="" -if caos get /cas/args/race-ref 2>/dev/null; then race_ref=$(cat /cas/args/race-ref); fi -if caos get /cas/args/count-ref 2>/dev/null; then count_ref=$(cat /cas/args/count-ref); fi - -rm -rf /tmp/probe -mkdir -p /tmp/probe -cd /tmp/probe -git init -q . -git config user.email probe@caos -git config user.name probe -git config gc.auto 0 -git remote add caos "$CAOS_SERVER_URL" - -head_of() { - local line - line=$(git ls-remote --refs caos "$1") || return 1 - if [ -n "$line" ]; then printf '%s\n' "${line%%[[:space:]]*}"; fi -} - -if [ -n "$race_ref" ] && [ -z "$(head_of "$race_ref")" ]; then - blob=$(printf '0\n' | git hash-object -w --stdin) - sub=$(printf '100644 blob %s\tx\n' "$blob" | git mktree) - root=$(printf '040000 tree %s\tstate\n' "$sub" | git mktree) - winner=$(git commit-tree "$root" -m "competing writer") - git push -q --force-with-lease="$race_ref:" caos "$winner:$race_ref" -fi - -if [ -n "$count_ref" ]; then - empty=$(git mktree < /dev/null) - prior=$(head_of "$count_ref") - if [ -n "$prior" ]; then - git fetch -q caos "$prior" - run=$(git commit-tree "$empty" -p "$prior" -m "ran $(date +%s%N)-$RANDOM") - else - run=$(git commit-tree "$empty" -m "ran $(date +%s%N)-$RANDOM") - fi - git push -q --force-with-lease="$count_ref:$prior" caos "$run:$count_ref" -fi - -caos get /cas/args/kv -exec bash /cas/args/kv diff --git a/tests/actor/worker.sh b/tests/actor/worker.sh deleted file mode 100644 index dfdf0a2f..00000000 --- a/tests/actor/worker.sh +++ /dev/null @@ -1,209 +0,0 @@ -#!/bin/bash -# Actor wrapper + reference kv inner, in stages (a worker cannot block on a run, -# so each stage tail-calls the next with run-request-then): -# start put a=1 -> one commit, state/a == 1 -# after-put get a -> reply 1, head unchanged (a read commits nothing) -# after-get put a=1 again -> head unchanged (same state, no commit, no push). -# This is also the crash-after-push case: a retry -# re-applies the message and reaches the same head. -# after-idem put b=2 -> a second commit whose parent is the first -# after-b fresh branch, impure inner pushes a competing commit mid-request -# raced the request FAILED (lost race, not cached); the competing head stands; -# send the identical request again -# retried the retry succeeded on top of the winner: state has x (winner) and a -# after-conc 12 concurrent puts (map-then, retried on a lost race) all landed -# after-lazy a read touched one entry and the inner saw the others unmaterialized -# after-hit1/after-hit2 -# the same read twice with different nonces ran the inner once -set -euo pipefail - -fail() { echo "FAIL: $*" >&2; exit 1; } - -stage=start -if caos get /cas/args/stage 2>/dev/null; then stage=$(cat /cas/args/stage); fi - -caos get /cas/args/test-salt || fail "reading --test-salt" -SALT=$(cat /cas/args/test-salt) - -: "${CAOS_SERVER_URL:?this test needs CAOS_SERVER_URL from the runner}" -rm -rf /tmp/repo -mkdir -p /tmp/repo -cd /tmp/repo -git init -q . -git config user.email test@caos -git config user.name caos -git config gc.auto 0 -git remote add caos "$CAOS_SERVER_URL" - -next() { - local next_stage=$1 - shift - caos curry --base:@=/cas/args/base --worker1:@=/cas/args/worker1 \ - --stage="$next_stage" --test-salt:@=/cas/args/test-salt \ - --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ - --probe:@=/cas/args/probe --mapper:@=/cas/args/mapper \ - --state-ref="$STATE_REF" "$@" -} - -remote_head() { - local line - line=$(git ls-remote --refs caos "$1") || return 1 - [ -n "$line" ] || return 1 - printf '%s\n' "${line%%[[:space:]]*}" -} - -state_file() { # - git fetch -q caos "$1" || fail "fetching $1" - git show "$1:state/$2" -} - -kv_inner() { - caos curry --base:@=/cas/args/bash --worker1:@=/cas/args/kv -} - -# probe_inner [--race-ref=R] [--count-ref=C]: the impure inner, in this image. -probe_inner() { - caos curry --base:@=/cas/args/base --worker1:@=/cas/args/probe \ - --kv:@=/cas/args/kv "$@" -} - -# actor_request : the complete request for one message. -actor_request() { - local message=$1 nonce=$2 inner=$3 - printf '%s\n' "$message" > /tmp/msg - rm -f /cas/msg - caos put /tmp/msg /cas/msg > /dev/null || fail "staging the message" - caos prepare-request --base:@=/cas/args/actor --state-ref="$STATE_REF" \ - --inner:hash="$inner" --nonce="$nonce-$SALT" --message:@=/cas/msg -} - -# call [next args...]; CATCH=1 delivers a -# failed request to the next stage as --error instead of failing the test. -call() { - local message=$1 nonce=$2 inner=$3 next_stage=$4 request catch=() - shift 4 - request=$(actor_request "$message" "$nonce" "$inner") || fail "preparing '$message'" - if [ "${CATCH:-}" = 1 ]; then catch=(--catch); fi - caos run-request-then "$request" --then:hash="$(next "$next_stage" "$@")" "${catch[@]}" -} - -fresh_ref() { printf 'refs/heads/actors/test-%s-%s-%s-%s' "$1" "$(date +%s%N)" "$$" "$RANDOM"; } - -read_arg() { caos get "/cas/args/$1" || fail "reading --$1"; cat "/cas/args/$1"; } - -if [ "$stage" = start ]; then - STATE_REF=$(fresh_ref main) -else - STATE_REF=$(read_arg state-ref) -fi - -case "$stage" in -start) - if remote_head "$STATE_REF" > /dev/null; then fail "fresh ref already exists"; fi - call "put a 1" n1 "$(kv_inner)" after-put - ;; - -after-put) - h1=$(remote_head "$STATE_REF") || fail "put created no branch" - [ "$(state_file "$h1" a)" = 1 ] || fail "state/a is not 1" - [ "$(git rev-list --count "$h1")" = 1 ] || fail "first update is not a root commit" - call "get a" n2 "$(kv_inner)" after-get --h1="$h1" - ;; - -after-get) - h1=$(read_arg h1) - [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "a read changed the head" - caos get /cas/args/result || fail "reading the reply" - [ "$(cat /cas/args/result)" = 1 ] || fail "get a replied '$(cat /cas/args/result)'" - call "put a 1" n3 "$(kv_inner)" after-idem --h1="$h1" - ;; - -after-idem) - h1=$(read_arg h1) - [ "$(remote_head "$STATE_REF")" = "$h1" ] || fail "an unchanged put made a commit" - call "put b 2" n4 "$(kv_inner)" after-b --h1="$h1" - ;; - -after-b) - h1=$(read_arg h1) - h2=$(remote_head "$STATE_REF") || fail "branch vanished" - [ "$h2" != "$h1" ] || fail "put b made no commit" - git fetch -q caos "$h2" || fail "fetching $h2" - [ "$(git rev-parse "$h2^1")" = "$h1" ] || fail "second update is not on the first" - [ "$(git rev-list --count "$h2")" = 2 ] || fail "history is not a linear chain of two" - [ "$(state_file "$h2" a)" = 1 ] || fail "state/a lost" - [ "$(state_file "$h2" b)" = 2 ] || fail "state/b missing" - # A forced lost race: on a fresh branch the impure inner pushes a competing - # commit while the request is in flight, so the wrapper's lease (no head) fails. - STATE_REF=$(fresh_ref race) - CATCH=1 call "put a 1" n5 "$(probe_inner --race-ref="$STATE_REF")" raced - ;; - -raced) - caos get /cas/args/error 2>/dev/null || fail "the raced request did not fail (--error missing)" - winner=$(remote_head "$STATE_REF") || fail "the competing writer left no branch" - [ "$(state_file "$winner" x)" = 0 ] || fail "the head is not the competing commit" - git cat-file -e "$winner:state/a" 2>/dev/null && fail "the lost request published anyway" - # The identical request again (same nonce): a cached failure would replay the - # failure; instead it re-runs against the new head and succeeds. - call "put a 1" n5 "$(probe_inner --race-ref="$STATE_REF")" retried --winner="$winner" - ;; - -retried) - winner=$(read_arg winner) - head=$(remote_head "$STATE_REF") || fail "branch vanished" - [ "$head" != "$winner" ] || fail "the retry published nothing" - git fetch -q caos "$head" || fail "fetching $head" - [ "$(git rev-parse "$head^1")" = "$winner" ] || fail "the retry is not on top of the winner" - [ "$(state_file "$head" x)" = 0 ] || fail "the winner's entry was lost" - [ "$(state_file "$head" a)" = 1 ] || fail "state/a missing after the retry" - # Concurrent writers, each retrying a lost race, must converge with no lost update. - STATE_REF=$(fresh_ref conc) - rm -rf /tmp/msgs - mkdir -p /tmp/msgs - for n in 1 2 3 4 5 6 7 8 9 10 11 12; do printf 'put c%s v%s\n' "$n" "$n" > "/tmp/msgs/m$n"; done - caos put /tmp/msgs /cas/msgs > /dev/null || fail "staging the messages" - mapper=$(caos curry --base:@=/cas/args/base --worker1:@=/cas/args/mapper \ - --bash:@=/cas/args/bash --actor:@=/cas/args/actor --kv:@=/cas/args/kv \ - --state-ref="$STATE_REF" --test-salt:@=/cas/args/test-salt) - caos map-then /cas/msgs --map:hash="$mapper" --then:hash="$(next after-conc)" - ;; - -after-conc) - head=$(remote_head "$STATE_REF") || fail "no branch after the concurrent puts" - for n in 1 2 3 4 5 6 7 8 9 10 11 12; do - [ "$(state_file "$head" "c$n")" = "v$n" ] || fail "update c$n was lost" - done - [ "$(git rev-list --count "$head")" = 12 ] || fail "expected a linear chain of 12 commits" - call "getcheck c3" n6 "$(kv_inner)" after-lazy --h="$head" - ;; - -after-lazy) - head=$(read_arg h) - [ "$(remote_head "$STATE_REF")" = "$head" ] || fail "a read changed the head" - caos get /cas/args/result || fail "reading the reply" - [ "$(cat /cas/args/result)" = v3 ] || fail "getcheck replied '$(cat /cas/args/result)'" - # The same read twice, different nonces: the inner (pure, so cached) runs once. - count_ref="refs/heads/actors-count/$(date +%s%N)-$$-$RANDOM" - call "get c4" n7 "$(probe_inner --count-ref="$count_ref")" after-hit1 --count-ref="$count_ref" - ;; - -after-hit1) - count_ref=$(read_arg count-ref) - remote_head "$count_ref" > /dev/null || fail "the inner did not run" - call "get c4" n8 "$(probe_inner --count-ref="$count_ref")" after-hit2 --count-ref="$count_ref" - ;; - -after-hit2) - count_ref=$(read_arg count-ref) - last=$(remote_head "$count_ref") || fail "count ref vanished" - git fetch -q caos "$last" || fail "fetching $last" - [ "$(git rev-list --count "$last")" = 1 ] \ - || fail "the inner ran $(git rev-list --count "$last") times; the repeat should hit the cache" - printf 'actor: ALL PASS\n' > /tmp/report - cat /tmp/report >&2 - caos put /tmp/report /cas/out - ;; - -*) fail "unknown --stage: $stage" ;; -esac From 583fe7e9d69d2ff80336c4d690cc7be080e51be9 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 80/92] Edit source tree --- design/actors.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/design/actors.md b/design/actors.md index 18f517e9..b6299594 100644 --- a/design/actors.md +++ b/design/actors.md @@ -8,8 +8,10 @@ re-apply (which is also the crash-after-push retry), a read making no commit, the inner's lazy view of the state, and the inner's cache hit. Two caveats: a real crash between push and reply is not injected (a re-applied message is the same observable), and laziness is checked from inside the inner, not by -counting server object reads. Open question 6 (history fetched on every write) -is unresolved. Daemons are deliberately set aside; see +counting server object reads. Both are Go programs on `std/go`. Open question 6 +(history fetched on every write) is resolved for the write path: the wrapper +moves the branch with a direct receive-pack command and an empty pack instead +of `git push`, so it fetches no history (see the question). Daemons are deliberately set aside; see [Deferred: daemons](#deferred-daemons). Builds on [client-owned conversation refs](client-owned-conversation-refs.md) From deaf86769c18734856d7c3c4a24e7da8bee8ab69 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 81/92] Edit source tree --- design/actors.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/design/actors.md b/design/actors.md index b6299594..7d0a2008 100644 --- a/design/actors.md +++ b/design/actors.md @@ -241,9 +241,12 @@ wrapper can use it to build `{state: }` the same way. ## Build plan -The wrapper is a new std tool, `std/actor`, laid out like `run-and-update-ref` -(`rustc` factory, `git-runner` as `--output-runner`). It depends on -`worker-common` and the shared ref code from item 4 above. +The wrapper is a new std tool, `std/actor`: a single Go program run by `std/go` +(Go is the language for new workers), with start and finish as two positions of +one program like `std/run-and-update-ref`. It shells out to the `caos` CLI and, +for the head read, to `git`. The sections above describe the first design, a +Rust wrapper that pushed from a scratch repository; the shipped wrapper replaced +that with the direct receive-pack command described under open question 6. 0. **Spike (verify before building).** An integration test against the test stack, using real `git`: From 9aa59957d08d27c645a3938898f74b6302f3ec75 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 82/92] Edit source tree --- std/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/README.md b/std/README.md index 2bba238d..94afc945 100644 --- a/std/README.md +++ b/std/README.md @@ -45,7 +45,7 @@ test suite exercises them. | `llm-call` | A single model call as an entry, for an expression that wants one without a conversation. | | `rgrep` | The search worker behind the step's `grep` tool. | | `run-and-update-ref` | The async worker: one binary, two stages, behind `run_async` and the subagent tools. | -| `actor` | The actor wrapper: runs an inner `(state, message) -> (state', reply)` request against state on a Git branch, publishing with a leased push (`design/actors.md`). | +| `actor` | The actor wrapper: runs an inner `(state, message) -> (state', reply)` request against state on a Git branch, publishing by moving the branch with a compare-and-swap (`design/actors.md`); a Go program on `std/go`. | | `hello` | The smallest possible entry, used by `tests/hello` and by hand when something is deeply broken. | | `llm-stub` | A scripted stand-in for the model, so `tests/llm-*` run with no API key and no network. | | `llm-test` / `llm-test-tool` | Fixtures the llm tests drive: a test harness entry and a tool for it to call. | From c9d02e0f603ce3a58e3d771a088c3fadc0c9abec Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 83/92] Edit source tree --- design/actors.md => std/actor/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename design/actors.md => std/actor/README.md (100%) diff --git a/design/actors.md b/std/actor/README.md similarity index 100% rename from design/actors.md rename to std/actor/README.md From 4cb4f366ecb3a2b85e0f2aeb623cb98948fea326 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 84/92] Edit source tree --- std/actor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/actor/README.md b/std/actor/README.md index 7d0a2008..dc0d4beb 100644 --- a/std/actor/README.md +++ b/std/actor/README.md @@ -14,7 +14,7 @@ moves the branch with a direct receive-pack command and an empty pack instead of `git push`, so it fetches no history (see the question). Daemons are deliberately set aside; see [Deferred: daemons](#deferred-daemons). -Builds on [client-owned conversation refs](client-owned-conversation-refs.md) +Builds on [client-owned conversation refs](../../design/client-owned-conversation-refs.md) (the Git protocol workers already use for conversation heads) and follows the start/finish shape of `std/run-and-update-ref`. From ffa0e5edc84aaf21456e730d52a5a07e8f5a60b2 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 85/92] Edit source tree --- design/runner-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/runner-protocol.md b/design/runner-protocol.md index a389d19c..71b9bfcb 100644 --- a/design/runner-protocol.md +++ b/design/runner-protocol.md @@ -7,7 +7,7 @@ backends outright: `dispatch_docker`/`dispatch_serve`/`dispatch_fly`, the are all deleted, and the dev stack gains `caos runnerd` as a required daemon. Builds on the runner-pool decomposition (`runner-pool-and-cloud-builds.md`): that doc removes the per-worker *image*; this one removes the per-job -*container start*. See also [actors](actors.md) for state that outlives a job. +*container start*. See also [actors](../std/actor/README.md) for state that outlives a job. --- From 3b288f190b3ca078bde9067f22094b671dd0b1ce Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 86/92] Edit source tree --- std/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/README.md b/std/README.md index 94afc945..ea45f2d5 100644 --- a/std/README.md +++ b/std/README.md @@ -45,7 +45,7 @@ test suite exercises them. | `llm-call` | A single model call as an entry, for an expression that wants one without a conversation. | | `rgrep` | The search worker behind the step's `grep` tool. | | `run-and-update-ref` | The async worker: one binary, two stages, behind `run_async` and the subagent tools. | -| `actor` | The actor wrapper: runs an inner `(state, message) -> (state', reply)` request against state on a Git branch, publishing by moving the branch with a compare-and-swap (`design/actors.md`); a Go program on `std/go`. | +| `actor` | The actor wrapper: runs an inner `(state, message) -> (state', reply)` request against state on a Git branch, publishing by moving the branch with a compare-and-swap ([`actor/README.md`](actor/README.md)); a Go program on `std/go`. | | `hello` | The smallest possible entry, used by `tests/hello` and by hand when something is deeply broken. | | `llm-stub` | A scripted stand-in for the model, so `tests/llm-*` run with no API key and no network. | | `llm-test` / `llm-test-tool` | Fixtures the llm tests drive: a test harness entry and a tool for it to call. | From 8751ce9a9305496dcb32be0586fe264e3a0edfe1 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 87/92] Edit source tree --- std/actor/.caos-expr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/actor/.caos-expr b/std/actor/.caos-expr index 920ca4fa..d1c54c5d 100644 --- a/std/actor/.caos-expr +++ b/std/actor/.caos-expr @@ -1,4 +1,4 @@ -# The actor wrapper (design/actors.md), a std/go worker. One program, two +# The actor wrapper (README.md), a std/go worker. One program, two # positions like run-and-update-ref: start reads the branch head and tail-calls # the inner request; finish publishes the new state by moving the branch with a # compare-and-swap. std/go has git, which start uses to read the head. From 9a05eb1ba0d4c2380be584c9831d347926e1ed7b Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 88/92] Edit source tree --- std/actor/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/actor/worker.go b/std/actor/worker.go index 56d9d0bf..146e6629 100644 --- a/std/actor/worker.go +++ b/std/actor/worker.go @@ -1,4 +1,4 @@ -// The actor wrapper (design/actors.md): run an inner `(state, message) -> +// The actor wrapper (README.md): run an inner `(state, message) -> // (state', reply)` request against state kept on a Git branch, and publish the // new state with a compare-and-swap. // From a5c59ae18aae8d5f2150eda4d838fe83948908ef Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 89/92] Edit source tree --- std/actor/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/std/actor/worker.go b/std/actor/worker.go index 146e6629..e0855a5a 100644 --- a/std/actor/worker.go +++ b/std/actor/worker.go @@ -25,7 +25,7 @@ // do this: it resolves the new commit in a local repository and walks its // ancestry to build a pack, which needs every ancestor commit ("a deep // checkout"), and a partial clone with a promisor remote does not avoid that -// (design/actors.md, open question 6; tests/actor-ref proves the direct route). +// (README.md, open question 6; tests/actor-ref proves the direct route). package main import ( From 4a162a4522503e9b0d0f2ef8f5ad703d68d4b4c5 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 90/92] Edit source tree --- tests/actor/kv.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor/kv.go b/tests/actor/kv.go index 8f7bc8f3..693d9004 100644 --- a/tests/actor/kv.go +++ b/tests/actor/kv.go @@ -1,4 +1,4 @@ -// Reference inner actor (design/actors.md): a key-value store. A pure function +// Reference inner actor (std/actor/README.md): a key-value store. A pure function // of (state tree, message) -> {state, reply}. Messages are idempotent: // // put set key (applying twice is the same as once) From 2f312a38b0e3d4317dd15630d96d07fc26821e42 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 91/92] Edit source tree --- tests/actor-ref/.caos-expr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor-ref/.caos-expr b/tests/actor-ref/.caos-expr index eec9eebd..f0cd6032 100644 --- a/tests/actor-ref/.caos-expr +++ b/tests/actor-ref/.caos-expr @@ -1,3 +1,3 @@ -# SPIKE for design/actors.md open question 6 (see worker.go): a std/go worker +# SPIKE for std/actor/README.md open question 6 (see worker.go): a std/go worker # that moves a branch by speaking git-receive-pack with an empty pack. curry --base:@=DEEP-DEPS/go --worker1:@=worker.go From 018b1a725ff3521ec3dfb232183308b20a2174c6 Mon Sep 17 00:00:00 2001 From: Malcolm Handley Date: Tue, 29 Sep 2026 17:18:26 -0700 Subject: [PATCH 92/92] Edit source tree --- tests/actor-ref/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actor-ref/worker.go b/tests/actor-ref/worker.go index 259526cb..b6d3e6f2 100644 --- a/tests/actor-ref/worker.go +++ b/tests/actor-ref/worker.go @@ -1,4 +1,4 @@ -// tests/actor-ref — SPIKE for design/actors.md, open question 6. +// tests/actor-ref — SPIKE for std/actor/README.md, open question 6. // // Can a worker move a branch with a compare-and-swap WITHOUT any scratch // repository and without fetching any history? A git push is a command line