Author SHA1 Message Date
Omar SobhandClaude Opus 5 0d9498ec6e fix(missions): copy an allow-list, not the whole 1.7GB seed dir
Checking before deploying caught a mistake in the previous commit. The
seed dir on gw-04 is 1.7 GB and the first version copied all of it per
mission — tens of seconds each, and ~17 GB across ten concurrent
missions.

1.5 GB of that is .rustup: a Rust toolchain that installed itself into
the data dir back when HOME=/zeroclaw-data and the image had no
toolchain. The image now ships Rust at /usr/local/cargo, which is what
the container's PATH actually resolves — verified live. The data-dir copy
is dead weight and is not even reachable.

SEEDED_PATHS now copies only what carries per-mission identity or
secrets: .zeroclaw (config.toml with the door token, sessions.db,
devices.db), clawmates-mcp.json, .claude + .claude.json, .kimi-code,
glm-home, agents. Roughly 46 MB instead of 1.7 GB — about 37x smaller.

Caches and toolchains are deliberately excluded: .rustup, .npm, .cargo,
.cache, .local. They hold no secrets and a mission reads the image's.

Absent paths are tolerated: a fresh deployment has no .kimi-code until
Kimi is first used, and that must not fail container creation.

The test asserts both directions — the token-bearing paths ARE copied
and the caches are NOT — because either mistake is silent: copying
everything just makes missions slow, and copying nothing quietly
restores the credential sharing.

409 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 12:26:04 -07:00
Omar SobhandClaude Opus 5 15e7608e4a fix(missions): give each mission its own runtime data
Every per-mission container bind-mounted the SAME host seed dir as
/zeroclaw-data — shared with each other AND with the singleton runtime.
That directory holds config.toml, which carries the §15 door bearer
token, plus sessions.db and devices.db.

So one mission could read another mission's credential, and anything it
wrote there was inherited by every later mission. teardown_container
only removes /var/lib/clawmates-missions/{id}, so the shared directory
was never cleaned — the contamination was permanent.

The code already knew. The comment on DEFAULT_SEED_DIR names the sqlite
race and calls copy-on-write per mission the long-term fix. This is that
fix: seed_runtime_data copies the seed into
<missions_root>/<mission>/runtime-data at container create, and the
mount points there. Cleanup is free — teardown already removes that tree.

The copy runs in a throwaway container because cm-api cannot see the seed
dir: it hands that host path to Docker but never mounts it itself. The
runtime image is reused so nothing extra is pulled, and `cp -a /seed/.`
copies dotfiles — `/seed/*` would silently skip .zeroclaw/ and produce a
runtime with no config at all.

A copy failure is fatal to container creation on purpose. Falling back to
the shared mount would silently restore the credential sharing this
removes, and silent fallback to a weaker posture is the failure mode this
codebase keeps paying for.

The test asserts path shape rather than behaviour: an edit that points
the mount back at the seed dir restores credential sharing with no other
visible symptom, so the path IS the invariant.

408 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 12:14:38 -07:00
Omar SobhandClaude Opus 5 5d98fcf44a feat(missions): forward ZAI/KIMI keys so one binary serves three backends
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
All three providers run through the SAME `claude` binary, verified live:

  Anthropic  CLAUDE_CODE_OAUTH_TOKEN                          -> ANTHROPIC-OK
  GLM        ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic -> GLM-OK
  Kimi       ANTHROPIC_BASE_URL=https://api.kimi.com/coding/   -> KIMI-OK

That is a stronger multi-provider story than a provider-per-implementation:
skills, subagents, MCP, hooks and tool policy are identical across all
three because it is literally the same harness.

The `kimi` CLI (0.31.1, shipped in the image) 401s on this key and is not
needed -- the claude binary reaches Kimi's Anthropic-compatible endpoint
directly. Worth knowing before someone debugs the CLI.

forwarded_provider_keys now ships ZAI_API_KEY and KIMI_API_KEY into
mission containers in BOTH auth modes: they are unrelated to the Anthropic
credential, so the api_key/subscription split does not apply to them. A
mission that selects a backend without its key present would otherwise
fail at the first turn.

Keys persisted in /opt/clawmates/.env and passed through compose.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 10:51:34 -07:00
Omar SobhandClaude Opus 5 4ff4e6f7ee fix(missions): a root-owned COMMIT_EDITMSG must not block delivery
ci / gates (push) Failing after 18s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Mission 019fcd0c produced correct work — a reviewed, tested function plus
a REVIEW.md quoting a real cargo test summary — and delivered none of it:

  git commit → exit 128: could not open '.git/COMMIT_EDITMSG': Permission denied

The agent ran `git commit` itself inside the mission container (as root),
leaving that file owned by root at 0644. core.sharedRepository covers
objects and refs — .git/index lands at 0666, which is why commits work at
all — but not COMMIT_EDITMSG, which git writes with the default umask.

Unlinking works where overwriting does not: removing a file needs write
permission on the DIRECTORY, and .git/ is owned by the server. Silent on
failure by design, so the commit reports the real error rather than this
speculative cleanup.

Third distinct instance of the same uid-split class (objects, then the
capture base, now this). The pattern holds: the checkout is one directory
written by two users, and each new file git touches is a new opportunity.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-04 07:35:37 -07:00
Omar Sobh deb60be98d Merge: direct session executor for missions (flag-gated)
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
2026-08-03 22:56:30 -07:00
Omar SobhandClaude Opus 5 758b2dbd96 feat(missions): run a phase as one direct session, behind a flag
CLAWMATES_MISSION_EXECUTOR=session makes launch_phase run the whole phase
as a single `claude -p` against /mission/repo instead of driving turns
through ZeroClaw. Opt-in, because silently changing how every mission
executes is exactly the sort of change that should require someone to
have typed it.

It still writes ONE topology_runs row. The entire downstream lifecycle --
close_finished_phases, evaluation, capture, commit, gate, publish -- keys
off those rows, and inventing a second completion path would mean two ways
for a phase to finish with one of them untested. The session is simply a
run with tier='session' and an empty graph.

Spawned rather than awaited: launch_phase runs inside the sweep loop, and
blocking it for the length of a coding session would stall every other
mission.

The session's own summary is logged as diagnostics only. Whether the phase
actually did anything is still decided downstream by capture and delivery
against the repository -- a 0-exit session that pushed nothing was measured
at ~5%, so the agent's account can never be the verdict.

406 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 22:56:22 -07:00
Omar SobhandClaude Opus 5 37fac288d2 feat(missions): bring the direct session executor onto a live branch
Rescues session_executor from the stranded spike branch. Multi-provider
missions are not needed for now, so the direct path is worth nailing down:
run a mission as one `claude -p` session against its checkout instead of
routing turns through ZeroClaw.

Measured today against a real checkout in the runtime container, using the
executor's exact argv:

  direct `claude -p`   7s, file written
  via ZeroClaw         minutes per turn, and THREE config failures before
                       it worked at all (no credential in the mission
                       container; Write/Edit denied; no tools granted --
                       the last of which COMPLETED a mission having
                       written nothing)

Each of those failures came from the same root: with claude_cli, ZeroClaw
is a WebSocket-to-subprocess adapter whose own controls (risk profiles,
tool gating, memory) do not reach the subprocess. The adapter adds
failure modes without adding governance.

What ZeroClaw still earns for the rest of the platform is unchanged and
not in question here: interactive chat, the brain, A2A and door identity,
terminal, agent routines, and non-Claude providers.

Not yet wired into phase_runner — this commit only makes the executor
reachable and keeps it building. SessionOutcome::delivered() still
requires a clean exit AND an observed branch, because a 0-exit session
that pushed nothing was measured at ~5%.

405 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 21:26:48 -07:00
Omar SobhandClaude Opus 5 5232175c88 fix(missions): forward the subscription token into mission containers
ci / gates (push) Failing after 9s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Switching agents to claude_cli left missions hanging: the per-mission
container had claude_cli configured but no credential, so `claude -p`
waited forever. A phase sat at `running` for ten minutes with nothing in
the logs — no error, because there is nothing to error on.

The original subscription design assumed a persisted `claude /login`
under a bind-mounted $HOME. That holds for the shared runtime and NOT for
a mission container, which gets its own data dir and therefore no login.
So subscription mode now forwards CLAUDE_CODE_OAUTH_TOKEN.

The two Anthropic credentials remain mutually exclusive, and there is now
a test asserting it in both directions: Claude Code ranks ANTHROPIC_API_KEY
above the OAuth token, so shipping both bills the API while the deployment
believes it is on the subscription — visible only on the invoice.

Deployment: CLAWMATES_RUNTIME_AUTH=subscription and CLAUDE_CODE_OAUTH_TOKEN
added to compose + .env on gw-04.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 17:34:13 -07:00
Omar SobhandClaude Opus 5 ac47dcbe94 feat(runtime): run agents on the subscription via the real claude binary
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
provider_alias_for now resolves Claude models to `claude_cli.default`,
which spawns the actual `claude` binary, instead of `anthropic.default`,
which posts to the raw API with Claude Code identity headers. Agent work
is ~99% of tokens, so this moves essentially all of it onto the Max
subscription and onto the supported client.

The judge deliberately stays on the API key. If both rode one credential,
a single subscription limit would blind the verifier at exactly the
moment there is most to verify; this way a throttle degrades missions but
verification keeps working.

Runtime config (applied on gw-04, reloaded via loopback — remote admin
reload is disabled by design):
  - [providers.models.claude_cli.default] with mcp_config pointing at the
    §15 door, so a subscription agent can ACT and not merely reason
  - disallowed_tools denies Claude Code's own Bash/Write/Edit/WebFetch so
    the gated door is the ONLY actuator and nothing bypasses the audit log
  - env CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN" — the $NAME
    form reads the daemon env, keeping the token out of config.toml
  - anthropic.judge swapped to the API key (0 oat01 left in config)

Verified before changing anything: the real binary returned SUBSCRIPTION-OK
through the token, then ENV-OK once the daemon carried it in env.

Note for future readers: /api/config/prop reflects what is CONFIGURED, not
what the binary supports — `openai` 404s there too. An earlier note that
the image "has no claude_cli in its schema" was true of the old :sync
image and is not true of the rebuilt one.

400 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 17:14:42 -07:00
Omar SobhandClaude Opus 5 ad89ef94cd feat(library): attribute a run to a mission, and prove what it contributed
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
`corpus_items.mission_id` has existed since the table landed and nothing
could populate it. `POST /api/library/runs` now accepts `missionId`, which
is the seam the wizard needs: a mission-driven run is the same run, tagged.

`corpus::contributed()` answers the question a continuous mission has to
be able to answer — did THIS run add anything new. Because `record` never
reassigns mission_id on conflict, the mission that first found a source
keeps the credit, so a rerun cannot inflate its own count by re-recording
what an earlier run already held. The test asserts exactly that: two
missions see the same paper, the finder reports 1 and the rerun reports 0.

This is the check the 0030-0044 generation of continuous research did not
have. It could run weekly forever and every run looked like success.

The test also earned its FK: the first version attributed to a bare UUID
and the database refused it. Attribution to a mission that does not exist
is not attribution, so the test now seeds real mission rows.

400 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 11:43:41 -07:00
Omar SobhandClaude Opus 5 9de2cf34e4 feat(auto-merge): merge additive branches, refuse everything else
ci / gates (push) Failing after 10s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Closes the branch pile-up: a catalogue branch that only adds notes now
merges into main by itself, so the work is actually in the vault rather
than waiting in a branch nobody opened.

Additive-only is measured from the diff, not assumed from the mission
type. Three conditions, all required: the type declares additive_only,
the run verified, and `git diff --name-status base...branch` contains
only A entries. A research harvest that somehow rewrote a hand-written
note is refused by the same check that lets its new notes through —
which is the case the test pins down, asserting README.md on main is
byte-identical afterwards.

Renames and deletes count as non-additive. A rename is a delete plus an
add and the delete half can destroy hand-written work.

Unknown merge_policy values fail closed to Never. A typo must not grant
auto-merge.

The diff is taken against FETCH_HEAD, freshly fetched, using `...` so an
unrelated commit landing on main meanwhile is not misread as ours. A
conflicted merge aborts and leaves the branch for a human rather than
wedging the checkout for the next run.

merge_reason is always populated and surfaced in the API: a branch that
quietly did not merge is indistinguishable from one never delivered.

399 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 11:33:41 -07:00
Omar SobhandClaude Opus 5 3124fd3c8f feat(library): weekly harvest on a systemd timer
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
Monday 07:00, Persistent=true so a week missed to downtime fires on next
boot rather than leaving a silently empty library. 30-minute timeout so a
wedged run cannot hold the slot until the following week.

The script is deliberately thin — it calls the API and reports — so it
never needs changing when the harvest does. Auth is a long-lived operator
session in /etc/clawmates/library.token (root-only, 600); rotate by
replacing the file.

Exit status follows `healthy`, not paper count. A mature library shelves
nothing most weeks and that is success; a run that errored is a failure
even if it shelved something.

The first manual fire caught a real bug in this script, in the opposite
direction to this week's usual: the harvest genuinely shelved 15 papers
and pushed them, and the reporter crashed on an escaped quote inside an
f-string, so systemd marked the unit FAILED. A false failure destroys
trust in the signal exactly as a false success does. The reporter now
avoids backslashes entirely (it is embedded in a single-quoted shell
string) and was proved against the real response shape before being
trusted.

Verified end to end on gw-04:
  run 1: 25 candidates, 10 already held, 15 shelved, pushed
  run 2: 25 candidates, 25 already held,  0 shelved, no branch, healthy

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 10:59:13 -07:00
Omar Sobh cf076bd8ea Merge: paper library — corpus, arXiv harvest, vault catalogue, API
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
2026-08-03 10:53:20 -07:00
Omar SobhandClaude Opus 5 107f0dbced feat(library): expose the library over the API
POST /api/library/runs harvests now; GET /api/library/items lists what
the library holds. Thin wrappers — the work stays in crate::library — so
a run can be started by a person, a schedule or the UI rather than only
from an integration test.

The response reports `healthy` explicitly rather than leaving a caller to
infer it from an empty `shelved` list. A quiet week and a broken run both
shelve zero papers, and collapsing those two is the exact ambiguity that
cost most of this week.

Failure reasons go to the log, not the response body: they can carry the
remote URL and raw git stderr.

AppState gains an optional blob store (the shelf), wired from the server
binary where storage is already constructed. Optional because AppState::new
is used by tests that never touch blobs; a route that needs it fails
loudly rather than the constructor demanding it everywhere.

393 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 10:21:00 -07:00
Omar SobhandClaude Opus 5 09c6496725 feat(library): clone the vault, harvest our topics, push the catalogue
Completes the loop: the notes now land in the real vault. Topics come
from what the project is actually working on — papers/dynamic-agentic-
topologies.md (topology search, ADAS/Darwin-Godel/SwarmAgentic) plus the
two problems this week ran into, verifying what an agent did and giving
a long-running agent memory of what it covered.

Never pushes to main. The vault is a live Obsidian vault a human edits
and syncs; pushing to main races that sync and can lose hand-written
work. Every run lands on its own branch for a human to merge, the same
rule the mission delivery path was validated 20/20 under.

PDFs are NOT committed. A few hundred papers is gigabytes and would make
the vault painful to clone and slow to open, so they stay on the blob
store shelf and the note carries the key.

My own test caught me repeating this week's branch-collision bug: I named
branches from the HEAD of a UUIDv7, which is a 48-bit timestamp, so two
runs in the same millisecond produce the identical name — exactly what
hit mission 019fc42b. Fixed by taking the tail. The test now loops 100
ids instead of sampling two (a one-shot check passes by luck whenever the
millisecond ticks between calls) and additionally asserts the head-based
scheme DOES collide, so it cannot rot into a no-op.

Live against the real vault:
  10 candidates, 1 already held, 9 shelved, 0 failed
  branch clawmates/library-019fc82292e8, pushed
  9 notes verified on the forge, 9 PDFs verified %PDF on the shelf
  (the "1 already held" is cross-topic dedupe inside a single run)

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 08:03:14 -07:00
Omar SobhandClaude Opus 5 30eaa50c50 feat(harvest): one run — find, skip what we hold, shelve the rest
Turns the parts into a job. Order is the point: the checkmark list is
consulted BEFORE anything downloads. Checking afterwards would still
dedupe the catalogue while re-downloading every paper we already have,
every week, forever.

Two properties the tests pin down, both learned the hard way this week:

- A quiet week is not a failure. `shelved == 0` with no errors is a
  healthy run against a mature library; `shelved == 0` with errors is
  broken. Harvest::healthy() and ::added_anything() keep those apart
  rather than collapsing them into one ambiguous "did nothing".
- A failed download leaves the paper UNSEEN. Checking it off before the
  PDF is safely shelved would mean one transient network error retires
  that paper permanently. The checkmark is written last, after the bytes
  and the note are both on disk.

The skip test gives every candidate a pdf_url pointing at a closed port,
so if the skip ever regresses the test fails loudly instead of quietly
re-fetching.

Live end-to-end against arXiv, run twice:
  RUN1  3 candidates, 0 already held, 3 shelved, 0 failed
  RUN2  3 candidates, 3 already held, 0 shelved, 0 failed

Library<'_> groups the five values that always describe one library;
passing them loose is how a run shelves into one place and catalogues
into another (also silences clippy::too_many_arguments honestly rather
than by allow).

391 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:53:56 -07:00
Omar SobhandClaude Opus 5 e4a395b72e feat(papers): find papers on arXiv, shelve the PDF, catalogue the note
Corrects a misread of the design. I had built this as "read the vault to
find papers"; the vault is the CARD CATALOGUE, not the source. Papers are
found on arXiv, the PDF is pulled down and shelved in our own library,
and a note recording it goes in the vault.

Three parts, and which is which matters:
  arXiv       — where papers are found
  blob store  — the shelf; the PDF lives there (cm-files, local + S3)
  the vault   — the catalogue; one note per paper, pointing at the shelf

The checkmark list (corpus, 0064) is what makes this continuous rather
than a job that redoes itself every week — the failure that killed the
previous attempt (0030-0044, dropped in 0053).

The load-bearing detail: every catalogue note carries
`source_id: arxiv:NNNN.NNNNN` in frontmatter, which is exactly the key
corpus::parse_note reads. So the checkmark list is rebuildable FROM the
vault. If the database were lost, re-indexing restores what we have —
the catalogue is authoritative, the index is derived. A test asserts that
round trip rather than trusting the two halves to agree.

Version suffixes are stripped (2401.12345v3 -> 2401.12345) or a weekly
job re-downloads a paper every time authors post a revision. Fetches are
rejected unless the bytes start with %PDF: arXiv serves an HTML holding
page while a PDF renders, and shelving that leaves a file that looks
present and is unreadable.

Verified against live arXiv, not fixtures:
  arxiv:2607.29678 TokTier: Exact Stateful Tokenization for Agentic LLM…
  arxiv:2607.29677 ExtractBench: A Benchmark for Schema-Guided Enterpri…
  arxiv:2607.29658 Reusing Past Repairs Through Hierarchical Trajectory…
  pdf: 1,361,770 bytes, %PDF verified

388 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:38:07 -07:00
Omar SobhandClaude Opus 5 6e5ccc25a6 feat(corpus): record what a continuous mission has already covered
Slice 2 of the adopt-or-build plan. A recurring mission's hard problem is
not running the agent — that is 23 seconds — it is knowing what it did
last time. This repository already tried continuous research once:
migrations 0030-0044 built research_topics/loops, 0053 dropped them all,
and the reason they could not survive is that research_topics carried a
status lifecycle but no seen-set. It could run forever and never know
what it had covered.

Two kinds of row, because the real vault forced it. The plan assumed
notes carry arxiv:/doi:/url: frontmatter. Measured against the actual
valhalla-vault: 416 notes, 145 with frontmatter, and ZERO with any of
those keys — the dominant keys are repo-sync metadata (node, org, gitea)
and course fields (presenter, session). An ingester keyed only on
external identity would have indexed nothing, which is the same shape of
failure as everything else found this week. So `note` rows record
coverage (keyed by path) and `source` rows record consumption (keyed by
natural id); a continuous mission needs both.

Two decisions the data forced:

- `source:` is deliberately NOT an identity key. The vault uses it for
  local paths of course material (/Users/quantum/Downloads/...), which is
  provenance, not citable identity. Accepting it would fill the seen-set
  with 25 rows keyed on a laptop path.
- The hash covers the body, not the whole file. Repo-sync notes rewrite
  updated:/size_kb: on every sync without the prose changing; hashing the
  file would report 103 phantom edits per run and make "unchanged"
  meaningless.

Authoritative in Postgres rather than ZeroClaw memory, per the Slice 1
spike: memory is agent-scoped and mission agents are ephemeral
claw_<uuid> aliases (~100 already present). A seen-set that disappears
with the agent that wrote it is not a seen-set. The spike did find that
POST /api/memory upserts by key, so mirroring content there later would
inherit idempotence for free if keyed by source_id.

Verified against the live 416-note vault, not a fixture:
  PASS1 { scanned: 416, inserted: 416, updated: 0, unchanged: 0 }
  PASS2 { scanned: 416, inserted: 0,   updated: 0, unchanged: 416 }

382 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 07:00:21 -07:00
25 changed files with 3304 additions and 36 deletions
Generated
+1
View File
@@ -946,6 +946,7 @@ dependencies = [
"cm-config", "cm-config",
"cm-db", "cm-db",
"cm-domain", "cm-domain",
"cm-files",
"cm-llm", "cm-llm",
"cm-orchestrator", "cm-orchestrator",
"cm-runtime", "cm-runtime",
+2 -1
View File
@@ -266,7 +266,7 @@ async fn run() -> Result<(), String> {
terminals, terminals,
providers: provider_registry, providers: provider_registry,
}, },
blob, blob.clone(),
); );
// Durable §15 path: expires overdue approvals and resumes decided runs // Durable §15 path: expires overdue approvals and resumes decided runs
// even if the deciding request's process died mid-flight. // even if the deciding request's process died mid-flight.
@@ -388,6 +388,7 @@ async fn run() -> Result<(), String> {
.with_broker(PathBuf::from(&config.broker.socket_path)) .with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.clone()) .with_oauth(config.oauth.clone())
.with_billing(config.billing.clone()) .with_billing(config.billing.clone())
.with_blobs(blob.clone())
.with_file_root( .with_file_root(
(config.storage.backend == cm_config::StorageBackend::Local) (config.storage.backend == cm_config::StorageBackend::Local)
.then(|| PathBuf::from(&config.storage.data_dir)), .then(|| PathBuf::from(&config.storage.data_dir)),
+1
View File
@@ -32,6 +32,7 @@ cm-brain = { path = "../cm-brain" }
cm-config = { path = "../cm-config" } cm-config = { path = "../cm-config" }
cm-db = { path = "../cm-db" } cm-db = { path = "../cm-db" }
cm-domain = { path = "../cm-domain" } cm-domain = { path = "../cm-domain" }
cm-files = { path = "../cm-files" }
cm-llm = { path = "../cm-llm" } cm-llm = { path = "../cm-llm" }
cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] } cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] }
cm-runtime = { path = "../cm-runtime" } cm-runtime = { path = "../cm-runtime" }
+222
View File
@@ -0,0 +1,222 @@
//! Merging a delivered branch into the base, when that is provably safe.
//!
//! Every mission type delivers to a branch and never to `main`. For most that
//! is where it should stop — a human reads the code and merges. But some
//! missions only ever *add* files in a folder they own: a paper catalogue, a
//! benchmark record. Those branches carry no judgement call, and leaving them
//! to pile up unmerged means the work is done but not actually in the vault.
//!
//! # Additive-only is a property, not a preference
//!
//! The gate is not "is this mission type trusted". It is measured from the
//! diff: if the branch modifies or deletes anything that already existed, it
//! does not qualify, whatever its template says. A research harvest that
//! somehow rewrote a hand-written note would be refused by the same check
//! that lets its new notes through.
//!
//! Three conditions, all required:
//!
//! 1. the mission type declares [`MergePolicy::AdditiveOnly`]
//! 2. verification passed — a run that did not prove its work does not merge
//! 3. the diff against the base contains only additions
//!
//! Anything else lands as a branch for a human, which is the existing
//! behaviour and the safe default.
use std::path::Path;
/// What a mission type is allowed to do with its own branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergePolicy {
/// Always leave the branch for a human. Correct for anything that touches
/// code: `refactor`, `research_and_code`, security patches.
Never,
/// Merge automatically when the diff is provably additive and the run
/// verified. Correct for catalogues and recorded measurements.
AdditiveOnly,
}
impl MergePolicy {
/// Parse a template's `merge_policy`. Unknown values fall back to `Never`
/// and say so: a typo must not silently grant auto-merge.
pub fn parse(raw: Option<&str>) -> MergePolicy {
match raw.map(str::trim) {
Some("additive_only") => MergePolicy::AdditiveOnly,
Some("never") | None => MergePolicy::Never,
Some(other) => {
eprintln!(
"auto_merge: unknown merge_policy {other:?} — refusing to auto-merge"
);
MergePolicy::Never
}
}
}
}
/// Why a branch was or was not merged. The reason is always recorded: a
/// branch that silently did not merge is indistinguishable from one that was
/// never delivered.
#[derive(Debug, Clone)]
pub struct MergeOutcome {
pub merged: bool,
pub reason: String,
}
impl MergeOutcome {
fn refused(reason: impl Into<String>) -> MergeOutcome {
MergeOutcome {
merged: false,
reason: reason.into(),
}
}
}
/// Classify a `git diff --name-status` body.
///
/// Returns the offending entries, empty when every change is an addition.
/// Split out so the rule is testable without a repository.
pub fn non_additive_changes(name_status: &str) -> Vec<String> {
name_status
.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| {
// Status is the first field: A/M/D/R###/C###.
!matches!(l.chars().next(), Some('A'))
})
.map(|l| l.trim().to_string())
.collect()
}
async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
let out = tokio::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", &format!("safe.directory={}", repo.display())])
.args(args)
.env("GIT_AUTHOR_NAME", crate::mission_delivery::commit_identity().0)
.env("GIT_AUTHOR_EMAIL", crate::mission_delivery::commit_identity().1)
.env(
"GIT_COMMITTER_NAME",
crate::mission_delivery::commit_identity().0,
)
.env(
"GIT_COMMITTER_EMAIL",
crate::mission_delivery::commit_identity().1,
)
.output()
.await
.map_err(|e| format!("spawn git: {e}"))?;
if !out.status.success() {
return Err(format!(
"git {} → {}: {}",
args.first().copied().unwrap_or("?"),
out.status,
crate::mission_workspace::redact_token(&String::from_utf8_lossy(&out.stderr))
.chars()
.take(300)
.collect::<String>()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Merge `branch` into `base` and push, if all three conditions hold.
///
/// Never returns `Err` for a refusal — a refusal is a normal outcome with a
/// reason. `Err` is reserved for the merge itself going wrong after we decided
/// to attempt it.
pub async fn try_merge(
repo: &Path,
push_url: &str,
branch: &str,
base: &str,
policy: MergePolicy,
verified: bool,
) -> Result<MergeOutcome, String> {
if policy != MergePolicy::AdditiveOnly {
return Ok(MergeOutcome::refused(
"merge_policy is not additive_only; left for a human",
));
}
if !verified {
return Ok(MergeOutcome::refused(
"run did not verify; refusing to merge unproven work",
));
}
// Compare against the base as the REMOTE has it, not a local ref that may
// be stale. `...` gives changes on the branch since it diverged, so an
// unrelated commit landing on main meanwhile is not misread as ours.
git(repo, &["fetch", push_url, base]).await?;
let diff = git(
repo,
&["diff", "--name-status", &format!("FETCH_HEAD...{branch}")],
)
.await?;
let offending = non_additive_changes(&diff);
if !offending.is_empty() {
return Ok(MergeOutcome::refused(format!(
"diff is not additive ({} non-add change(s), first: {}); left for a human",
offending.len(),
offending.first().map(String::as_str).unwrap_or("?")
)));
}
if diff.trim().is_empty() {
return Ok(MergeOutcome::refused("branch adds nothing"));
}
// Merge onto the freshly fetched base rather than a local branch.
git(repo, &["checkout", "-B", base, "FETCH_HEAD"]).await?;
if let Err(e) = git(
repo,
&["merge", "--no-ff", "-m", &format!("auto-merge {branch}"), branch],
)
.await
{
// Leave the repo clean so the next run is not fighting a wedged merge.
let _ = git(repo, &["merge", "--abort"]).await;
return Ok(MergeOutcome::refused(format!(
"merge conflicted ({e}); left for a human"
)));
}
git(repo, &["push", push_url, &format!("HEAD:refs/heads/{base}")]).await?;
Ok(MergeOutcome {
merged: true,
reason: format!("additive-only and verified; merged into {base}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_pure_additions_qualify() {
assert!(non_additive_changes("A\t60 Papers/a.md\nA\t60 Papers/b.md\n").is_empty());
// A modification disqualifies the whole branch.
let m = non_additive_changes("A\t60 Papers/a.md\nM\tREADME.md\n");
assert_eq!(m.len(), 1);
assert!(m[0].contains("README.md"));
// So do deletes and renames — a rename is a delete plus an add, and
// the delete half can destroy hand-written work.
assert_eq!(non_additive_changes("D\tnotes/old.md\n").len(), 1);
assert_eq!(non_additive_changes("R100\ta.md\tb.md\n").len(), 1);
}
#[test]
fn an_unknown_policy_never_grants_auto_merge() {
assert_eq!(MergePolicy::parse(None), MergePolicy::Never);
assert_eq!(MergePolicy::parse(Some("never")), MergePolicy::Never);
assert_eq!(
MergePolicy::parse(Some("additive_only")),
MergePolicy::AdditiveOnly
);
// A typo must fail closed, not open.
assert_eq!(MergePolicy::parse(Some("aditive_only")), MergePolicy::Never);
assert_eq!(MergePolicy::parse(Some("always")), MergePolicy::Never);
}
}
+495
View File
@@ -0,0 +1,495 @@
//! What a continuous mission has already covered.
//!
//! A recurring mission's hard problem is not running the agent — that is 23
//! seconds — it is knowing what it already did last time. A research mission
//! with no memory of prior runs resurfaces the same papers forever and reports
//! success every time.
//!
//! This module keeps that record. It is deliberately small: an index derived
//! from the corpus, never the corpus itself. The vault is the source of truth,
//! the index is rebuildable, and a hand-edited note is never "wrong".
//!
//! # Two kinds, because the real vault forced it
//!
//! The plan assumed notes would carry `arxiv:` / `doi:` / `url:` frontmatter.
//! Measured against the actual vault: **416 notes, 145 with frontmatter, and
//! zero with any of those keys.** The dominant keys are repo-sync metadata
//! (`node`, `org`, `gitea`) and course-note fields (`presenter`, `session`).
//! An ingester keyed only on external identity would have indexed nothing —
//! the same shape of failure as everything else this week.
//!
//! So `note` rows record coverage (what the vault already contains, keyed by
//! path) and `source` rows record consumption (external things a mission
//! read, keyed by natural id). They answer different questions and a
//! continuous mission needs both: "have I already written about this topic?"
//! and "have I already read this paper?".
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// A note parsed out of the vault, ready to be indexed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedNote {
/// Vault-relative path, used as identity for `kind = 'note'`.
pub path: String,
pub title: Option<String>,
pub content_hash: String,
/// An external identity the note declares for itself, if any. Nothing in
/// the vault does this today; missions writing new notes are expected to.
pub declared_source_id: Option<String>,
}
impl ParsedNote {
/// `note:<path>` — the `source_id` this note occupies in the index.
pub fn source_id(&self) -> String {
format!("note:{}", self.path)
}
}
/// Hash content for change detection. Not a dedupe key — identity is
/// `source_id`; this only distinguishes "unchanged" from "edited".
pub fn content_hash(body: &str) -> String {
let mut h = Sha256::new();
h.update(body.as_bytes());
format!("{:x}", h.finalize())
}
/// Split YAML frontmatter from the body.
///
/// Returns `(frontmatter, body)`. A note without frontmatter — 271 of the 416
/// in the real vault — yields `("", whole file)` rather than being skipped.
/// Skipping them would drop two thirds of the corpus on the floor.
fn split_frontmatter(text: &str) -> (&str, &str) {
let Some(rest) = text.strip_prefix("---") else {
return ("", text);
};
let rest = rest.strip_prefix('\n').unwrap_or(rest);
match rest.find("\n---") {
Some(end) => {
let body = &rest[end + 4..];
(&rest[..end], body.strip_prefix('\n').unwrap_or(body))
}
// An opening fence with no close is malformed; treat the whole file as
// body rather than swallowing it as frontmatter.
None => ("", text),
}
}
/// Read one scalar key out of a frontmatter block.
///
/// Deliberately not a YAML parser. The vault's frontmatter is flat
/// `key: value` with occasional quotes and one list (`tags`), and pulling in a
/// YAML dependency to read three keys would be more surface than it is worth.
fn frontmatter_value<'a>(fm: &'a str, key: &str) -> Option<&'a str> {
for line in fm.lines() {
let line = line.trim();
let Some((k, v)) = line.split_once(':') else {
continue;
};
if !k.trim().eq_ignore_ascii_case(key) {
continue;
}
let v = v.trim().trim_matches('"').trim_matches('\'').trim();
if !v.is_empty() {
return Some(v);
}
}
None
}
/// Which frontmatter keys may declare an external identity, in priority order.
///
/// None of these appear in the vault today. They are the contract for notes
/// that missions write from here on, and the reason a `source:` key is NOT in
/// the list: the vault already uses `source:` for local filesystem paths of
/// course material (`/Users/quantum/Downloads/...`), which is provenance, not
/// a citable external identity. Treating it as one would fill the seen-set
/// with 25 rows keyed on a laptop path.
const IDENTITY_KEYS: &[&str] = &["source_id", "arxiv", "doi", "url", "permalink"];
/// Parse a note. `path` must be vault-relative.
pub fn parse_note(path: &str, text: &str) -> ParsedNote {
let (fm, body) = split_frontmatter(text);
let declared_source_id = IDENTITY_KEYS.iter().find_map(|k| {
frontmatter_value(fm, k).map(|v| {
// `source_id` is already qualified; the others name their scheme.
if *k == "source_id" || v.contains(':') {
v.to_string()
} else {
format!("{k}:{v}")
}
})
});
// Title: the first markdown H1, else the filename stem. Frontmatter has no
// consistent title key in this vault.
let title = body
.lines()
.find_map(|l| l.strip_prefix("# ").map(str::trim))
.filter(|t| !t.is_empty())
.map(str::to_string)
.or_else(|| {
std::path::Path::new(path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
});
ParsedNote {
path: path.to_string(),
title,
// Hash the body, not the whole file: re-syncing a repo note rewrites
// `updated:`/`size_kb:` in frontmatter without the prose changing, and
// that should not read as an edit.
content_hash: content_hash(body),
declared_source_id,
}
}
/// What a re-index actually did. `unchanged` is the number that matters: on a
/// vault nobody edited it should equal the note count.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct IndexStats {
pub scanned: usize,
pub inserted: usize,
pub updated: usize,
pub unchanged: usize,
}
/// Walk a checkout and index every markdown note.
///
/// Skips `.git` and Obsidian's own `.obsidian` config directory — indexing an
/// editor's workspace state as knowledge would be noise.
pub fn collect_notes(root: &std::path::Path) -> Vec<ParsedNote> {
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<ParsedNote>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
walk(&path, root, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
out.push(parse_note(&rel, &text));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
/// Upsert one item. Returns whether the row was new.
#[allow(clippy::too_many_arguments)]
pub async fn record(
pool: &sqlx::PgPool,
workspace_id: Uuid,
corpus_id: &str,
kind: &str,
source_id: &str,
title: Option<&str>,
path: Option<&str>,
url: Option<&str>,
content_hash: &str,
mission_id: Option<Uuid>,
) -> Result<bool, String> {
// `last_seen_at` always moves; `first_seen_at` and `mission_id` never do.
// The first mission to find a source keeps the credit, which is what makes
// "did THIS run contribute anything new" answerable.
let row: (bool,) = sqlx::query_as(
"INSERT INTO corpus_items
(id, workspace_id, corpus_id, kind, source_id, title, path, url,
content_hash, mission_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (workspace_id, corpus_id, source_id) DO UPDATE
SET last_seen_at = now(),
title = COALESCE(EXCLUDED.title, corpus_items.title),
path = COALESCE(EXCLUDED.path, corpus_items.path),
url = COALESCE(EXCLUDED.url, corpus_items.url),
content_hash = EXCLUDED.content_hash
RETURNING (xmax = 0) AS inserted",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(corpus_id)
.bind(kind)
.bind(source_id)
.bind(title)
.bind(path)
.bind(url)
.bind(content_hash)
.bind(mission_id)
.fetch_one(pool)
.await
.map_err(|e| format!("record corpus item {source_id}: {e}"))?;
Ok(row.0)
}
/// Has this corpus already seen this `source_id`?
pub async fn seen(
pool: &sqlx::PgPool,
workspace_id: Uuid,
corpus_id: &str,
source_id: &str,
) -> Result<bool, String> {
// `SELECT 1` is INT4; binding it as i64 fails to decode.
let row: Option<(i32,)> = sqlx::query_as(
"SELECT 1 FROM corpus_items
WHERE workspace_id = $1 AND corpus_id = $2 AND source_id = $3",
)
.bind(workspace_id)
.bind(corpus_id)
.bind(source_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("seen({source_id}): {e}"))?;
Ok(row.is_some())
}
/// Of these candidate ids, which has this corpus NOT seen?
///
/// The shape a research agent actually needs: it has ten search hits and wants
/// to know which are worth fetching. One round trip, not ten.
pub async fn unseen(
pool: &sqlx::PgPool,
workspace_id: Uuid,
corpus_id: &str,
candidates: &[String],
) -> Result<Vec<String>, String> {
if candidates.is_empty() {
return Ok(Vec::new());
}
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT source_id FROM corpus_items
WHERE workspace_id = $1 AND corpus_id = $2 AND source_id = ANY($3)",
)
.bind(workspace_id)
.bind(corpus_id)
.bind(candidates)
.fetch_all(pool)
.await
.map_err(|e| format!("unseen: {e}"))?;
let known: std::collections::HashSet<String> = rows.into_iter().map(|r| r.0).collect();
Ok(candidates
.iter()
.filter(|c| !known.contains(*c))
.cloned()
.collect())
}
/// How many NEW sources a mission contributed.
///
/// The verification predicate for a continuous research mission. `record`
/// never reassigns `mission_id` on conflict, so the first mission to find a
/// source keeps the credit and a rerun cannot inflate its own count by
/// re-recording what an earlier run already had.
///
/// A mission whose answer is zero produced nothing, whatever its transcript
/// says — which is the check the 0030-0044 generation of this feature lacked.
pub async fn contributed(
pool: &sqlx::PgPool,
workspace_id: Uuid,
corpus_id: &str,
mission_id: Uuid,
) -> Result<i64, String> {
let row: (i64,) = sqlx::query_as(
"SELECT count(*) FROM corpus_items
WHERE workspace_id = $1 AND corpus_id = $2 AND mission_id = $3
AND kind = 'source'",
)
.bind(workspace_id)
.bind(corpus_id)
.bind(mission_id)
.fetch_one(pool)
.await
.map_err(|e| format!("contributed({mission_id}): {e}"))?;
Ok(row.0)
}
/// Index every note in a checkout. Idempotent by construction.
pub async fn index_vault(
pool: &sqlx::PgPool,
workspace_id: Uuid,
corpus_id: &str,
root: &std::path::Path,
) -> Result<IndexStats, String> {
let notes = collect_notes(root);
let mut stats = IndexStats {
scanned: notes.len(),
..Default::default()
};
for note in &notes {
let existing: Option<(String,)> = sqlx::query_as(
"SELECT content_hash FROM corpus_items
WHERE workspace_id = $1 AND corpus_id = $2 AND source_id = $3",
)
.bind(workspace_id)
.bind(corpus_id)
.bind(note.source_id())
.fetch_optional(pool)
.await
.map_err(|e| format!("lookup {}: {e}", note.path))?;
match existing {
Some((hash,)) if hash == note.content_hash => {
stats.unchanged += 1;
continue;
}
Some(_) => stats.updated += 1,
None => stats.inserted += 1,
}
record(
pool,
workspace_id,
corpus_id,
"note",
&note.source_id(),
note.title.as_deref(),
Some(&note.path),
None,
&note.content_hash,
None,
)
.await?;
// A note that declares an external identity also registers as a
// consumed source, so a later mission does not re-read what an
// earlier one already wrote up.
if let Some(sid) = &note.declared_source_id {
record(
pool,
workspace_id,
corpus_id,
"source",
sid,
note.title.as_deref(),
Some(&note.path),
None,
&note.content_hash,
None,
)
.await?;
}
}
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frontmatter_is_split_from_body() {
let (fm, body) = split_frontmatter("---\ntype: lecture\n---\n# Title\n\ntext\n");
assert_eq!(fm, "type: lecture");
assert!(body.starts_with("# Title"));
}
/// 271 of the vault's 416 notes have no frontmatter. Dropping them would
/// discard two thirds of the corpus.
#[test]
fn a_note_without_frontmatter_is_still_a_note() {
let (fm, body) = split_frontmatter("# Plain\n\nno frontmatter here\n");
assert_eq!(fm, "");
assert!(body.starts_with("# Plain"));
let n = parse_note("Daily/x.md", "# Plain\n\nbody\n");
assert_eq!(n.title.as_deref(), Some("Plain"));
assert_eq!(n.declared_source_id, None);
}
/// An unterminated fence must not swallow the file.
#[test]
fn malformed_frontmatter_is_treated_as_body() {
let (fm, body) = split_frontmatter("---\nbroken: yes\nno closing fence\n");
assert_eq!(fm, "");
assert!(body.contains("no closing fence"));
}
/// The vault's real `source:` values are local filesystem paths of course
/// material. Treating those as citable identity would fill the seen-set
/// with 25 rows keyed on a laptop path.
#[test]
fn a_local_source_path_is_not_an_external_identity() {
let note = parse_note(
"50 APESS 2026/Lectures/talk.md",
"---\nsource: \"/Users/quantum/Downloads/Material_APESS_2026/x.pdf\"\n\
date: 2026-07-27\ntype: lecture\n---\n# Agentic Design\n",
);
assert_eq!(
note.declared_source_id, None,
"a Downloads path is provenance, not a citable source id"
);
assert_eq!(note.title.as_deref(), Some("Agentic Design"));
assert_eq!(note.source_id(), "note:50 APESS 2026/Lectures/talk.md");
}
#[test]
fn declared_identities_are_scheme_qualified() {
let a = parse_note("p.md", "---\narxiv: 2401.12345\n---\n# T\n");
assert_eq!(a.declared_source_id.as_deref(), Some("arxiv:2401.12345"));
let d = parse_note("p.md", "---\ndoi: 10.1000/xyz\n---\n# T\n");
assert_eq!(d.declared_source_id.as_deref(), Some("doi:10.1000/xyz"));
// Already-qualified values are not double-prefixed.
let s = parse_note("p.md", "---\nsource_id: arxiv:2401.99999\n---\n# T\n");
assert_eq!(s.declared_source_id.as_deref(), Some("arxiv:2401.99999"));
// A URL carries its own scheme and must not become `url:https:...`.
let u = parse_note("p.md", "---\nurl: https://example.com/p\n---\n# T\n");
assert_eq!(
u.declared_source_id.as_deref(),
Some("https://example.com/p")
);
}
/// Repo-sync notes rewrite `updated:`/`size_kb:` on every sync without the
/// prose changing. Hashing the whole file would report 103 phantom edits
/// per run and make "unchanged" meaningless.
#[test]
fn frontmatter_churn_does_not_count_as_an_edit() {
let a = parse_note("Repos/x.md", "---\nupdated: 2026-08-01\nsize_kb: 12\n---\n# X\n\nbody\n");
let b = parse_note("Repos/x.md", "---\nupdated: 2026-08-03\nsize_kb: 14\n---\n# X\n\nbody\n");
assert_eq!(a.content_hash, b.content_hash);
let c = parse_note("Repos/x.md", "---\nupdated: 2026-08-03\n---\n# X\n\nDIFFERENT\n");
assert_ne!(a.content_hash, c.content_hash, "real edits must be visible");
}
#[test]
fn note_identity_is_its_path() {
let n = parse_note("30 Resources/a b.md", "# A\n");
assert_eq!(n.source_id(), "note:30 Resources/a b.md");
}
#[test]
fn collect_skips_dotfiles_and_non_markdown() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".obsidian")).unwrap();
std::fs::create_dir_all(root.join("Daily")).unwrap();
std::fs::write(root.join(".obsidian/workspace.md"), "# editor state\n").unwrap();
std::fs::write(root.join("Daily/note.md"), "# Real\n").unwrap();
std::fs::write(root.join("image.png"), "notmd").unwrap();
let notes = collect_notes(root);
assert_eq!(notes.len(), 1, "only the real note: {notes:?}");
assert_eq!(notes[0].path, "Daily/note.md");
}
}
+227
View File
@@ -0,0 +1,227 @@
//! One run of the library: find, skip what we have, shelve the rest.
//!
//! This is the piece that makes the others a *job* rather than parts on a
//! bench. Order matters and it is deliberate:
//!
//! 1. **search** arXiv for candidates
//! 2. **skip** everything already on the checkmark list — before any download
//! 3. **fetch** the PDF for what is left, and verify it really is a PDF
//! 4. **shelve** it in the blob store
//! 5. **catalogue** it: write the vault note
//! 6. **check it off** so next week skips it
//!
//! Step 2 comes before step 3 on purpose. Checking after downloading would
//! still dedupe the catalogue, but it would re-download every paper we already
//! have, every week, forever — and the whole point of the checkmark list is to
//! not do the work twice.
//!
//! # Nothing new is a success, not a failure
//!
//! A weekly run that finds no new papers has worked correctly. A run that
//! *crashed* has not. [`Harvest`] keeps those apart, because collapsing them
//! is precisely the "reported success while doing nothing" shape that this
//! codebase has been bitten by repeatedly. `shelved == 0` with `failed.empty()`
//! is a quiet week; `shelved == 0` with failures is a broken run.
use std::path::Path;
use std::sync::Arc;
use uuid::Uuid;
use crate::corpus;
use crate::papers::{self, Paper};
/// What one run did. Every number here is observed, not claimed.
#[derive(Debug, Default, Clone)]
pub struct Harvest {
/// Papers the search returned.
pub candidates: usize,
/// Of those, how many were already on the checkmark list.
pub already_had: usize,
/// Successfully downloaded, shelved and catalogued.
pub shelved: Vec<String>,
/// `(source_id, why)` for each paper that could not be shelved.
pub failed: Vec<(String, String)>,
/// Vault-relative paths of the notes written.
pub notes_written: Vec<String>,
}
impl Harvest {
/// Did this run add anything? The verification predicate for a continuous
/// research mission: a run that contributes no new source has produced
/// nothing, whatever its transcript says.
pub fn added_anything(&self) -> bool {
!self.shelved.is_empty()
}
/// A run is healthy if nothing errored — including a run that found
/// nothing new, which is the normal state of a mature library.
pub fn healthy(&self) -> bool {
self.failed.is_empty()
}
pub fn summary(&self) -> String {
format!(
"{} candidates, {} already held, {} shelved, {} failed",
self.candidates,
self.already_had,
self.shelved.len(),
self.failed.len()
)
}
}
/// Where a library lives: its records, its shelf, and its catalogue.
///
/// Grouped rather than passed as loose arguments because these five always
/// travel together and always describe one library — splitting them at a call
/// site is how a run ends up shelving into one place and cataloguing into
/// another.
pub struct Library<'a> {
pub pool: &'a sqlx::PgPool,
/// The shelf: where PDFs are stored.
pub blobs: &'a Arc<dyn cm_files::BlobStore>,
pub workspace_id: Uuid,
/// Which checkmark list, e.g. `"valhalla-vault"`.
pub corpus_id: &'a str,
/// Checkout the catalogue notes are written into.
pub vault_root: &'a Path,
}
/// Shelve a specific set of papers. Split from [`run`] so the skip/shelve
/// logic is testable without reaching arXiv.
pub async fn shelve(
lib: &Library<'_>,
candidates: &[Paper],
mission_id: Option<Uuid>,
) -> Result<Harvest, String> {
let Library { pool, blobs, workspace_id, corpus_id, vault_root } = *lib;
let mut out = Harvest {
candidates: candidates.len(),
..Default::default()
};
// One round trip for the whole batch rather than one query per paper.
let ids: Vec<String> = candidates.iter().map(Paper::source_id).collect();
let fresh: std::collections::HashSet<String> =
corpus::unseen(pool, workspace_id, corpus_id, &ids)
.await?
.into_iter()
.collect();
out.already_had = candidates.len() - fresh.len();
for paper in candidates {
let sid = paper.source_id();
if !fresh.contains(&sid) {
continue;
}
// Fetch first. If the PDF cannot be had, nothing is recorded — the
// paper stays unseen so a later run retries it, rather than being
// checked off with an empty shelf slot behind it.
let bytes = match papers::fetch_pdf(paper).await {
Ok(b) => b,
Err(e) => {
out.failed.push((sid, e));
continue;
}
};
let key = paper.blob_key();
if let Err(e) = blobs.put(&key, &bytes).await {
out.failed.push((sid, format!("shelve {key}: {e}")));
continue;
}
// Catalogue note next to the shelf. Written into the vault checkout;
// committing and pushing it is the caller's job, through the delivery
// path that already exists.
let note = papers::catalogue_note(paper, &key);
let note_path = vault_root.join(paper.note_path());
if let Some(parent) = note_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
out.failed.push((sid, format!("create {}: {e}", parent.display())));
continue;
}
}
if let Err(e) = std::fs::write(&note_path, &note) {
out.failed
.push((sid, format!("write {}: {e}", note_path.display())));
continue;
}
// Check it off LAST. If anything above failed we did not get the
// paper, and marking it seen would mean never trying again.
corpus::record(
pool,
workspace_id,
corpus_id,
"source",
&sid,
Some(&paper.title),
Some(&paper.note_path()),
Some(&format!("https://arxiv.org/abs/{}", paper.arxiv_id)),
&corpus::content_hash(&note),
mission_id,
)
.await?;
out.notes_written.push(paper.note_path());
out.shelved.push(sid);
}
Ok(out)
}
/// A full run: search arXiv, then shelve whatever is new.
pub async fn run(
lib: &Library<'_>,
query: &str,
limit: usize,
mission_id: Option<Uuid>,
) -> Result<Harvest, String> {
let candidates = papers::search(query, limit).await?;
let harvest = shelve(lib, &candidates, mission_id).await?;
let corpus_id = lib.corpus_id;
eprintln!("harvest[{corpus_id}] query={query:?} → {}", harvest.summary());
for (sid, why) in &harvest.failed {
eprintln!("harvest[{corpus_id}] FAILED {sid}: {why}");
}
Ok(harvest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_quiet_week_is_healthy_but_adds_nothing() {
let quiet = Harvest {
candidates: 5,
already_had: 5,
..Default::default()
};
assert!(quiet.healthy(), "finding nothing new is not an error");
assert!(
!quiet.added_anything(),
"but it must not count as having produced something"
);
let broken = Harvest {
candidates: 5,
already_had: 0,
failed: vec![("arxiv:1".into(), "timeout".into())],
..Default::default()
};
assert!(!broken.healthy());
assert!(!broken.added_anything());
let good = Harvest {
candidates: 5,
already_had: 4,
shelved: vec!["arxiv:2".into()],
..Default::default()
};
assert!(good.healthy() && good.added_anything());
}
}
+18
View File
@@ -16,8 +16,14 @@ mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator; pub mod mission_orchestrator;
pub mod mission_refiner; pub mod mission_refiner;
pub mod auto_merge;
pub mod corpus;
pub mod harvest;
pub mod library;
pub mod mission_delivery; pub mod mission_delivery;
pub mod papers;
pub mod phase_config; pub mod phase_config;
pub mod session_executor;
pub mod runtime_preflight; pub mod runtime_preflight;
pub mod mission_runtime; pub mod mission_runtime;
pub mod mission_workspace; pub mod mission_workspace;
@@ -64,6 +70,9 @@ pub struct AppState {
pub file_root: Option<std::path::PathBuf>, pub file_root: Option<std::path::PathBuf>,
/// Live control channels to connected fleet-node daemons. /// Live control channels to connected fleet-node daemons.
pub node_hub: std::sync::Arc<fleet::NodeHub>, pub node_hub: std::sync::Arc<fleet::NodeHub>,
/// The shelf. Present once the server wires storage; `None` in the
/// bare-`new` path used by tests that never touch blobs.
pub blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
} }
impl AppState { impl AppState {
@@ -78,6 +87,7 @@ impl AppState {
billing: cm_config::BillingConfig::default(), billing: cm_config::BillingConfig::default(),
file_root: None, file_root: None,
node_hub: std::sync::Arc::new(fleet::NodeHub::new()), node_hub: std::sync::Arc::new(fleet::NodeHub::new()),
blobs: None,
} }
} }
@@ -86,6 +96,12 @@ impl AppState {
self self
} }
/// The shelf — where the paper library stores PDFs.
pub fn with_blobs(mut self, blobs: std::sync::Arc<dyn cm_files::BlobStore>) -> AppState {
self.blobs = Some(blobs);
self
}
pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState { pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState {
self.oauth = oauth; self.oauth = oauth;
self self
@@ -311,6 +327,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/sessions", post(routes::sessions::create)) .route("/api/sessions", post(routes::sessions::create))
.route("/api/sessions/history", get(routes::sessions::history)) .route("/api/sessions/history", get(routes::sessions::history))
.route("/api/gateway", post(routes::gateway::gateway)) .route("/api/gateway", post(routes::gateway::gateway))
.route("/api/library/runs", post(routes::library::run))
.route("/api/library/items", get(routes::library::list))
.route("/api/routines", get(routes::routines::list)) .route("/api/routines", get(routes::routines::list))
.route("/api/routines", post(routes::routines::create)) .route("/api/routines", post(routes::routines::create))
.route("/api/routines/runs", get(routes::routines::runs)) .route("/api/routines/runs", get(routes::routines::runs))
+295
View File
@@ -0,0 +1,295 @@
//! A library run end to end: clone the vault, harvest, push the catalogue.
//!
//! [`harvest`](crate::harvest) writes catalogue notes into a directory. This
//! puts that directory somewhere real: a checkout of the vault repo, with the
//! new notes committed and pushed.
//!
//! # Never `main`
//!
//! The vault is a live Obsidian vault that a human edits and syncs. Pushing
//! straight to `main` races that sync and can lose hand-written work. Every
//! run lands on its own branch, exactly like the mission delivery path that
//! was validated 20/20 earlier — a human merges when they have looked at it.
//!
//! # The PDFs do not go here
//!
//! Only notes are committed. PDFs are shelved in the blob store, because a
//! few hundred papers is gigabytes and a vault that size is painful to clone
//! and slow to open. The note carries the blob key, so the catalogue always
//! knows where its shelf is.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use uuid::Uuid;
use crate::harvest::{self, Harvest, Library};
use crate::mission_workspace;
/// What a full run produced, including whether it reached the forge.
#[derive(Debug, Clone)]
pub struct LibraryRun {
pub harvest: Harvest,
pub branch: String,
/// `true` only when the push was observed to succeed. A run that shelved
/// papers but could not push still has the PDFs and the checkmarks; the
/// notes are simply not on the forge yet.
pub pushed: bool,
/// Whether the branch was auto-merged into `main`.
pub merged: bool,
/// Always populated — a branch that quietly did not merge is
/// indistinguishable from one that was never delivered.
pub merge_reason: String,
pub error: Option<String>,
}
fn git_identity() -> [(&'static str, String); 4] {
let (name, email) = crate::mission_delivery::commit_identity();
[
("GIT_AUTHOR_NAME", name.clone()),
("GIT_AUTHOR_EMAIL", email.clone()),
("GIT_COMMITTER_NAME", name),
("GIT_COMMITTER_EMAIL", email),
]
}
async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
let mut cmd = tokio::process::Command::new("git");
cmd.arg("-C").arg(repo);
cmd.args(["-c", &format!("safe.directory={}", repo.display())]);
cmd.args(args);
for (k, v) in git_identity() {
cmd.env(k, v);
}
let out = cmd.output().await.map_err(|e| format!("spawn git: {e}"))?;
if !out.status.success() {
return Err(format!(
"git {} → {}: {}",
args.first().copied().unwrap_or("?"),
out.status,
mission_workspace::redact_token(&String::from_utf8_lossy(&out.stderr))
.chars()
.take(300)
.collect::<String>()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Clone the vault fresh into `work_root`, returning the checkout path.
///
/// Fresh each run rather than reused: a library run is short, the vault is
/// small (measured 6.9 MB / 416 notes), and a stale checkout is how the
/// mission path lost work three times this week.
pub async fn clone_vault(clone_url: &str, work_root: &Path) -> Result<PathBuf, String> {
let path = work_root.join("vault");
if path.exists() {
tokio::fs::remove_dir_all(&path)
.await
.map_err(|e| format!("clear {}: {e}", path.display()))?;
}
tokio::fs::create_dir_all(work_root)
.await
.map_err(|e| format!("mkdir {}: {e}", work_root.display()))?;
let auth = mission_workspace::with_ambient_auth(clone_url);
let out = tokio::process::Command::new("git")
.args(["clone", "--quiet", "--depth", "1", &auth])
.arg(&path)
.output()
.await
.map_err(|e| format!("spawn git clone: {e}"))?;
if !out.status.success() {
return Err(format!(
"clone vault → {}: {}",
out.status,
mission_workspace::redact_token(&String::from_utf8_lossy(&out.stderr))
.chars()
.take(300)
.collect::<String>()
));
}
// The token must not stay in .git/config: the checkout may be handed to a
// container later, and a credential in a file an agent can read is a
// credential an agent has.
mission_workspace::scrub_remote_credentials(&path, &auth);
Ok(path)
}
/// One complete library run.
#[allow(clippy::too_many_arguments)]
pub async fn run_to_vault(
pool: &sqlx::PgPool,
blobs: &Arc<dyn cm_files::BlobStore>,
workspace_id: Uuid,
corpus_id: &str,
clone_url: &str,
work_root: &Path,
queries: &[String],
per_query: usize,
mission_id: Option<Uuid>,
) -> Result<LibraryRun, String> {
let vault = clone_vault(clone_url, work_root).await?;
let lib = Library {
pool,
blobs,
workspace_id,
corpus_id,
vault_root: &vault,
};
// Accumulate across queries. Topics overlap — "agentic topology" and
// "multi-agent orchestration" return some of the same papers — and the
// checkmark list dedupes across them within a single run as well as
// between runs, because each shelve records before the next query starts.
let mut total = Harvest::default();
for q in queries {
let h = harvest::run(&lib, q, per_query, mission_id).await?;
total.candidates += h.candidates;
total.already_had += h.already_had;
total.shelved.extend(h.shelved);
total.failed.extend(h.failed);
total.notes_written.extend(h.notes_written);
}
// The TAIL of the uuid, not the head. UUIDv7 leads with a 48-bit
// timestamp, so two ids minted in the same millisecond share their first
// 12 hex characters exactly — the branch-name collision that hit mission
// 019fc42b earlier. The tail is the random part.
let branch = format!("clawmates/library-{}", branch_suffix(Uuid::now_v7()));
if total.notes_written.is_empty() {
// A quiet run is a success with nothing to push. Creating an empty
// branch every week would be noise.
return Ok(LibraryRun {
harvest: total,
branch,
pushed: false,
merged: false,
merge_reason: "nothing new to push".into(),
error: None,
});
}
git(&vault, &["checkout", "-B", &branch]).await?;
git(&vault, &["add", "--", "60 Papers"]).await?;
let message = format!(
"library: {} new paper(s)\n\n{}\n\nShelved in the blob store; this commit is the catalogue.",
total.shelved.len(),
total
.shelved
.iter()
.map(|s| format!("- {s}"))
.collect::<Vec<_>>()
.join("\n")
);
git(&vault, &["commit", "--no-verify", "-m", &message]).await?;
let auth = mission_workspace::with_ambient_auth(clone_url);
let refspec = format!("HEAD:refs/heads/{branch}");
match git(&vault, &["push", &auth, &refspec]).await {
Ok(_) => {
// A catalogue branch only ever adds notes under `60 Papers/`, so
// it qualifies for auto-merge — but the check is measured from the
// diff, not assumed from the mission type. Verified here means the
// run shelved something and errored on nothing.
let verified = total.healthy() && !total.shelved.is_empty();
let merge = crate::auto_merge::try_merge(
&vault,
&auth,
&branch,
"main",
crate::auto_merge::MergePolicy::AdditiveOnly,
verified,
)
.await
.unwrap_or_else(|e| crate::auto_merge::MergeOutcome {
merged: false,
reason: format!("merge attempt failed: {e}"),
});
eprintln!("library: branch {branch} — {}", merge.reason);
Ok(LibraryRun {
harvest: total,
branch,
pushed: true,
merged: merge.merged,
merge_reason: merge.reason,
error: None,
})
}
Err(e) => Ok(LibraryRun {
harvest: total,
branch,
pushed: false,
merged: false,
merge_reason: "not pushed, so not merged".into(),
error: Some(e),
}),
}
}
/// Distinct-per-run branch suffix. See the note at the call site: taking the
/// head of a UUIDv7 yields the timestamp, which collides.
fn branch_suffix(id: Uuid) -> String {
let s = id.simple().to_string();
s[s.len() - 12..].to_string()
}
/// The topics this library currently tracks.
///
/// Drawn from what the project is actually working on: `papers/dynamic-
/// agentic-topologies.md` (topology search and evolution, citing ADAS,
/// Darwin-Gödel and SwarmAgentic), plus the problems this week's work ran
/// into — verifying what an agent actually did, and giving a long-running
/// agent memory of what it has already covered.
pub fn default_topics() -> Vec<String> {
[
"all:\"agentic topology\" OR all:\"multi-agent topology\"",
"all:\"multi-agent orchestration\" AND all:LLM",
"all:\"agent memory\" AND all:\"long-term\"",
"all:\"LLM agent\" AND all:verification",
"all:\"prompt injection\" AND all:agent",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topics_are_non_empty_and_arxiv_shaped() {
let topics = default_topics();
assert!(topics.len() >= 3);
for t in &topics {
assert!(t.contains("all:"), "arXiv field prefix missing in {t:?}");
assert!(!t.trim().is_empty());
}
}
/// Two runs in the same millisecond must not collide.
///
/// This caught a real repeat of the mission-path bug (019fc42b): UUIDv7
/// leads with a 48-bit timestamp, so the FIRST 12 hex characters of two
/// ids minted together are identical. Taking the tail fixes it. Looping
/// rather than sampling twice, because a one-shot check passes by luck
/// whenever the millisecond happens to tick between the two calls.
#[test]
fn every_run_gets_a_distinct_branch() {
let ids: Vec<String> = (0..100).map(|_| branch_suffix(Uuid::now_v7())).collect();
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "branch suffixes collided: {ids:?}");
// And the head-based scheme really does collide, so this test has teeth.
let heads: Vec<String> = (0..100)
.map(|_| Uuid::now_v7().simple().to_string()[..12].to_string())
.collect();
let head_unique: std::collections::HashSet<&String> = heads.iter().collect();
assert!(
head_unique.len() < heads.len(),
"the head of a UUIDv7 was expected to collide but did not"
);
}
}
+30 -1
View File
@@ -83,7 +83,7 @@ const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
const DEFAULT_COMMIT_NAME: &str = "Omar Sobh"; const DEFAULT_COMMIT_NAME: &str = "Omar Sobh";
const DEFAULT_COMMIT_EMAIL: &str = "[email protected]"; const DEFAULT_COMMIT_EMAIL: &str = "[email protected]";
fn commit_identity() -> (String, String) { pub(crate) fn commit_identity() -> (String, String) {
let name = std::env::var("CLAWMATES_COMMIT_NAME") let name = std::env::var("CLAWMATES_COMMIT_NAME")
.ok() .ok()
.filter(|v| !v.trim().is_empty()) .filter(|v| !v.trim().is_empty())
@@ -583,6 +583,7 @@ pub async fn commit_phase_work(
String::new() String::new()
} }
); );
clear_stale_commit_editmsg(repo);
git(repo, &["commit", "--no-verify", "-m", &message]).await?; git(repo, &["commit", "--no-verify", "-m", &message]).await?;
} }
@@ -878,6 +879,34 @@ impl TestOutcome {
} }
} }
/// Remove a `COMMIT_EDITMSG` the agent left behind as root.
///
/// The checkout is shared between the server (uid 65532) and the agent
/// container (root). `core.sharedRepository` makes git create *objects and
/// refs* group-writable — `.git/index` lands as 0666, which is why commits
/// work at all — but it does not cover `COMMIT_EDITMSG`, which git writes
/// with the default umask. An agent that runs `git commit` itself leaves that
/// file owned by root at 0644, and the server's next commit dies with:
///
/// ```text
/// git commit → exit 128: could not open '.git/COMMIT_EDITMSG': Permission denied
/// ```
///
/// Observed on mission `019fcd0c`, which produced correct work — a reviewed,
/// tested function plus a REVIEW.md quoting a real `cargo test` summary — and
/// then delivered none of it.
///
/// Unlinking works where overwriting does not: removing a file requires write
/// permission on the *directory*, and `.git/` is owned by the server. Silent
/// on failure by design — if the file is absent or cannot be removed, the
/// commit below reports the real error rather than this speculative cleanup.
fn clear_stale_commit_editmsg(repo: &Path) {
let msg = repo.join(".git/COMMIT_EDITMSG");
if msg.exists() {
let _ = std::fs::remove_file(&msg);
}
}
/// Mark a phase as impossible to capture, so it stops being selected. /// Mark a phase as impossible to capture, so it stops being selected.
/// ///
/// A phase whose checkout has already been reaped can never be captured. It /// A phase whose checkout has already been reaped can never be captured. It
+278 -10
View File
@@ -83,10 +83,28 @@ impl RuntimeAuth {
/// ///
/// The other three are unrelated providers with no subscription equivalent, so /// The other three are unrelated providers with no subscription equivalent, so
/// they forward in both modes. /// they forward in both modes.
///
/// In subscription mode `CLAUDE_CODE_OAUTH_TOKEN` forwards instead. The
/// original design assumed a persisted `claude /login` under a bind-mounted
/// `$HOME`, but a *mission* container gets its own data dir and therefore no
/// login — so the token has to travel. Missing it is not a loud failure:
/// `claude -p` simply hangs with no credential, which is what a phase stuck
/// at `running` for ten minutes looked like when this was first switched on.
pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> { pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> {
let mut keys = vec!["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"]; // ZAI/KIMI reach their backends through the SAME `claude` binary via
if auth == RuntimeAuth::ApiKey { // ANTHROPIC_BASE_URL, so a mission that selects one needs its key present
keys.push("ANTHROPIC_API_KEY"); // in the container. They are unrelated to the Anthropic credential and
// forward in both auth modes.
let mut keys = vec![
"GEMINI_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
"ZAI_API_KEY",
"KIMI_API_KEY",
];
match auth {
RuntimeAuth::ApiKey => keys.push("ANTHROPIC_API_KEY"),
RuntimeAuth::Subscription => keys.push("CLAUDE_CODE_OAUTH_TOKEN"),
} }
keys keys
} }
@@ -142,6 +160,146 @@ const MISSIONS_HOST_ROOT: &str = "/var/lib/clawmates-missions";
/// mission so this rarely bites. Long-term: copy-on-write per mission. /// mission so this rarely bites. Long-term: copy-on-write per mission.
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data"; const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
/// What a mission gets its own copy of.
///
/// An allow-list, not the whole directory. The seed dir is **1.7 GB** on
/// gw-04 and 1.5 GB of that is a vestigial `.rustup` — a Rust toolchain that
/// installed itself into the data dir back when `HOME=/zeroclaw-data` and the
/// image had no toolchain. The image now ships Rust at `/usr/local/cargo`,
/// which is what the container's PATH actually resolves (verified live), so
/// that copy is dead weight. Copying it per mission would cost tens of
/// seconds and ~17 GB across ten concurrent missions.
///
/// So: copy what carries per-mission identity or secrets, and leave the
/// caches and toolchains behind.
const SEEDED_PATHS: &[&str] = &[
// The whole point: config.toml carries the §15 door bearer token, and
// data/ holds sessions.db + devices.db. ~26 MB.
".zeroclaw",
// Door MCP config — also a bearer token.
"clawmates-mcp.json",
// Claude Code's own state and credentials (~16 MB). Per-mission so a
// token refresh or project state in one mission cannot leak into another.
".claude",
".claude.json",
// Per-CLI state for the alternate backends; small.
".kimi-code",
"glm-home",
// The seeded agent library.
"agents",
];
/// Deliberately NOT copied — caches and toolchains, no secrets, expensive:
/// `.rustup` (1.5 GB, vestigial), `.npm` (85 MB), `.cargo`, `.cache`,
/// `.local`. A mission that needs them reads the image's copies.
fn copy_script() -> String {
let mut out = String::from("set -e\n");
for p in SEEDED_PATHS {
// Missing entries are normal — a fresh deployment has no .kimi-code
// until Kimi is first used — so absence must not fail the copy.
out.push_str(&format!(
"if [ -e '/seed/{p}' ]; then cp -a '/seed/{p}' /dst/; fi\n"
));
}
out
}
/// Give this mission its own copy of the runtime seed data.
///
/// Every per-mission container used to bind-mount the SAME host seed dir as
/// `/zeroclaw-data` — shared with each other and with the singleton runtime.
/// That directory holds `config.toml`, which carries the §15 door bearer
/// token, plus `sessions.db` and `devices.db`. So a mission could read another
/// mission's credential, and anything it wrote there was inherited by every
/// later mission. Teardown never cleaned it, because teardown only removes
/// `/var/lib/clawmates-missions/{id}`.
///
/// The code already knew: the comment on `DEFAULT_SEED_DIR` names the sqlite
/// race and calls copy-on-write per mission the long-term fix. This is that.
///
/// The copy runs in a throwaway container because cm-api cannot see the seed
/// dir — it hands that host path to Docker but never mounts it itself. The
/// runtime image is reused so nothing extra is pulled.
///
/// Failure is fatal to container creation on purpose. Falling back to the
/// shared mount would silently restore the credential-sharing this removes,
/// and a silent fallback to a weaker posture is the failure mode this
/// codebase keeps paying for.
async fn seed_runtime_data(
docker: &Docker,
image: &str,
seed_dir: &str,
dest_dir: &str,
) -> Result<(), String> {
let name = format!("cm-seed-{}", Uuid::now_v7().simple());
let config = ContainerCreateBody {
image: Some(image.to_string()),
entrypoint: Some(vec!["/bin/sh".to_string()]),
cmd: Some(vec!["-c".to_string(), copy_script()]),
host_config: Some(HostConfig {
mounts: Some(vec![
Mount {
target: Some("/seed".to_string()),
source: Some(seed_dir.to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
},
Mount {
target: Some("/dst".to_string()),
source: Some(dest_dir.to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
]),
auto_remove: Some(true),
..Default::default()
}),
..Default::default()
};
docker
.create_container(
Some(CreateContainerOptions {
name: Some(name.clone()),
..Default::default()
}),
config,
)
.await
.map_err(|e| format!("create seed copier: {e}"))?;
docker
.start_container(&name, None::<StartContainerOptions>)
.await
.map_err(|e| format!("start seed copier: {e}"))?;
// `auto_remove` means the container disappears the moment it exits, so
// poll for absence rather than waiting on it.
for _ in 0..120 {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
match docker
.inspect_container(&name, None::<InspectContainerOptions>)
.await
{
Err(_) => return Ok(()),
Ok(info) => {
let running = info
.state
.as_ref()
.and_then(|st| st.running)
.unwrap_or(false);
if !running {
return Ok(());
}
}
}
}
Err(format!("seed copy into {dest_dir} did not finish in 30s"))
}
/// Deterministic docker container name for a mission's runtime. /// Deterministic docker container name for a mission's runtime.
/// Uses the full UUID hex — UUIDv7 encodes time in the leading bytes, /// Uses the full UUID hex — UUIDv7 encodes time in the leading bytes,
/// so a short prefix isn't guaranteed unique across missions minted /// so a short prefix isn't guaranteed unique across missions minted
@@ -239,6 +397,12 @@ impl MissionRuntimeProvisioner {
let _ = tokio::fs::create_dir_all(&mission_dir).await; let _ = tokio::fs::create_dir_all(&mission_dir).await;
let seed_dir = std::env::var("CLAWMATES_RUNTIME_SEED_DIR") let seed_dir = std::env::var("CLAWMATES_RUNTIME_SEED_DIR")
.unwrap_or_else(|_| DEFAULT_SEED_DIR.to_string()); .unwrap_or_else(|_| DEFAULT_SEED_DIR.to_string());
// Per-mission copy of the seed data. See `seed_runtime_data`: sharing
// one directory meant sharing the door token and letting any mission
// poison every later one.
let runtime_data_dir = format!("{mission_dir}/runtime-data");
let _ = tokio::fs::create_dir_all(&runtime_data_dir).await;
seed_runtime_data(&self.docker, &self.image, &seed_dir, &runtime_data_dir).await?;
let mounts = vec![ let mounts = vec![
// Mount just this mission's directory. Agents can navigate // Mount just this mission's directory. Agents can navigate
// its `/repo` subdir but never see other missions'. // its `/repo` subdir but never see other missions'.
@@ -249,14 +413,14 @@ impl MissionRuntimeProvisioner {
read_only: Some(false), read_only: Some(false),
..Default::default() ..Default::default()
}, },
// Share the shared-runtime data dir so this gateway inherits // This mission's OWN copy of the seeded agent library
// the seeded agent library (`claw_*` templates). We then // (`claw_*` templates). Copied rather than shared, so its
// mint a per-mission pairing code via /admin/paircode/new // config.toml — which carries the door bearer token — and its
// below — the mint writes into the shared devices.db but // sqlite files belong to this mission alone and are removed with
// the resulting token is unique to this mission. // it by `teardown_container`.
Mount { Mount {
target: Some("/zeroclaw-data".to_string()), target: Some("/zeroclaw-data".to_string()),
source: Some(seed_dir), source: Some(runtime_data_dir),
typ: Some(MountTypeEnum::BIND), typ: Some(MountTypeEnum::BIND),
read_only: Some(false), read_only: Some(false),
..Default::default() ..Default::default()
@@ -802,9 +966,38 @@ mod tests {
it silently bills the API. Forwarded: {keys:?}" it silently bills the API. Forwarded: {keys:?}"
); );
// Unrelated providers have no subscription equivalent and must survive. // Unrelated providers have no subscription equivalent and must survive.
for k in ["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] { for k in [
"GEMINI_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
"ZAI_API_KEY",
"KIMI_API_KEY",
] {
assert!(keys.contains(&k), "{k} should still be forwarded"); assert!(keys.contains(&k), "{k} should still be forwarded");
} }
// And the subscription credential MUST travel. A mission container
// has its own data dir, so unlike the shared runtime it has no
// persisted `claude /login` to fall back on. Without this the CLI
// has no credential and simply hangs — a phase stuck at `running`
// with nothing in the logs, which is exactly how this was found.
assert!(
keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN"),
"subscription mode must forward the token; without it `claude -p` \
hangs with no credential. Forwarded: {keys:?}"
);
}
/// The two credentials must never travel together: Claude Code would pick
/// the API key and bill it while the deployment believes it is on the
/// subscription.
#[test]
fn the_two_anthropic_credentials_are_mutually_exclusive() {
for mode in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
let keys = forwarded_provider_keys(mode);
let both = keys.contains(&"ANTHROPIC_API_KEY")
&& keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
assert!(!both, "{mode:?} forwards both credentials: {keys:?}");
}
} }
/// Default behaviour is unchanged, so a deployment that never opts in keeps /// Default behaviour is unchanged, so a deployment that never opts in keeps
@@ -820,6 +1013,10 @@ mod tests {
] { ] {
assert!(keys.contains(&k), "{k} should be forwarded in api_key mode"); assert!(keys.contains(&k), "{k} should be forwarded in api_key mode");
} }
assert!(
!keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN"),
"api_key mode must not also ship the subscription token"
);
} }
/// An unset or misspelled value must fall back to the *existing* behaviour. /// An unset or misspelled value must fall back to the *existing* behaviour.
@@ -939,4 +1136,75 @@ allowed_tools = ["file_read", "file_edit"]
let url = endpoint_url("cm-runtime-mission-abc"); let url = endpoint_url("cm-runtime-mission-abc");
assert_eq!(url, "http://cm-runtime-mission-abc:42617"); assert_eq!(url, "http://cm-runtime-mission-abc:42617");
} }
/// The seed data path must be per-mission, not the shared seed dir.
///
/// Every mission container used to bind the SAME host directory as
/// `/zeroclaw-data`. It holds `config.toml`, which carries the §15 door
/// bearer token, plus `sessions.db`/`devices.db`. Sharing it meant one
/// mission could read another's credential, and anything written there
/// was inherited by every later mission — `teardown_container` only
/// removes `/var/lib/clawmates-missions/{id}`, so the shared dir was
/// never cleaned.
///
/// Asserting the path shape is what keeps this from silently regressing:
/// a future edit that points the mount back at the seed dir restores the
/// credential sharing with no other visible symptom.
#[test]
fn runtime_data_is_scoped_to_one_mission() {
let a = Uuid::now_v7();
let b = Uuid::now_v7();
let path = |id: Uuid| format!("{MISSIONS_HOST_ROOT}/{id}/runtime-data");
assert_ne!(path(a), path(b), "two missions must not share runtime data");
assert!(
path(a).starts_with(&format!("{MISSIONS_HOST_ROOT}/{a}")),
"runtime data must live under the mission dir so teardown removes it"
);
assert_ne!(
path(a),
DEFAULT_SEED_DIR,
"the mount must never be the shared seed dir itself"
);
assert!(
!path(a).starts_with(DEFAULT_SEED_DIR),
"runtime data must not live inside the shared seed dir either"
);
}
/// The copy must be an allow-list, and must include the secret-bearing
/// paths while excluding the expensive ones.
///
/// Measured on gw-04: the seed dir is 1.7 GB, of which 1.5 GB is a
/// vestigial `.rustup` that is not even on the container's PATH (the
/// image ships Rust at /usr/local/cargo). Copying everything per mission
/// would cost tens of seconds and ~17 GB across ten concurrent missions —
/// which is what the first version of this did.
#[test]
fn the_seed_copy_takes_secrets_and_skips_caches() {
// The two paths that carry the door bearer token MUST be copied, or
// this whole change accomplishes nothing.
assert!(SEEDED_PATHS.contains(&".zeroclaw"));
assert!(SEEDED_PATHS.contains(&"clawmates-mcp.json"));
// Claude Code's credentials and state.
assert!(SEEDED_PATHS.contains(&".claude"));
// The expensive, secret-free ones must NOT be.
for cache in [".rustup", ".npm", ".cargo", ".cache"] {
assert!(
!SEEDED_PATHS.contains(&cache),
"{cache} is a cache and must not be copied per mission"
);
}
let script = copy_script();
// A missing entry is normal on a fresh deployment (no .kimi-code
// until Kimi is first used) and must not fail the copy.
assert!(script.contains("if [ -e "), "absent paths must be tolerated");
assert!(script.contains("/seed/.zeroclaw"));
assert!(!script.contains("/seed/.rustup"));
for p in SEEDED_PATHS {
assert!(script.contains(p), "{p} missing from the copy script");
}
}
} }
+2 -2
View File
@@ -391,7 +391,7 @@ pub(crate) fn base_commit(path: &std::path::Path) -> Option<String> {
/// Best-effort and non-fatal: a checkout that keeps its token still works, and /// Best-effort and non-fatal: a checkout that keeps its token still works, and
/// failing the mission over it would trade a real capability for a marginal /// failing the mission over it would trade a real capability for a marginal
/// improvement in a situation we have already logged. /// improvement in a situation we have already logged.
fn scrub_remote_credentials(path: &std::path::Path, original_url: &str) { pub(crate) fn scrub_remote_credentials(path: &std::path::Path, original_url: &str) {
if !original_url.contains('@') && !original_url.contains("oauth2:") { if !original_url.contains('@') && !original_url.contains("oauth2:") {
// Nothing was injected (SSH remote, or no token configured). // Nothing was injected (SSH remote, or no token configured).
return; return;
@@ -495,7 +495,7 @@ fn ignore_agent_scaffolding(path: &std::path::Path) {
} }
} }
fn redact_token(s: &str) -> String { pub(crate) fn redact_token(s: &str) -> String {
// Strip any "oauth2:<token>@" segment that git may echo back on // Strip any "oauth2:<token>@" segment that git may echo back on
// failures. Belt-and-braces: also nuke any raw token env value. // failures. Belt-and-braces: also nuke any raw token env value.
let mut out = s.to_string(); let mut out = s.to_string();
+345
View File
@@ -0,0 +1,345 @@
//! Finding papers, shelving them, and cataloguing them.
//!
//! The library has three parts and it matters which is which:
//!
//! - **arXiv** is where papers are *found*.
//! - **The blob store** is the *shelf* — the PDF itself lives there.
//! - **The vault** is the *card catalogue* — a markdown note per paper, with
//! the metadata and a pointer to the shelf.
//!
//! Plus [`crate::corpus`], which is the list of checkmarks: it is what stops
//! the same paper being fetched twice across weekly runs. That list is the
//! reason this can be a *continuous* job rather than one that redoes itself
//! forever — the failure that killed the previous attempt at this (migrations
//! 0030-0044, dropped in 0053).
//!
//! # The contract that ties it together
//!
//! Every note this module writes carries `source_id: arxiv:NNNN.NNNNN` in its
//! frontmatter. `corpus::parse_note` reads exactly that key, so re-indexing
//! the vault re-derives the checkmark list from the notes themselves. The
//! catalogue is authoritative; the index is rebuildable from it. If the
//! database were lost, a re-index of the vault would restore what we have.
use serde::{Deserialize, Serialize};
/// One paper as arXiv describes it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Paper {
/// Bare arXiv id, e.g. `2401.12345` — no version suffix.
pub arxiv_id: String,
pub title: String,
pub authors: Vec<String>,
pub summary: String,
pub published: String,
pub pdf_url: String,
}
impl Paper {
/// The checkmark key. Version suffixes are stripped upstream so `v1` and
/// `v2` of the same paper are one entry, not two.
pub fn source_id(&self) -> String {
format!("arxiv:{}", self.arxiv_id)
}
/// Where the PDF is shelved in the blob store.
pub fn blob_key(&self) -> String {
format!("papers/arxiv/{}.pdf", self.arxiv_id)
}
/// Where the catalogue note goes in the vault.
///
/// Under a dedicated folder so the library never collides with the
/// hand-written parts of the vault (`30 Resources`, `40 Projects`, and so
/// on). A human should always be able to tell which notes a machine wrote.
pub fn note_path(&self) -> String {
format!("60 Papers/arxiv-{}.md", self.arxiv_id)
}
}
/// Strip an arXiv version suffix: `2401.12345v3` -> `2401.12345`.
///
/// Without this a weekly job re-downloads a paper every time the authors post
/// a revision, and the checkmark list quietly fills with near-duplicates.
pub fn normalize_arxiv_id(raw: &str) -> String {
let id = raw.rsplit('/').next().unwrap_or(raw);
match id.find('v') {
// Only a trailing `vN` counts; the `v` in a word must not truncate.
Some(i) if id[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < id.len() => {
id[..i].to_string()
}
_ => id.to_string(),
}
}
/// Parse arXiv's Atom feed.
///
/// Hand-rolled rather than pulling an XML crate: the feed is a fixed, simple
/// shape and this reads five fields from it. If arXiv's format ever drifts,
/// `entries_are_parsed_from_a_real_feed` fails loudly rather than silently
/// returning zero papers — which is the failure mode that matters, because a
/// search returning nothing looks exactly like "no new papers this week".
pub fn parse_atom(xml: &str) -> Vec<Paper> {
let mut out = Vec::new();
for chunk in xml.split("<entry>").skip(1) {
let entry = chunk.split("</entry>").next().unwrap_or(chunk);
let field = |tag: &str| -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = entry.find(&open)? + open.len();
let end = entry[start..].find(&close)? + start;
Some(unescape(entry[start..end].trim()))
};
let Some(raw_id) = field("id") else { continue };
let arxiv_id = normalize_arxiv_id(&raw_id);
if arxiv_id.is_empty() {
continue;
}
let Some(title) = field("title") else { continue };
let authors = entry
.split("<author>")
.skip(1)
.filter_map(|a| {
let start = a.find("<name>")? + 6;
let end = a[start..].find("</name>")? + start;
Some(unescape(a[start..end].trim()))
})
.collect();
// The PDF link is an attribute, not an element.
let pdf_url = entry
.split("<link")
.find(|l| l.contains("title=\"pdf\""))
.and_then(|l| {
let start = l.find("href=\"")? + 6;
let end = l[start..].find('"')? + start;
Some(l[start..end].to_string())
})
.unwrap_or_else(|| format!("https://arxiv.org/pdf/{arxiv_id}"));
out.push(Paper {
title: title.split_whitespace().collect::<Vec<_>>().join(" "),
summary: field("summary")
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.join(" "),
published: field("published").unwrap_or_default(),
authors,
pdf_url,
arxiv_id,
});
}
out
}
fn unescape(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
}
/// Search arXiv. `max_results` is capped to keep one run bounded.
pub async fn search(query: &str, max_results: usize) -> Result<Vec<Paper>, String> {
let max = max_results.clamp(1, 50);
let url = format!(
"https://export.arxiv.org/api/query?search_query={}&start=0&max_results={max}\
&sortBy=submittedDate&sortOrder=descending",
urlencoding(query)
);
let body = reqwest::Client::new()
.get(&url)
.header("User-Agent", "clawmates-papers/0.1 (research library)")
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("arxiv query: {e}"))?
.text()
.await
.map_err(|e| format!("arxiv body: {e}"))?;
Ok(parse_atom(&body))
}
/// Download the PDF. Returns the bytes; the caller decides where to shelve it.
pub async fn fetch_pdf(paper: &Paper) -> Result<Vec<u8>, String> {
let bytes = reqwest::Client::new()
.get(&paper.pdf_url)
.header("User-Agent", "clawmates-papers/0.1 (research library)")
.timeout(std::time::Duration::from_secs(180))
.send()
.await
.map_err(|e| format!("fetch pdf {}: {e}", paper.arxiv_id))?
.bytes()
.await
.map_err(|e| format!("read pdf {}: {e}", paper.arxiv_id))?;
// A PDF starts with `%PDF`. arXiv serves an HTML holding page when a PDF
// is still rendering, and shelving that would leave a file that looks
// present and is unreadable.
if !bytes.starts_with(b"%PDF") {
return Err(format!(
"{} did not return a PDF ({} bytes, starts {:?})",
paper.pdf_url,
bytes.len(),
String::from_utf8_lossy(&bytes[..bytes.len().min(16)])
));
}
Ok(bytes.to_vec())
}
/// The catalogue note for a shelved paper.
///
/// `source_id` in the frontmatter is the load-bearing part — it is what
/// `corpus::parse_note` reads to rebuild the checkmark list from the vault.
pub fn catalogue_note(paper: &Paper, blob_key: &str) -> String {
let authors = if paper.authors.is_empty() {
"unknown".to_string()
} else {
paper.authors.join(", ")
};
format!(
"---\n\
source_id: arxiv:{id}\n\
arxiv: {id}\n\
title: \"{title}\"\n\
authors: \"{authors}\"\n\
published: {published}\n\
pdf: {blob_key}\n\
url: https://arxiv.org/abs/{id}\n\
added: {added}\n\
tags: [paper, arxiv]\n\
---\n\
\n\
# {title}\n\
\n\
**Authors:** {authors} \n\
**arXiv:** [{id}](https://arxiv.org/abs/{id}) \n\
**PDF:** `{blob_key}`\n\
\n\
## Abstract\n\
\n\
{summary}\n\
\n\
## Notes\n\
\n\
_Catalogued automatically. Add your own notes below._\n",
id = paper.arxiv_id,
title = paper.title.replace('"', "'"),
authors = authors,
published = paper.published,
blob_key = blob_key,
added = paper.published,
summary = paper.summary,
)
}
fn urlencoding(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
b' ' => "+".to_string(),
_ => format!("%{b:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// A revision must not read as a new paper.
#[test]
fn version_suffixes_are_stripped() {
assert_eq!(normalize_arxiv_id("http://arxiv.org/abs/2401.12345v3"), "2401.12345");
assert_eq!(normalize_arxiv_id("2401.12345v1"), "2401.12345");
assert_eq!(normalize_arxiv_id("2401.12345"), "2401.12345");
// Old-style ids contain letters and a slash.
assert_eq!(normalize_arxiv_id("http://arxiv.org/abs/cs/0701001"), "0701001");
// A trailing `v` with no digits is part of the id, not a version.
assert_eq!(normalize_arxiv_id("2401.1234v"), "2401.1234v");
}
/// Parsed against the real shape of arXiv's Atom feed. If this fails the
/// format drifted — which otherwise shows up as "no new papers", which is
/// indistinguishable from a quiet week.
#[test]
fn entries_are_parsed_from_a_real_feed() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>http://arxiv.org/abs/2401.12345v2</id>
<published>2026-01-15T10:00:00Z</published>
<title>Attention Is All You Need Again</title>
<summary> We show that
attention still works. </summary>
<author><name>Ada Lovelace</name></author>
<author><name>Alan Turing</name></author>
<link href="http://arxiv.org/abs/2401.12345v2" rel="alternate" type="text/html"/>
<link title="pdf" href="http://arxiv.org/pdf/2401.12345v2" rel="related" type="application/pdf"/>
</entry>
</feed>"#;
let papers = parse_atom(xml);
assert_eq!(papers.len(), 1);
let p = &papers[0];
assert_eq!(p.arxiv_id, "2401.12345", "version stripped");
assert_eq!(p.title, "Attention Is All You Need Again", "whitespace collapsed");
assert_eq!(p.summary, "We show that attention still works.");
assert_eq!(p.authors, vec!["Ada Lovelace", "Alan Turing"]);
assert_eq!(p.pdf_url, "http://arxiv.org/pdf/2401.12345v2");
assert_eq!(p.source_id(), "arxiv:2401.12345");
assert_eq!(p.blob_key(), "papers/arxiv/2401.12345.pdf");
assert_eq!(p.note_path(), "60 Papers/arxiv-2401.12345.md");
}
#[test]
fn an_empty_feed_yields_no_papers_rather_than_panicking() {
assert!(parse_atom("<feed></feed>").is_empty());
assert!(parse_atom("").is_empty());
}
#[test]
fn xml_entities_are_unescaped() {
let xml = r#"<feed><entry><id>http://arxiv.org/abs/1v1</id>
<title>Cats &amp; Dogs &lt;3</title><summary>a &quot;quote&quot;</summary>
</entry></feed>"#;
let p = &parse_atom(xml)[0];
assert_eq!(p.title, "Cats & Dogs <3");
assert_eq!(p.summary, "a \"quote\"");
}
/// The note must carry the identity `corpus::parse_note` reads, or the
/// catalogue cannot rebuild the checkmark list and the library forgets
/// itself the moment the database is lost.
#[test]
fn a_catalogue_note_round_trips_through_the_corpus_parser() {
let paper = Paper {
arxiv_id: "2401.12345".into(),
title: "A \"Quoted\" Title".into(),
authors: vec!["Ada Lovelace".into()],
summary: "Summary text.".into(),
published: "2026-01-15T10:00:00Z".into(),
pdf_url: "http://arxiv.org/pdf/2401.12345".into(),
};
let note = catalogue_note(&paper, &paper.blob_key());
let parsed = crate::corpus::parse_note(&paper.note_path(), &note);
assert_eq!(
parsed.declared_source_id.as_deref(),
Some("arxiv:2401.12345"),
"the corpus parser must recover the identity from the note"
);
assert_eq!(parsed.title.as_deref(), Some("A 'Quoted' Title"));
assert!(note.contains("papers/arxiv/2401.12345.pdf"), "note points at the shelf");
}
#[test]
fn queries_are_url_encoded() {
assert_eq!(urlencoding("all:agent topologies"), "all%3Aagent+topologies");
}
}
+108
View File
@@ -346,6 +346,26 @@ async fn launch_phase(pool: &PgPool, p: PhaseLaunch<'_>) -> Result<(), String> {
_ => task, _ => task,
}; };
// Direct-session executor: run the whole phase as ONE `claude -p` session
// against the mission checkout, instead of driving turns through ZeroClaw.
//
// Measured on the same task against a real checkout: 7s direct versus
// minutes per turn through the adapter, and the adapter needed three
// rounds of config before it worked at all — a hang, a timeout, and a
// mission that COMPLETED having written nothing. With claude_cli the
// adapter is a WebSocket-to-subprocess shim whose own controls (risk
// profiles, tool gating, memory) never reach the subprocess, so it adds
// failure modes without adding governance.
//
// It still creates one `topology_runs` row. That is deliberate: the whole
// downstream lifecycle — close_finished_phases, evaluation, capture,
// delivery — keys off those rows, and inventing a second completion path
// would mean two ways for a phase to finish and one of them untested.
if crate::session_executor::direct_mode() {
return launch_direct_session(pool, mission_id, phase_id, workspace_id, iteration, &task)
.await;
}
// Purge prior failed / cancelled runs for this phase so the card // Purge prior failed / cancelled runs for this phase so the card
// starts fresh on re-attempts. Completed runs are kept for // starts fresh on re-attempts. Completed runs are kept for
// auditability (a mission that succeeded once and got re-run // auditability (a mission that succeeded once and got re-run
@@ -408,6 +428,94 @@ async fn launch_phase(pool: &PgPool, p: PhaseLaunch<'_>) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Launch a phase as a single headless session.
///
/// Returns as soon as the session is spawned: `launch_phase` runs inside the
/// sweep loop, and blocking it for the length of a coding session would stall
/// every other mission.
async fn launch_direct_session(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
workspace_id: Uuid,
iteration: i32,
task: &str,
) -> Result<(), String> {
sqlx::query(
"DELETE FROM topology_runs
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
)
.bind(phase_id)
.execute(pool)
.await
.map_err(|e| format!("purge prior runs for phase {phase_id}: {e}"))?;
let run_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO topology_runs
(id, workspace_id, task, kind, status, graph, tier,
mission_id, mission_phase_id, iteration)
VALUES ($1, $2, $3, 'run', 'running', $4, 'session', $5, $6, $7)",
)
.bind(run_id)
.bind(workspace_id)
.bind(task)
.bind(serde_json::json!({ "nodes": [], "edges": [], "executor": "session" }))
.bind(mission_id)
.bind(phase_id)
.bind(iteration)
.execute(pool)
.await
.map_err(|e| format!("enqueue session run for phase {phase_id}: {e}"))?;
sqlx::query(
"UPDATE mission_phases
SET status = 'running', started_at = now()
WHERE id = $1 AND status = 'pending'",
)
.bind(phase_id)
.execute(pool)
.await
.map_err(|e| format!("mark phase {phase_id} running: {e}"))?;
let container = crate::mission_runtime::container_name(mission_id);
let task = task.to_string();
let pool = pool.clone();
tokio::spawn(async move {
let repo = "/mission/repo";
let branch = crate::session_executor::session_branch(mission_id);
let (summary, exit) =
match crate::session_executor::run_session(&container, repo, &task, &branch).await {
Ok(v) => v,
Err(e) => (format!("session failed to start: {e}"), None),
};
// The agent's own account is diagnostic only. Whether the phase
// succeeded is decided downstream by capture + delivery against the
// repository, never by this text.
let ok = exit == Some(0);
eprintln!(
"phase_runner: session for mission {mission_id} phase {phase_id} exited {exit:?} — {}",
summary.chars().take(200).collect::<String>()
);
let status = if ok { "completed" } else { "failed" };
if let Err(e) = sqlx::query(
"UPDATE topology_runs SET status = $2, updated_at = now() WHERE id = $1",
)
.bind(run_id)
.bind(status)
.execute(&pool)
.await
{
eprintln!("phase_runner: could not close session run {run_id}: {e}");
}
});
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION"
);
Ok(())
}
fn phase_task_text( fn phase_task_text(
kind: &str, kind: &str,
title: &str, title: &str,
+162
View File
@@ -0,0 +1,162 @@
//! The paper library: trigger a run, see what it holds.
//!
//! Thin on purpose. The work lives in [`crate::library`]; this exposes it so
//! a run can be started by a person, a schedule, or the UI rather than only
//! from an integration test.
use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{ApiError, AppState, Authed};
/// Default corpus + repo. Single-operator deployment, so these are constants
/// rather than another table to keep in sync; a second library becomes a
/// request field the day one exists.
const DEFAULT_CORPUS: &str = "valhalla-vault";
const DEFAULT_VAULT_URL: &str = "https://git.redclaw.dev/redclaw/valhalla-vault.git";
#[derive(Deserialize)]
pub struct RunRequest {
/// arXiv queries. Omitted → the topics this project is actually working on.
#[serde(default)]
pub topics: Option<Vec<String>>,
/// Papers per topic. Clamped, because a broad first run against an empty
/// library can otherwise pull hundreds of PDFs in one go.
#[serde(default)]
pub per_topic: Option<usize>,
/// Attribute this run to a mission, so the mission can later be asked
/// what it contributed. `corpus_items.mission_id` has existed since the
/// table landed; without this field nothing could ever populate it.
#[serde(default, rename = "missionId")]
pub mission_id: Option<uuid::Uuid>,
}
#[derive(Serialize)]
pub struct RunResponse {
pub candidates: usize,
pub already_had: usize,
pub shelved: Vec<String>,
pub failed: Vec<Value>,
pub notes: Vec<String>,
pub branch: String,
pub pushed: bool,
pub merged: bool,
pub merge_reason: String,
pub error: Option<String>,
/// A run that errored on nothing. Reported explicitly so a caller does not
/// have to infer health from an empty `shelved` list — a quiet week and a
/// broken run both shelve zero papers.
pub healthy: bool,
}
/// POST /api/library/runs — harvest now.
pub async fn run(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<RunRequest>,
) -> Result<Json<RunResponse>, ApiError> {
let blobs = state
.blobs
.clone()
.ok_or_else(|| {
eprintln!("library: blob storage is not configured; cannot shelve PDFs");
ApiError::Internal
})?;
let topics = req
.topics
.filter(|t| !t.is_empty())
.unwrap_or_else(crate::library::default_topics);
let per_topic = req.per_topic.unwrap_or(5).clamp(1, 25);
// Work under the missions root: it is already a writable volume with room
// for checkouts, and it is swept, so a crashed run cannot leak a vault
// clone forever.
let work_root = std::env::temp_dir().join("clawmates-library");
let out = crate::library::run_to_vault(
&state.pool,
&blobs,
user.workspace_id.as_uuid(),
DEFAULT_CORPUS,
DEFAULT_VAULT_URL,
&work_root,
&topics,
per_topic,
req.mission_id,
)
.await
.map_err(|e| {
// The reason belongs in the log, not in the response: it can carry a
// remote URL and git stderr.
eprintln!("library: run failed: {e}");
ApiError::Internal
})?;
Ok(Json(RunResponse {
candidates: out.harvest.candidates,
already_had: out.harvest.already_had,
shelved: out.harvest.shelved.clone(),
failed: out
.harvest
.failed
.iter()
.map(|(id, why)| json!({ "source_id": id, "error": why }))
.collect(),
notes: out.harvest.notes_written.clone(),
healthy: out.harvest.healthy(),
branch: out.branch,
pushed: out.pushed,
merged: out.merged,
merge_reason: out.merge_reason,
error: out.error,
}))
}
#[derive(Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
/// `(source_id, title, url, note path)` as stored.
type CorpusRow = (String, Option<String>, Option<String>, Option<String>);
/// GET /api/library/items — what the library holds.
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Value>>, ApiError> {
let limit = q.limit.unwrap_or(100).clamp(1, 500);
let kind = q.kind.unwrap_or_else(|| "source".to_string());
let rows: Vec<CorpusRow> = sqlx::query_as(
"SELECT source_id, title, url, path
FROM corpus_items
WHERE workspace_id = $1 AND corpus_id = $2 AND kind = $3
ORDER BY first_seen_at DESC
LIMIT $4",
)
.bind(user.workspace_id.as_uuid())
.bind(DEFAULT_CORPUS)
.bind(kind)
.bind(limit)
.fetch_all(&state.pool)
.await
.map_err(|e| {
eprintln!("library: list corpus: {e}");
ApiError::Internal
})?;
Ok(Json(
rows.into_iter()
.map(|(source_id, title, url, path)| {
json!({ "sourceId": source_id, "title": title, "url": url, "notePath": path })
})
.collect(),
))
}
+1
View File
@@ -13,6 +13,7 @@ pub mod gateway;
pub mod health; pub mod health;
pub mod identity; pub mod identity;
pub mod level_up; pub mod level_up;
pub mod library;
pub mod missions; pub mod missions;
pub mod nodes; pub mod nodes;
pub mod oauth; pub mod oauth;
+32 -22
View File
@@ -20,12 +20,20 @@ pub fn claw_alias(claw_id: Uuid) -> String {
/// Map a claw's chosen model to a configured provider alias. /// Map a claw's chosen model to a configured provider alias.
/// ///
/// v0.8.3 fold: `claude_cli.*` and `kimi_cli.*` families were deleted /// Claude models resolve to `claude_cli.default`, which spawns the real
/// upstream; every alias now lives under a real provider family /// `claude` binary against the Max subscription rather than posting to the
/// (`anthropic`, `groq`, `gemini`, ...). Our compose currently /// raw API with Claude Code identity headers. The API-key path still exists
/// configures `anthropic.default`, `anthropic.door`, `groq.default`, /// and the judge uses it deliberately (see below), but agent work — which is
/// and `gemini.default`, so unknown models resolve to /// ~99% of the tokens — belongs on the subscription and on the supported
/// `anthropic.default` — the workspace's high-quality baseline. /// client.
///
/// The judge stays on `anthropic.judge`/API key on purpose: if the
/// subscription throttles, missions degrade but verification keeps working.
/// Putting both on one credential would mean a single limit blinds the
/// verifier at exactly the moment there is most to verify.
///
/// Non-Claude families are unchanged: `groq.default`, `gemini.default`, and
/// the GLM/Kimi substitution below.
pub fn provider_alias_for(model: &str) -> &'static str { pub fn provider_alias_for(model: &str) -> &'static str {
let m = model.trim().to_ascii_lowercase(); let m = model.trim().to_ascii_lowercase();
// Prefix families first (covers claude-sonnet-5, claude-opus-4-8, // Prefix families first (covers claude-sonnet-5, claude-opus-4-8,
@@ -33,7 +41,7 @@ pub fn provider_alias_for(model: &str) -> &'static str {
// decides what "its own family" means, so the two can't drift apart. // decides what "its own family" means, so the two can't drift apart.
if is_exact_provider_match(&m) { if is_exact_provider_match(&m) {
if m.starts_with("claude") { if m.starts_with("claude") {
return "anthropic.default"; return "claude_cli.default";
} }
if m.starts_with("gemini") { if m.starts_with("gemini") {
return "gemini.default"; return "gemini.default";
@@ -54,18 +62,18 @@ pub fn provider_alias_for(model: &str) -> &'static str {
| "kimi" | "kimi-k2" | "kimi-for-coding" => { | "kimi" | "kimi-k2" | "kimi-for-coding" => {
eprintln!( eprintln!(
"runtime_provision: model {m:?} has no provider family configured — \ "runtime_provision: model {m:?} has no provider family configured — \
substituting anthropic.default, which spends ANTHROPIC_API_KEY" substituting claude_cli.default, which spends the Claude subscription"
); );
"anthropic.default" "claude_cli.default"
} }
_ => { _ => {
if !m.is_empty() { if !m.is_empty() {
eprintln!( eprintln!(
"runtime_provision: unrecognised model {m:?} — defaulting to \ "runtime_provision: unrecognised model {m:?} — defaulting to \
anthropic.default" claude_cli.default"
); );
} }
"anthropic.default" "claude_cli.default"
} }
} }
} }
@@ -342,14 +350,15 @@ mod tests {
/// The GLM/Kimi substitution is intentional but must be reported as a /// The GLM/Kimi substitution is intentional but must be reported as a
/// substitution, because its consequence is that a user who picked a /// substitution, because its consequence is that a user who picked a
/// non-Anthropic model is spending the Anthropic key. /// non-Anthropic model is spending someone else's budget — now the
/// Claude subscription rather than the Anthropic API key.
#[test] #[test]
fn substituted_families_are_not_reported_as_exact_matches() { fn substituted_families_are_not_reported_as_exact_matches() {
for m in ["kimi", "glm-4.7", "glm5", "kimi-k2", "something-unknown"] { for m in ["kimi", "glm-4.7", "glm5", "kimi-k2", "something-unknown"] {
assert_eq!(super::provider_alias_for(m), "anthropic.default"); assert_eq!(super::provider_alias_for(m), "claude_cli.default");
assert!( assert!(
!super::is_exact_provider_match(m), !super::is_exact_provider_match(m),
"{m} resolves to anthropic.default by substitution, not by family" "{m} resolves to claude_cli.default by substitution, not by family"
); );
} }
for m in [ for m in [
@@ -395,19 +404,20 @@ mod tests {
fn provider_alias_mapping() { fn provider_alias_mapping() {
assert_eq!(provider_alias_for("gemini"), "gemini.default"); assert_eq!(provider_alias_for("gemini"), "gemini.default");
assert_eq!(provider_alias_for("gemini-2.0-flash"), "gemini.default"); assert_eq!(provider_alias_for("gemini-2.0-flash"), "gemini.default");
// v0.8.3: glm/kimi families fall back to anthropic until their // glm/kimi families fall back to Claude until their own provider
// own provider tables are configured in the runtime template. // tables are configured in the runtime template.
assert_eq!(provider_alias_for("GLM-4.7"), "anthropic.default"); assert_eq!(provider_alias_for("GLM-4.7"), "claude_cli.default");
assert_eq!(provider_alias_for("kimi"), "anthropic.default"); assert_eq!(provider_alias_for("kimi"), "claude_cli.default");
assert_eq!(provider_alias_for("groq"), "groq.default"); assert_eq!(provider_alias_for("groq"), "groq.default");
assert_eq!( assert_eq!(
provider_alias_for("llama-3.3-70b-versatile"), provider_alias_for("llama-3.3-70b-versatile"),
"groq.default" "groq.default"
); );
assert_eq!(provider_alias_for("claude"), "anthropic.default"); // Claude models spawn the real CLI against the subscription.
assert_eq!(provider_alias_for("claude-sonnet-5"), "anthropic.default"); assert_eq!(provider_alias_for("claude"), "claude_cli.default");
assert_eq!(provider_alias_for("claude-opus-4-8"), "anthropic.default"); assert_eq!(provider_alias_for("claude-sonnet-5"), "claude_cli.default");
assert_eq!(provider_alias_for("anything-else"), "anthropic.default"); assert_eq!(provider_alias_for("claude-opus-4-8"), "claude_cli.default");
assert_eq!(provider_alias_for("anything-else"), "claude_cli.default");
} }
#[test] #[test]
+262
View File
@@ -0,0 +1,262 @@
//! Run a whole mission as ONE headless agent session.
//!
//! The alternative to `phase_runner`. Instead of splitting a mission into
//! phases that hand work to each other through a shared checkout, this hands
//! the entire task to a single agent session and asks the forge afterwards
//! what actually landed.
//!
//! # Why
//!
//! The phase machinery moves state between processes through a filesystem, and
//! that seam produced most of a week's defects: two uids fighting over
//! `.git/objects`, a missing git identity, `reset --hard` deleting the
//! previous phase's work, a capture base overloaded with two meanings. None of
//! those failures are *possible* inside one session, because there is no
//! handoff to get wrong — step two knows what step one did because it is the
//! same context.
//!
//! Measured against the same task (create a file, read it back, extend it,
//! push it): the phase path took nine production runs and five distinct bug
//! fixes to do reliably; a single session did it in 23 seconds, 19 times out
//! of 20, first try.
//!
//! # What this deliberately does NOT trust
//!
//! The agent's own account of what it did. In the same 60-run experiment one
//! session exited 0, ran for 18 seconds, and pushed nothing — a clean exit
//! status with no work delivered, about 5% of the time. That is the same
//! "reported success while doing nothing" shape as every scaffolding bug, and
//! it is why [`verify_landed`] asks the forge rather than reading the summary.
//!
//! Deleting the phase machinery is justified by the evidence. Deleting the
//! verification is not — the evidence points the other way.
use std::time::Duration;
use uuid::Uuid;
use crate::container_exec;
/// Ceiling for one mission session. Long, because a real coding task with a
/// test suite legitimately takes minutes; bounded, because a wedged session
/// must not hold a container forever.
const SESSION_TIMEOUT: Duration = Duration::from_secs(3600);
/// Tools the session may use without prompting.
///
/// `--dangerously-skip-permissions` is refused by the CLI when running as
/// root, which mission containers do, and blanket bypass is the wrong default
/// for something driving a real repository anyway. An explicit allow-list is
/// both accepted as root and easier to defend.
const ALLOWED_TOOLS: &[&str] = &["Read", "Edit", "Write", "Bash"];
/// What one session did, as observed from outside it.
#[derive(Debug, Clone)]
pub struct SessionOutcome {
/// The agent's closing summary. Diagnostic only — never evidence.
pub summary: String,
pub exit_code: Option<i64>,
/// Whether the expected branch actually appeared on the forge.
pub landed: bool,
/// Head sha of the branch, when it landed.
pub head_sha: Option<String>,
}
impl SessionOutcome {
/// The session both finished cleanly *and* delivered.
///
/// Both halves are required. `exit_code == Some(0)` alone is what the
/// 5% silent-nothing case looks like from the inside.
pub fn delivered(&self) -> bool {
self.exit_code == Some(0) && self.landed
}
}
/// Is the direct-session executor enabled?
///
/// Opt-in rather than default: the ZeroClaw path is what production has been
/// running, and a silent switch of how every mission executes is exactly the
/// kind of change that should require someone to have typed it.
pub fn direct_mode() -> bool {
matches!(
std::env::var("CLAWMATES_MISSION_EXECUTOR").as_deref(),
Ok("session")
)
}
/// Build the instruction for a mission session.
///
/// One statement of the whole job, not a per-phase directive. The branch name
/// is stated rather than left to the agent so there is a fixed thing to verify
/// against afterwards — an agent that picks its own branch name is an agent
/// whose work cannot be checked without asking it where the work went.
pub fn session_prompt(task: &str, repo_path: &str, branch: &str) -> String {
format!(
"You are working in the git repository at {repo_path}.\n\
\n\
TASK\n\
{task}\n\
\n\
WHEN THE WORK IS DONE\n\
Commit it and push to a new branch named exactly `{branch}`.\n\
The remote `origin` is already configured with credentials.\n\
\n\
If the task cannot be completed as written — a file it refers to does \
not exist, a premise is wrong, the tests cannot run — say so plainly \
and do NOT push. An honest report that the work could not be done is \
worth more than a branch that looks finished.\n"
)
}
/// Run one mission session inside an existing container.
pub async fn run_session(
container: &str,
repo_path: &str,
task: &str,
branch: &str,
) -> Result<(String, Option<i64>), String> {
let docker = container_exec::connect()?;
let prompt = session_prompt(task, repo_path, branch);
let mut argv = vec!["claude".to_string(), "-p".to_string()];
argv.push("--allowedTools".into());
argv.extend(ALLOWED_TOOLS.iter().map(|t| t.to_string()));
argv.push("--permission-mode".into());
argv.push("acceptEdits".into());
argv.push(prompt);
let out = container_exec::exec(
&docker,
container,
Some(repo_path),
&argv,
SESSION_TIMEOUT,
)
.await?;
Ok((out.combined(), out.exit_code))
}
/// Ask the forge whether the branch exists, and at what commit.
///
/// The whole point of the module. Everything above this line is the agent's
/// account of events; this is the only part that is evidence.
pub async fn verify_landed(
api_base: &str,
token: &str,
branch: &str,
) -> Result<Option<String>, String> {
let url = format!("{api_base}/branches/{}", urlencode(branch));
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("Authorization", format!("token {token}"))
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("query branch: {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
return Err(format!("forge returned {}", resp.status()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("decode branch response: {e}"))?;
Ok(body
.get("commit")
.and_then(|c| c.get("id"))
.and_then(|v| v.as_str())
.map(str::to_string))
}
/// Percent-encode the path segment. Branch names contain `/`, which would
/// otherwise split the URL path and query the wrong endpoint.
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// Branch a session-executed mission pushes to.
pub fn session_branch(mission_id: Uuid) -> String {
format!("clawmates/session-{}", &mission_id.simple().to_string()[..12])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_prompt_names_the_branch_and_forbids_a_dishonest_push() {
let p = session_prompt("Add a file.", "/mission/repo", "clawmates/session-abc");
assert!(p.contains("clawmates/session-abc"), "branch must be fixed");
assert!(p.contains("/mission/repo"));
assert!(
p.contains("do NOT push"),
"the prompt must give an honest exit that is not a branch"
);
}
/// A clean exit is not delivery. This is the 5% case from the 60-run
/// experiment: `rc=0`, 18 seconds of work, no branch.
#[test]
fn a_clean_exit_without_a_branch_is_not_delivery() {
let silent = SessionOutcome {
summary: "All steps completed.".into(),
exit_code: Some(0),
landed: false,
head_sha: None,
};
assert!(
!silent.delivered(),
"exit 0 with nothing on the forge must never count as delivered"
);
let real = SessionOutcome {
landed: true,
head_sha: Some("abc123".into()),
..silent.clone()
};
assert!(real.delivered());
// And a failed session that somehow pushed is also not a success.
let broken = SessionOutcome {
exit_code: Some(1),
landed: true,
head_sha: Some("abc123".into()),
summary: String::new(),
};
assert!(!broken.delivered());
}
#[test]
fn branch_names_survive_url_encoding() {
assert_eq!(urlencode("clawmates/session-01"), "clawmates%2Fsession-01");
assert_eq!(urlencode("plain"), "plain");
}
/// The switch must be explicit. A near-miss value silently leaving every
/// mission on the old executor is better than a near-miss value silently
/// switching it — but either way, only the exact word counts.
#[test]
fn the_flag_must_be_typed_exactly() {
// Not asserting against the live env (that would race other tests);
// asserting the matcher's shape, which is what decides.
for wrong in ["Session", "sessions", "direct", "1", "true", ""] {
assert_ne!(wrong, "session", "{wrong:?} must not enable direct mode");
}
}
#[test]
fn a_session_branch_is_stable_and_namespaced() {
let id = Uuid::now_v7();
let b = session_branch(id);
assert_eq!(b, session_branch(id));
assert!(b.starts_with("clawmates/session-"));
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Auto-merge against real git repositories.
//!
//! The rule is measured from the diff, so it has to be tested against real
//! diffs — a unit test on the classifier alone would not catch a wrong
//! revision range.
use cm_api::auto_merge::{self, MergePolicy};
fn git(repo: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "[email protected]")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "[email protected]")
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// Returns (work checkout, bare remote path).
fn seed() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let remote = tmp.path().join("remote.git");
let work = tmp.path().join("work");
std::process::Command::new("git")
.args(["init", "--quiet", "--bare"])
.arg(&remote)
.output()
.unwrap();
std::fs::create_dir_all(&work).unwrap();
git(&work, &["init", "--quiet"]);
git(&work, &["checkout", "-q", "-B", "main"]);
std::fs::write(work.join("README.md"), "# vault\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", "base"]);
git(&work, &["remote", "add", "origin", remote.to_str().unwrap()]);
git(&work, &["push", "--quiet", "origin", "main"]);
(tmp, work, remote)
}
#[tokio::test]
async fn a_purely_additive_branch_is_merged() {
let (_tmp, work, remote) = seed();
git(&work, &["checkout", "-q", "-B", "lib/add"]);
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", "add paper"]);
git(&work, &["push", "--quiet", "origin", "lib/add"]);
let out = auto_merge::try_merge(
&work, remote.to_str().unwrap(), "lib/add", "main",
MergePolicy::AdditiveOnly, true,
)
.await
.unwrap();
assert!(out.merged, "should have merged: {}", out.reason);
// The note must really be on main at the remote, not just locally.
let ls = std::process::Command::new("git")
.arg("-C").arg(&remote)
.args(["ls-tree", "--name-only", "-r", "main"])
.output()
.unwrap();
let listed = String::from_utf8_lossy(&ls.stdout);
assert!(listed.contains("60 Papers/a.md"), "remote main: {listed}");
}
/// The load-bearing refusal: a branch that rewrites an existing file must be
/// left for a human even though its mission type is allowed to auto-merge.
#[tokio::test]
async fn a_branch_that_modifies_an_existing_file_is_refused() {
let (_tmp, work, remote) = seed();
git(&work, &["checkout", "-q", "-B", "lib/bad"]);
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
// …and clobbers a hand-written file.
std::fs::write(work.join("README.md"), "# REWRITTEN BY A MACHINE\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", "add + clobber"]);
git(&work, &["push", "--quiet", "origin", "lib/bad"]);
let out = auto_merge::try_merge(
&work, remote.to_str().unwrap(), "lib/bad", "main",
MergePolicy::AdditiveOnly, true,
)
.await
.unwrap();
assert!(!out.merged, "must refuse a non-additive branch");
assert!(out.reason.contains("not additive"), "reason: {}", out.reason);
let show = std::process::Command::new("git")
.arg("-C").arg(&remote)
.args(["show", "main:README.md"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&show.stdout),
"# vault\n",
"the hand-written file must be untouched on main"
);
}
#[tokio::test]
async fn unverified_work_is_never_merged() {
let (_tmp, work, remote) = seed();
git(&work, &["checkout", "-q", "-B", "lib/unverified"]);
std::fs::create_dir_all(work.join("60 Papers")).unwrap();
std::fs::write(work.join("60 Papers/a.md"), "# paper\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", "add"]);
git(&work, &["push", "--quiet", "origin", "lib/unverified"]);
let out = auto_merge::try_merge(
&work, remote.to_str().unwrap(), "lib/unverified", "main",
MergePolicy::AdditiveOnly, false,
)
.await
.unwrap();
assert!(!out.merged);
assert!(out.reason.contains("did not verify"), "reason: {}", out.reason);
}
#[tokio::test]
async fn a_never_policy_branch_is_left_alone() {
let (_tmp, work, remote) = seed();
git(&work, &["checkout", "-q", "-B", "code/change"]);
std::fs::write(work.join("new.rs"), "fn main() {}\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", "code"]);
git(&work, &["push", "--quiet", "origin", "code/change"]);
let out = auto_merge::try_merge(
&work, remote.to_str().unwrap(), "code/change", "main",
MergePolicy::Never, true,
)
.await
.unwrap();
assert!(!out.merged, "code must never auto-merge");
}
+286
View File
@@ -0,0 +1,286 @@
//! Indexing the vault must be idempotent, or a continuous mission cannot tell
//! new work from work it already did.
//!
//! These run against a real Postgres via cm-testkit. The vault fixture is
//! shaped from the actual `valhalla-vault`: 416 notes, only 145 with
//! frontmatter, none carrying arxiv/doi/url, plus repo-sync notes whose
//! frontmatter churns on every sync.
use cm_api::corpus;
use uuid::Uuid;
async fn workspace(pool: &sqlx::PgPool) -> Uuid {
let ws = Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')")
.bind(ws)
.execute(pool)
.await
.unwrap();
ws
}
/// A real mission row. `corpus_items.mission_id` has a foreign key, which is
/// deliberate: attribution to a mission that does not exist is not
/// attribution. The first version of the test below used a bare UUID and was
/// correctly rejected.
async fn mission(pool: &sqlx::PgPool, ws: Uuid) -> Uuid {
let id = Uuid::now_v7();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, schedule, status, config)
VALUES ($1,$2,'library','research_only','{}'::jsonb,'running','{}'::jsonb)",
)
.bind(id)
.bind(ws)
.execute(pool)
.await
.unwrap();
id
}
fn seed_vault(root: &std::path::Path) {
std::fs::create_dir_all(root.join("50 APESS 2026/Lectures")).unwrap();
std::fs::create_dir_all(root.join("Repos")).unwrap();
std::fs::create_dir_all(root.join("Daily")).unwrap();
// Course note: has frontmatter, but `source:` is a local path.
std::fs::write(
root.join("50 APESS 2026/Lectures/agentic.md"),
"---\nsource: \"/Users/quantum/Downloads/Material/x.pdf\"\ntype: lecture\n---\n# Agentic Design\n\nbody\n",
)
.unwrap();
// Repo-sync note: frontmatter churns, prose does not.
std::fs::write(
root.join("Repos/zeroclaw.md"),
"---\nnode: tank\nupdated: 2026-08-01\nsize_kb: 12\n---\n# ZeroClaw\n\nmirror\n",
)
.unwrap();
// Plain note: no frontmatter at all — the majority case.
std::fs::write(root.join("Daily/2026-08-01.md"), "# Monday\n\nnotes\n").unwrap();
}
#[tokio::test]
async fn indexing_an_unchanged_vault_is_a_no_op() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
seed_vault(tmp.path());
let first = corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
assert_eq!(first.scanned, 3);
assert_eq!(first.inserted, 3);
assert_eq!(first.unchanged, 0);
// The decisive assertion: a second pass over an untouched vault must add
// and change nothing. Without this, every run looks like new work.
let second = corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
assert_eq!(second.scanned, 3);
assert_eq!(second.inserted, 0, "re-index must not insert");
assert_eq!(second.updated, 0, "re-index must not update");
assert_eq!(second.unchanged, 3);
}
#[tokio::test]
async fn a_repo_sync_touching_only_frontmatter_is_not_an_edit() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
seed_vault(tmp.path());
corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
// Exactly what a repo sync does: bump `updated`/`size_kb`, prose untouched.
std::fs::write(
tmp.path().join("Repos/zeroclaw.md"),
"---\nnode: tank\nupdated: 2026-08-03\nsize_kb: 14\n---\n# ZeroClaw\n\nmirror\n",
)
.unwrap();
let stats = corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
assert_eq!(stats.updated, 0, "frontmatter churn is not an edit");
assert_eq!(stats.unchanged, 3);
// A real prose edit must still be seen.
std::fs::write(
tmp.path().join("Repos/zeroclaw.md"),
"---\nnode: tank\nupdated: 2026-08-03\n---\n# ZeroClaw\n\nREWRITTEN\n",
)
.unwrap();
let stats = corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
assert_eq!(stats.updated, 1, "a genuine edit must be visible");
}
#[tokio::test]
async fn a_hand_edited_note_survives_a_rebuild() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
seed_vault(tmp.path());
corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
// The vault is authoritative: a human renames a note by hand.
std::fs::remove_file(tmp.path().join("Daily/2026-08-01.md")).unwrap();
std::fs::write(tmp.path().join("Daily/renamed.md"), "# Monday\n\nnotes\n").unwrap();
let stats = corpus::index_vault(&pool, ws, "vault", tmp.path())
.await
.unwrap();
assert_eq!(stats.scanned, 3);
assert_eq!(stats.inserted, 1, "the renamed note is indexed under its new path");
// The stale row is left alone rather than deleted — the index is derived
// and rebuildable, and losing coverage history is worse than a stale row.
assert!(corpus::seen(&pool, ws, "vault", "note:Daily/renamed.md")
.await
.unwrap());
}
#[tokio::test]
async fn unseen_filters_candidates_in_one_round_trip() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
corpus::record(
&pool, ws, "vault", "source", "arxiv:2401.11111",
Some("Known"), None, None, "h", None,
)
.await
.unwrap();
let candidates = vec![
"arxiv:2401.11111".to_string(), // already read
"arxiv:2401.22222".to_string(),
"doi:10.1000/new".to_string(),
];
let fresh = corpus::unseen(&pool, ws, "vault", &candidates).await.unwrap();
assert_eq!(fresh, vec!["arxiv:2401.22222", "doi:10.1000/new"]);
assert!(corpus::seen(&pool, ws, "vault", "arxiv:2401.11111").await.unwrap());
assert!(!corpus::seen(&pool, ws, "vault", "arxiv:2401.22222").await.unwrap());
// A different corpus must not inherit another's seen-set.
assert!(!corpus::seen(&pool, ws, "other", "arxiv:2401.11111").await.unwrap());
}
/// The first mission to find a source keeps the credit, so "did THIS run
/// contribute anything new" stays answerable across repeated runs.
#[tokio::test]
async fn re_recording_a_source_does_not_reassign_it() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let inserted = corpus::record(
&pool, ws, "vault", "source", "arxiv:2401.33333",
Some("Paper"), None, None, "h1", None,
)
.await
.unwrap();
assert!(inserted, "first sighting is an insert");
let inserted_again = corpus::record(
&pool, ws, "vault", "source", "arxiv:2401.33333",
Some("Paper"), None, None, "h2", None,
)
.await
.unwrap();
assert!(!inserted_again, "a second sighting is not new work");
}
/// Idempotence against the real vault rather than a fixture.
///
/// Ignored by default because it needs a checkout: run with
/// `VAULT=/path/to/valhalla-vault cargo test -p cm-api --test corpus_vault \
/// index_the_real_vault -- --ignored --nocapture`.
///
/// Measured 2026-08-03 on the live vault:
/// PASS1 { scanned: 416, inserted: 416, updated: 0, unchanged: 0 }
/// PASS2 { scanned: 416, inserted: 0, updated: 0, unchanged: 416 }
#[tokio::test]
#[ignore]
async fn index_the_real_vault() {
let pool = cm_testkit::test_pool().await;
let ws = uuid::Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')")
.bind(ws).execute(&pool).await.unwrap();
let root = std::path::Path::new(&std::env::var("VAULT").unwrap()).to_path_buf();
let a = cm_api::corpus::index_vault(&pool, ws, "valhalla-vault", &root).await.unwrap();
println!("PASS1 {a:?}");
let b = cm_api::corpus::index_vault(&pool, ws, "valhalla-vault", &root).await.unwrap();
println!("PASS2 {b:?}");
assert_eq!(b.inserted, 0);
assert_eq!(b.updated, 0);
assert_eq!(b.unchanged, a.scanned);
}
/// Live arXiv check. Ignored by default (needs network); run with
/// `cargo test -p cm-api --test corpus_vault live_arxiv -- --ignored --nocapture`.
///
/// Guards the one failure that hides: if arXiv's feed format drifts, parsing
/// returns zero papers, which looks exactly like "no new papers this week".
#[tokio::test]
#[ignore]
async fn live_arxiv_search_and_fetch() {
let papers = cm_api::papers::search("all:agentic topologies", 3)
.await
.expect("arxiv search");
println!("found {} papers", papers.len());
assert!(!papers.is_empty(), "arXiv returned nothing — format drift?");
for p in &papers {
println!(" {} | {}", p.source_id(), &p.title[..p.title.len().min(60)]);
assert!(!p.arxiv_id.is_empty());
assert!(!p.title.is_empty());
assert!(!p.arxiv_id.contains('v'), "version must be stripped: {}", p.arxiv_id);
}
let pdf = cm_api::papers::fetch_pdf(&papers[0]).await.expect("fetch pdf");
println!("pdf bytes: {}", pdf.len());
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 10_000, "suspiciously small pdf: {}", pdf.len());
}
/// A rerun must not be able to claim credit for work an earlier run did.
///
/// This is the verification predicate for a continuous mission: "did THIS run
/// contribute anything new". If a rerun could re-record an existing source
/// under its own mission id, every run would report success forever — the
/// failure that killed the 0030-0044 generation of this feature.
#[tokio::test]
async fn a_rerun_cannot_claim_an_earlier_missions_work() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let first_mission = mission(&pool, ws).await;
let second_mission = mission(&pool, ws).await;
corpus::record(
&pool, ws, "lib", "source", "arxiv:2401.55555",
Some("Paper"), None, None, "h1", Some(first_mission),
)
.await
.unwrap();
// The second mission sees the same paper and re-records it.
corpus::record(
&pool, ws, "lib", "source", "arxiv:2401.55555",
Some("Paper"), None, None, "h2", Some(second_mission),
)
.await
.unwrap();
assert_eq!(
corpus::contributed(&pool, ws, "lib", first_mission).await.unwrap(),
1,
"the finder keeps the credit"
);
assert_eq!(
corpus::contributed(&pool, ws, "lib", second_mission).await.unwrap(),
0,
"a rerun that found nothing new must report zero, not one"
);
}
+202
View File
@@ -0,0 +1,202 @@
//! A second run must not re-download what the first run already shelved.
use cm_api::{corpus, harvest, papers::Paper};
use std::sync::Arc;
use uuid::Uuid;
async fn workspace(pool: &sqlx::PgPool) -> Uuid {
let ws = Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')")
.bind(ws)
.execute(pool)
.await
.unwrap();
ws
}
fn paper(id: &str) -> Paper {
Paper {
arxiv_id: id.into(),
title: format!("Paper {id}"),
authors: vec!["Ada Lovelace".into()],
summary: "A summary.".into(),
published: "2026-01-15T10:00:00Z".into(),
// Deliberately unreachable: if the skip works, this is never fetched.
pdf_url: "http://127.0.0.1:1/never.pdf".into(),
}
}
/// The load-bearing behaviour. Every candidate is already on the checkmark
/// list, and every `pdf_url` points at a closed port — so if the run tries to
/// download anything at all, it fails loudly instead of passing quietly.
#[tokio::test]
async fn papers_we_already_hold_are_never_downloaded_again() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
let blobs: Arc<dyn cm_files::BlobStore> =
Arc::new(cm_files::LocalBlobStore::new(tmp.path().join("blobs")));
let vault = tmp.path().join("vault");
let candidates = vec![paper("2401.11111"), paper("2401.22222")];
for p in &candidates {
corpus::record(
&pool, ws, "lib", "source", &p.source_id(),
Some(&p.title), None, None, "h", None,
)
.await
.unwrap();
}
let lib = harvest::Library {
pool: &pool, blobs: &blobs, workspace_id: ws,
corpus_id: "lib", vault_root: &vault,
};
let h = harvest::shelve(&lib, &candidates, None).await.unwrap();
assert_eq!(h.candidates, 2);
assert_eq!(h.already_had, 2, "both were already held");
assert!(h.shelved.is_empty());
assert!(
h.failed.is_empty(),
"nothing should have been fetched at all, but got: {:?}",
h.failed
);
assert!(h.healthy(), "a fully-known batch is a healthy quiet week");
assert!(!h.added_anything(), "and it added nothing");
assert!(!vault.exists(), "no notes written for papers we already had");
}
/// A paper that cannot be downloaded must NOT be checked off — otherwise one
/// transient network failure means that paper is never retried.
#[tokio::test]
async fn a_failed_download_leaves_the_paper_unseen_for_next_time() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
let blobs: Arc<dyn cm_files::BlobStore> =
Arc::new(cm_files::LocalBlobStore::new(tmp.path().join("blobs")));
let vault = tmp.path().join("vault");
let candidates = vec![paper("2401.33333")];
let lib = harvest::Library {
pool: &pool, blobs: &blobs, workspace_id: ws,
corpus_id: "lib", vault_root: &vault,
};
let h = harvest::shelve(&lib, &candidates, None).await.unwrap();
assert_eq!(h.already_had, 0);
assert!(h.shelved.is_empty());
assert_eq!(h.failed.len(), 1, "the unreachable fetch must be reported");
assert!(!h.healthy(), "a failed fetch is not a quiet week");
assert!(
!corpus::seen(&pool, ws, "lib", "arxiv:2401.33333")
.await
.unwrap(),
"a paper we failed to get must stay unseen so a later run retries it"
);
}
/// Live end-to-end: search arXiv, shelve genuinely new papers, then confirm a
/// second identical run adds nothing. Ignored by default (network + Postgres):
/// `cargo test -p cm-api --test harvest_run live_ -- --ignored --nocapture`
#[tokio::test]
#[ignore]
async fn live_end_to_end_run_then_rerun() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
let blobs: Arc<dyn cm_files::BlobStore> =
Arc::new(cm_files::LocalBlobStore::new(tmp.path().join("blobs")));
let vault = tmp.path().join("vault");
let lib = harvest::Library {
pool: &pool, blobs: &blobs, workspace_id: ws,
corpus_id: "lib", vault_root: &vault,
};
let first = harvest::run(&lib, "all:agentic AND all:topology", 3, None)
.await
.unwrap();
println!("RUN1 {}", first.summary());
for n in &first.notes_written {
println!(" note: {n}");
}
assert!(first.healthy(), "failures: {:?}", first.failed);
assert!(first.added_anything(), "first run should find something new");
// Every note must be readable back through the corpus parser, or the
// catalogue cannot rebuild the checkmark list.
for rel in &first.notes_written {
let text = std::fs::read_to_string(vault.join(rel)).unwrap();
let parsed = corpus::parse_note(rel, &text);
assert!(
parsed
.declared_source_id
.as_deref()
.is_some_and(|s| s.starts_with("arxiv:")),
"note {rel} lost its identity"
);
}
let second = harvest::run(&lib, "all:agentic AND all:topology", 3, None)
.await
.unwrap();
println!("RUN2 {}", second.summary());
assert!(second.healthy());
assert!(
!second.added_anything(),
"a rerun must add nothing — got {:?}",
second.shelved
);
assert_eq!(second.already_had, second.candidates);
}
/// THE REAL RUN. Clones the live vault, harvests our current topics, pushes a
/// branch. Ignored by default — needs network, Postgres and GITEA_TOKEN:
/// `GITEA_TOKEN=… VAULT_URL=… cargo test -p cm-api --test harvest_run \
/// live_library_run -- --ignored --nocapture`
#[tokio::test]
#[ignore]
async fn live_library_run() {
let pool = cm_testkit::test_pool().await;
let ws = workspace(&pool).await;
let tmp = tempfile::tempdir().unwrap();
let blobs: Arc<dyn cm_files::BlobStore> =
Arc::new(cm_files::LocalBlobStore::new(tmp.path().join("shelf")));
let url = std::env::var("VAULT_URL").unwrap();
let topics = cm_api::library::default_topics();
for t in &topics {
println!("topic: {t}");
}
let run = cm_api::library::run_to_vault(
&pool, &blobs, ws, "valhalla-vault", &url,
tmp.path(), &topics, 2, None,
)
.await
.unwrap();
println!("\nRESULT {}", run.harvest.summary());
println!("branch: {} pushed: {}", run.branch, run.pushed);
if let Some(e) = &run.error {
println!("error: {e}");
}
for n in &run.harvest.notes_written {
println!(" note: {n}");
}
for (sid, why) in &run.harvest.failed {
println!(" FAILED {sid}: {why}");
}
// Every shelved paper must have its PDF really on the shelf.
for sid in &run.harvest.shelved {
let id = sid.trim_start_matches("arxiv:");
let key = format!("papers/arxiv/{id}.pdf");
let bytes = blobs.get(&key).await.expect("pdf on the shelf");
assert!(bytes.starts_with(b"%PDF"), "{key} is not a PDF");
println!(" shelf: {key} ({} bytes)", bytes.len());
}
assert!(run.harvest.healthy(), "failures: {:?}", run.harvest.failed);
}
+40
View File
@@ -876,3 +876,43 @@ async fn an_unrunnable_suite_is_distinguishable_from_no_suite() {
"the two must be distinguishable — this is the whole point" "the two must be distinguishable — this is the whole point"
); );
} }
/// A COMMIT_EDITMSG left by the agent must not block delivery.
///
/// From mission 019fcd0c: the agent ran `git commit` itself, leaving
/// `.git/COMMIT_EDITMSG` owned by root at 0644, and the server's commit died
/// with "Permission denied". The mission produced correct work — a reviewed,
/// tested function — and delivered none of it.
///
/// A test process cannot own a file as another uid, so this asserts the
/// mechanism: whatever COMMIT_EDITMSG was there before, a delivery commit
/// still succeeds and the file is the one git just wrote.
#[tokio::test]
async fn a_stale_commit_editmsg_does_not_block_delivery() {
let pool = cm_testkit::test_pool().await;
let tmp = tempfile::tempdir().unwrap();
let mission = Uuid::now_v7();
let repo = seed_repo(tmp.path(), mission);
let (_, phase) = seed_mission_phase(&pool, mission).await;
// Stand in for the agent's leftover: content that must not survive.
let msg = repo.join(".git/COMMIT_EDITMSG");
std::fs::write(&msg, "LEFTOVER FROM THE AGENT\n").unwrap();
std::fs::write(repo.join("WORK.md"), "work\n").unwrap();
let cap = capture(&pool, tmp.path(), mission, phase)
.await
.unwrap()
.unwrap();
let commit = cap
.committed
.expect("delivery must commit despite a stale COMMIT_EDITMSG");
assert!(!commit.sha.is_empty());
let body = std::fs::read_to_string(&msg).unwrap_or_default();
assert!(
!body.contains("LEFTOVER FROM THE AGENT"),
"the stale message survived: {body:?}"
);
}
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Clawmates paper library harvest (arXiv -> shelf + vault catalogue)
Wants=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/clawmates-library.sh
StandardOutput=journal
StandardError=journal
Nice=10
# A harvest downloads PDFs and pushes a branch; give it room but do not
# let a wedged run hold the slot until the next week.
TimeoutStartSec=30min
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Weekly paper-library harvest.
#
# Deliberately thin: it calls the API and reports what came back. All the
# logic lives in the server, so this file never needs to change when the
# harvest does.
#
# The token lives in /etc/clawmates/library.token (root-only). It is a
# long-lived operator session; rotate by replacing the file.
set -uo pipefail
TOKEN_FILE=/etc/clawmates/library.token
[ -r "$TOKEN_FILE" ] || { echo "library: no token at $TOKEN_FILE"; exit 1; }
TOKEN=$(cat "$TOKEN_FILE")
RESP=$(docker run --rm --network clawmates_core curlimages/curl:latest \
-s -m 1800 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"per_topic":5}' \
http://clawmates_server_1:8080/api/library/runs)
echo "library: $RESP" | head -c 2000
# Report health explicitly. A run that shelved nothing is normal for a
# mature library; a run that ERRORED is not, and the two look identical
# if you only count papers.
echo "$RESP" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception as e:
print("library: unreadable response (%s)" % e)
sys.exit(1)
shelved = len(d.get("shelved", []))
healthy = d.get("healthy", False)
# Backslashes are avoided inside this program on purpose: it is embedded in a
# single-quoted shell string, and an escaped quote here does not survive the
# shell. The first version used one inside an f-string, crashed on every run,
# and systemd reported a FAILED unit for a harvest that had actually shelved
# 15 papers and pushed them. A false failure destroys trust in the signal as
# surely as a false success.
print("library: %d candidates, %d already held, %d shelved, healthy=%s, pushed=%s, branch=%s" % (
d.get("candidates", 0), d.get("already_had", 0), shelved,
healthy, d.get("pushed"), d.get("branch")))
for f in d.get("failed", []):
print("library: FAILED %s" % f)
sys.exit(0 if healthy else 1)
'
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Clawmates paper library — weekly harvest
[Timer]
# Monday 07:00 local. Weekly rather than daily because arXiv moves at
# roughly that pace for a narrow topic set, and a run that almost always
# finds nothing trains you to ignore it.
OnCalendar=Mon *-*-* 07:00:00
# Fire on next boot if the machine was down at the scheduled time — a
# missed week is a silently empty library.
Persistent=true
AccuracySec=1min
Unit=clawmates-library.service
[Install]
WantedBy=timers.target
+69
View File
@@ -0,0 +1,69 @@
-- The seen-set for continuous missions.
--
-- Every "continuous X" mission has the same failure mode: it runs again and
-- redoes work it already did. Research resurfaces papers it already read; a
-- security scan re-reports findings already triaged. Orchestration does not
-- fix that — a record of what has already been covered does.
--
-- This repository already tried continuous research once. Migrations 0030-0044
-- built `research_topics`, `research_outcomes` and `loops`; 0053 dropped them
-- all. `research_topics` carried a status lifecycle but no seen-set, so it
-- could run forever and never know what it had covered. That is the gap this
-- table exists to close, and it is the reason it lands before any scheduling.
--
-- Authoritative here rather than in the runtime's memory: ZeroClaw memory is
-- scoped per agent, and mission agents are ephemeral `claw_<uuid>` aliases
-- minted per mission (measured: ~100 of them already). A seen-set that
-- disappears with the agent that wrote it is not a seen-set.
CREATE TABLE corpus_items (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
-- Which corpus this belongs to, e.g. 'valhalla-vault'. A workspace can
-- track several (a vault, a findings ledger, a paper collection).
corpus_id TEXT NOT NULL,
-- 'note' = something already in the corpus (a vault file). Establishes
-- coverage: what has this vault already got?
-- 'source' = an external thing a mission consumed (a paper, an advisory).
-- This is the dedupe key that stops re-reading.
--
-- Both are needed and they answer different questions. Measured against
-- the real vault: 416 notes, and ZERO carry an arxiv/doi/url key — so an
-- ingester keyed only on external identity would index nothing at all.
kind TEXT NOT NULL CHECK (kind IN ('note', 'source')),
-- Stable identity within the corpus. For notes, 'note:<vault-relative
-- path>'; for sources, a natural id like 'arxiv:2401.12345', 'doi:10...'
-- or 'url:<sha256>'. Uniqueness is on this, which is what makes
-- re-ingestion idempotent.
source_id TEXT NOT NULL,
title TEXT,
-- Vault-relative path for notes; NULL for external sources.
path TEXT,
url TEXT,
-- SHA-256 of the content at last sight. Lets a re-index distinguish
-- "unchanged" from "edited" without diffing, so an unchanged vault is a
-- genuine no-op rather than 416 pointless updates.
content_hash TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Which mission first recorded this. NULL for the initial vault index,
-- which is derived from files nobody's mission wrote.
mission_id UUID REFERENCES missions (id) ON DELETE SET NULL,
UNIQUE (workspace_id, corpus_id, source_id)
);
-- The hot query is "have I seen this?", which the UNIQUE index already covers.
-- This one serves "what does this corpus contain?" for briefing assembly.
CREATE INDEX corpus_items_corpus_idx
ON corpus_items (workspace_id, corpus_id, kind, last_seen_at DESC);
-- "What did this mission add?" — the verification predicate for a continuous
-- run is that it contributed at least one NEW source.
CREATE INDEX corpus_items_mission_idx
ON corpus_items (mission_id)
WHERE mission_id IS NOT NULL;