Skip to content

Release 0.23.0 - #68

Open
phy0x1a79ed wants to merge 500 commits into
hallamlab:releasefrom
phy0x1a79ed:release
Open

phy0x1a79ed wants to merge 500 commits into
hallamlab:releasefrom
phy0x1a79ed:release

Conversation

@phy0x1a79ed

@phy0x1a79ed phy0x1a79ed commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Release 0.23.0.

This pull request originally carried 0.22.1, which was never merged upstream. The
fork's release branch has moved on, so this one now delivers both.

What changes

A given's identity is assigned by an import into the agent's data pool and recorded
there, rather than derived from the filesystem at the moment the given is registered.
That fixes a reproduction campaign which could not reuse any cached work: every
submission handed its inputs a new identity, and the plan key moved with them.

  • Import is the only act that mints an identity for an input. Two physically separate
    files are two imports and two identities, whatever they are called, and importing the
    same file again is how a caller says a re-declaration is a different thing.
  • A plan refuses a given whose identity it minted itself, and names the import call that
    fixes it. The refusal tests whether this process invented the identity, not whether a
    pool holds it, so shipped templates, the env and container libraries and DVC-pinned
    references are unaffected.
  • A client can list a remote agent's pool and resolve a name to an identity over the
    relay, without the data being reachable from the client.
  • The web interface reads a remote pool and can take a plan's inputs from one.
  • The drivers under research/ and the fabfos pipelines take their givens from the pool.

CACHE_KEY_VERSION stays at 6 deliberately. Every affected key moves on its own
arithmetic, so a bump protects against nothing and would delete a measurement that two
pinned campaign batches exist to produce.

An assigned identity cannot be re-derived, so an agent's pool is authoritative state
rather than a cache of a calculation. Reuse does not cross a campaign, shards die with
the pool that holds their identities, and a comparison between two batches must finish
inside the agent home's retention window. This is written where an operator configures
an agent home.

Also merged in from the engine lane: the direct-run binding model and its lineage-key
fix, a bootstrap input-resolution fix, the solver ratchet, and the docker image
installing the wheel rather than the source distribution.

Artifacts

quay.io/hallamlab/metasmith:0.23.0-bf27ebf
  sha256:d083e03087b92ff0fbcca7ec9fe8d78b505a1c9bbe6b1e9a973889d1beb00fa7
anaconda.org/hallamlab/metasmith 0.23.0 (metasmith-0.23.0-py_0.tar.bz2)
full version 0.23.0+bf27ebf, vendored library cf24781

🤖 Generated with Claude Code

phy0x1a79ed and others added 30 commits August 19, 2026 17:26
…date them

The wire format the Groovy orchestrator emits existed nowhere but a gitignored
56MB agent home that no fixture ever resets. Metadata from both runs is now in
tests/metasmith/fixtures/real_runs, and the home drops runs/ and task_cache/
whenever the cache-key or lineage-payload contract it was written under differs
from this tree's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ohVkjgAjzYg4o7C9YtTK6
…corded it

CollectResults reconstructed provenance from trace edges keyed on compile-time
slot ids, in a third path-hash identity space, and guessed when a slot named
more than one producer. It could not resolve a parent that was another step's
output at all, which is why eight docker trace tests were skipped.

Execution records the fact instead. `.command.metadata` already carries PROV --
the index behind each staged input file -- so promote reads each output's direct
parents off it, stores them in the shard so a cache hit answers the same way,
and both trace emitters put them on ProducedFile.parents. CollectResults becomes
a topological merge of those rows.

Three things had to be true first. A step's produce instance and the downstream
step's require instance both carried the transform archetype's id, so `produces`
and `consumes` were in different identity spaces; cache_decisions now stamps the
slot id on both, which also makes a downstream key move when its producer does
(CACHE_KEY_VERSION 3 -> 4). The stub lane wrote no .command.metadata, so the one
lane the trace tests run in recorded nothing. And an uncacheable step promoted
nothing and emitted no event, leaving a hole every consumer downstream fell into.

The runtime doubles now call what they emulate: one payload builder for the
index, FILES and PROV; LinPayload.mint_file_id as the only file identity; the
codegen's spelling for publish paths; and the by-channel, not the plan's
instance list, as the count of tasks a step runs.

Verified: TestTraceLinearChain, TestTraceFanOutMerge and TestTraceMultiStepDiamond
all pass under real Nextflow (14/14), with new per-sample assertions that fail
when the old conflation is reintroduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ohVkjgAjzYg4o7C9YtTK6
…ef50/interproscan)

Ports the kofamscan + diamond-uniref50 + interproscan trio from the solved
nano-bandicoot workflow into a shipped template. Metabid deliberately absent:
its 744 GB GTDB reference is unreachable for a local run. Solves in 11 steps.
The driver now lives in templates/<name>/ next to the compiled spec. build_templates
resolves authors from either the package root (the other ten) or the template folder.
…ng that is true stays

`msm gui` printed werkzeug's red production-deployment warning and three more
banner lines on top of metasmith's own `serving at [...]`, on every start. Neither
Flask's banner nor werkzeug's is configurable, so `serve` drops to the layer under
both: `make_server` + `serve_forever`, which is also what lets the address line
report the port that was actually bound rather than the one that was asked for.
Request logging is untouched -- it goes through the same werkzeug logger either way.

Silencing it unconditionally would be papering over the advice, because there is
one case where it applies: `--host 0.0.0.0`. So the bind address is checked, and a
host outside the loopback range prints one warning saying the thing that is
actually true of this API -- it stages and runs work on remote hosts and reads
local files, with no authentication -- rather than a generic one about throughput.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…way it runs tools

Staging a workflow and launching it were the only things the GUI could do with an
agent; nothing prepared the host first. Tool images fetch lazily inside the first
task that needs one, which fails on a compute node with no route to a registry --
`metasmith workflow materialise` existed for that, but only from a terminal. Conda
environments were never created at all: the library ships one recipe per tool and
a `--runtime mamba` user was expected to build them by hand.

`SetupEnvironment` is both halves behind one verb, dispatching on the agent rather
than on the person asking: an image store to fill for a container runtime, envs to
build for mamba or native. The container half is the proven one reused as-is. The
conda half is its sibling in `agents/conda.py` -- free functions taking a shell,
skipping an env that already answers `mamba run -n <name>`, which is the wrapper
every task is launched under, so a second press does nothing and `force` removes
before rebuilding.

What it cannot do it reports rather than guesses. An env whose recipe the library
does not ship is named, because a package spec inferred from a container tag makes
a plausible env that is not the one the transform was written against. A step whose
env resource carries no `conda:` at all is named with the container it does have --
the same fact `_check_env_portability` raises on at launch, produced early and as a
list. A host with neither mamba nor conda is answered once instead of failing every
create.

In the GUI it is a split button beside `stage and run`, with `force setup` behind
its chevron -- the gesture AgentView's force-redeploy already teaches, now a shared
`SplitButton`. The endpoint stages with `on_exist=update` first, because the
manifest that says which images and envs are needed is written by staging, and
writes no run record: this is preparation, not a run. The page had one job id and
assumed it was the solve, so it now tracks which kind is running or a setup would
paint the solve's stage bar.

Verified end to end against a local mamba agent: an env built from the library's
recipe, skipped on the second run, removed and rebuilt under force, with a
container-only step reported beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es a reload

showDeployBar now follows jobId alone, not "still running or failed" -- it is
the record of the last attempt, not a spinner that vanishes when the wait is
over. The last stage now actually turns green on success instead of getting
stuck at "running". On mount, AgentView asks GET /jobs?agent=<name> for the
most recent deploy job and re-attaches to it, so revisiting or reloading the
page shows what a tab left open the whole time would have shown.

Co-Authored-By: Opal <noreply@anthropic.com>
…swallowing it

NonBlockingReader only flushed its buffer on `\n`, so a pull's `\r`-redrawn
progress bar sat invisible in the pty buffer (up to the 1MB truncation cap)
until the command finally printed a real newline. It now flushes on `\r` too,
via a universal-newline split that keeps each boundary on the segment it
ends -- lone `\r` marks a redraw, `\n`/`\r\n` commits a line.

LiveShell's tee carries that distinction forward as a trailing `\r` on the
message text, since every listener downstream is `Callable[[str], None]`.
Job.emit collapses a still-mid-redraw last line in place rather than letting
every tick of a pull pile up as its own log entry (and evict real setup
output once the 5000-line cap is hit); JobLog.svelte applies the identical
rule client-side so backlog replay and a live stream never disagree on what
they show.

Two unit tests were asserting exact call signatures from before `on_phase`
was added to Agent.Deploy/op_agent.deploy and had gone stale; updated both.

Co-Authored-By: Opal <noreply@anthropic.com>
…ct dir

ftp.uniprot.org 404s on every path now; served from the EBI mirror
instead, same as fabfos/build_references/transforms/acquire/uniref.py
already does. Dropped uniref50's memory ask to 14GB.

InterProScan's mkdir created the output slot's container path while tar
extracted into a literal `ipr_data` that was never created, so every run
failed at the extract step after a successful multi-GB download.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A given input with zero out-degree (no step consumes it, and it isn't
itself a target) added clutter with no information in it. Prune those
nodes -- and the synthetic "given" root too, if nothing is left under
it -- after the full graph is built, so a real "requires this" edge
always wins over the prune.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…used

A run's trace/log resolve through the agent's `logs.latest` symlink
until `run_number` is assigned, and that symlink still points at the
PREVIOUS run's directory until its launcher script relinks it -- so a
restart briefly painted the old run's failure onto the new one's bar.
Ignore `traceFailed` (and the "N tasks failed" banner) while `rec.state`
is `staging`/`launching`; it self-corrects once `running` lands anyway.

Separately, the workflow tab's agent picker now starts on the agent
last used anywhere (`metasmith.lastAgent` in localStorage) instead of
blank, and seeds that agent's params on load rather than only on the
select's own `onchange`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Typed into the launch panel and previously held only in page state, so
a refresh silently dropped them. Persisted to localStorage per workflow
name (`metasmith.overrides.<name>`), loaded on mount and whenever the
workflow switches, saved on every edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…effect that settles

The in-flow `.res-head` pixel-chased onto the diagram's first node was always
the redundant copy: a plan tall enough to scroll past that point had already
carried the sticky bar up to answer the same question. It goes, and with it
`HEAD_H` and the `topCy` measurement it needed. The one remaining header, the
guide lines and the rows now read a single `--res-cols` template, so they
cannot drift apart.

The row is right-justified rather than centred, so the fixed-width columns
anchor the right edge and only the diagram pans.

`seedFromAgent` reads and writes `runParams`/`seededParams`, writing a fresh
object each time, so the `$effect` that called it tracked its own writes and
re-fired forever. `untrack` keys the effect to `chosenAgent`/`agentChoice`,
which is the actual condition for re-seeding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CitDf2CY1HNbefFwufrkrh
…s, params-executor precedence, docs and tests

Ports uncommitted work from the engine/dev worktree, which was on the
same commit but ahead in working-tree state:

- downloadKofamDB.py: extracts the profiles archive instead of moving it
  raw into the slot, fixing kofamscan's "not a directory" guard.
- downloadInterProScanDB.py: runs setup.py's indexing in a subshell so
  its exit-trap marker doesn't try to write to a directory the task's
  uid can't write to.
- kofamscan.py: updated guard message to describe both producers.
- workflow_ops.py: appends an explicit `executor {}` override from
  `params.executor` after the free -b/nproc auto-detect, since the
  preset's own `executor { cpus = params.executor.cpus }` block is
  evaluated before -params-file is merged and silently never applies.
- dev/metasmith.sh, dev/fabfos.sh, docs, RELEASE_PROTOCOL.md, tests:
  carried-over fixes and doc updates from the dev-scope session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ftp.uniprot.org has gone down whole-host before (every path 404s, not
just this one) with no warning. Pinning a single replacement mirror
just resets the same fragility clock -- fall through EBI, uniprot.org,
then Expasy instead of failing the step the moment one host is down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both /log and /trace resolved through logs.latest whenever run_number
was still null (staging/staged/launching) -- a symlink shared across
every run of a task_key, so a brand-new run briefly showed the
PREVIOUS run's log lines and per-step trace rows, painting steps as
already done/failed before any real work happened.

api.py: short-circuit both endpoints with an empty/pending result the
instant run_number is None, instead of ever resolving through the
shared symlink. RunView.svelte: key the log-panel display and the
progress bar's failure check off `rec.run_number == null` rather than
enumerating state names, so this can't reopen if another pre-run state
gets added later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…en resources column

Resource overrides were keyed by step.order and persisted to
localStorage per workflow name. Regenerating a workflow (e.g. pointing
a given at a different source) can drop or add upstream steps and
renumber everything after them, so a stale numeric key silently
reattached to whichever step now sat at that position instead of the
one it was meant for -- the step that actually needed the override got
none. Key by transform name instead (a stable identifier the backend
already matched via its string-keyed `.*__{tr}` branch), and drop any
override naming no step in the current plan before sending it.

Also: "setup environment" hidden behind `{#if false}` pending real
testing -- untested feature, do not ship to release. Resources column
widened ~5% so memory-column chips don't squish, and its header
centered over the grid it names rather than sitting at its left edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Staging re-sent every registered library in full on every click -- 3 of 54
transforms, all 85 env resources -- because PrepTransfer queues self.location
and nothing narrows it. engine/gui asked for a prune. Two mechanisms were
already here for it and neither reached the staging path.

PruneTypes was broken in two ways that cancelled out. It kept a namespace only
when a *manifest entry* named one of its types, and every entry of a transform
library is `transforms::transform`, so it deleted all but that one; the
whitelist built from the transforms' own requires/produces could not rescue a
namespace, being consulted only inside the branch for namespaces that had
already survived. And _persist never unlinked while Load re-reads the whole
types/ directory, so the deletion never reached disk and the over-deletion was
invisible. Fixing either alone breaks the library. responseSurface needs four
namespaces and shipped all 28: 188 KB of type YAML for 28 KB of types.

So survival is a union now, the whitelist matches on properties rather than on
a node hash a cloned Dependency does not share, index `parents:` entries count
as used, and what the directory holds is what survives -- including files left
by an older build with a wider --types, which are namespaces nobody declared.

The views were the other half. A mask meant "the subset that matters" to the
solver and nothing else, because __getattr__ forwarded PruneTypes and
PrepTransfer straight to _original. Both are real on the view now.
PrepTransfer materializes an image and queues one transfer of it, so the ssh
executor still opens one rsync per library rather than one per file, and
WorkflowTask.SaveAs derives the mask from the plan: the steps' transforms,
plan.given -- already narrowed to the endpoints the plan consumes -- and the
ancestor closure of both, which crosses libraries because a parent entry names
the library it lives in.

The image is the tree MINUS the unused entries, not a selection of the used
ones, and that is load-bearing twice. The manifest is never narrowed because
the library key is a hash of it and that key names the staged directory,
prefixes every packed DataInstance and appears in every step's transform
reference. And `build` keeps `_`-prefixed files out of the manifest, so
_extract_noncoding_chunks.py and its two siblings -- which promotech.py and
bakta_noncoding.py copy out by __file__ -- are runtime dependencies a
selection would drop with nothing to say so.

On the annotation trio: 370 files / 824 KiB -> 41 / 104. Same task key, same
plan.Pack(), and a workflow.nf identical byte for byte modulo the bundle path.

Two things this turned up on the way.

DataTypeLibrary.Save was a truncating open(..., "w"), which writes THROUGH a
hardlink -- and an image links what it images, so pruning the copy rewrote the
original. It goes through write_yaml_atomic now, like the index, and the image
copies its own metadata rather than linking it.

And narrowing the types exposed Spec._get_endpoint resolving a target by
finding the first library holding its *namespace* and then asserting the type
was in it. That only ever worked because every library carried every type;
seven of eleven templates failed until it resolved by the type instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Killing a run left tools running on the agent. Every kill targeted a single
pid, nothing escalated past SIGTERM, an ssh disconnect half-killed a run, and
nothing could say what survived.

A run now has one identity and two groups. start.sh backgrounds the driver
under `set -m` and exports METASMITH_RUN=<key>.<timestamp>, recording both as
RUN.pgid and RUN.token beside PID.lock; the driver puts nextflow in a second
group so a cancel stops the workload and leaves the driver to snapshot logs
and promote the cache. Docker tool containers carry the token as the msm.run
label, apptainer puts it back after --cleanenv, and it survives a setsid, so
the environ scan catches anything that leaves the group.

The relay job protocol now records who asked (.owner) and for which run
(.run), before the .start rename. Its .pid holds a process group -- the
launcher runs each job under `set -m` -- and every stop path signals the
group, TERM then KILL. A job whose requester died is reclaimed by the watcher,
which is what repairs the ownership inversion the bounce creates. The watcher
setsids out of the login shell's group, so a disconnect no longer stops it,
and a shutdown that cannot kill a job keeps that job's records.

CancelWorkflow is a ladder that returns {stopped, survived, rung}; the GUI
writes `cancelled` only when nothing survived, `cancelling` otherwise.
`metasmith workflow ps|reap` are the recovery surface.

Tests: a new `lifecycle` axis (dev/metasmith.sh -tl, 13 tests) covering group
kills, relay reclaim, kill-run scoping, watcher survival across SIGHUP and the
environ-scan backstop; one fast file pinning the generated scripts; two docker
e2e tests for cancel and reap end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e the files are

Leaf identity was a blake3 digest of the file's bytes, taken on whichever
machine registered the row. For an input living on the agent's host the
client cannot read it, so every registration minted a fresh random id and
every re-submission of the same inputs re-keyed the whole cache.

An id is now multihash("stat" || abspath || mtime_ns), and StageWorkflow
re-derives every one of them before compiling -- on the agent, where the
files are, a local stat rather than anything crossing the wire. The plan
goes back to task.yml because CollectResults joins the trace against it,
and the task key is recorded there too: it names the staged directory, and
WorkflowPlan._update_hash folds the given ids in.

The trade is deliberate. Two hosts holding identical bytes at different
paths no longer agree, and an in-place edit that restores mtime is
invisible. The tests that pinned cross-location collapse now pin the same
property against one shared library, which is what a re-run actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lishes only its targets

Each of kofamscan, diamond_uniref50 and interproscan now produces two files, both
carried through the chunk/merge pair the way predictf already does it. UniRef50's
hit table gains a header and loses `stitle`, which moves to sseqid -> description;
the other two never carried a description at all and gain one from data they were
already parsing and discarding -- interproscan's `signature_desc` attribute, and
the definition column of the ko_list that transform already requires.

The consumers of the uniref50 table follow: the GPR lanes and fabfos_evidence read
the header and join the description table, and each now refuses a header it does
not recognise rather than emptying the lane. `_MB, _SB, _CB` in
metagenomics_from_paired_reads move with the targets inserted above them -- they
are positions in TARGETS, and stale they pinned checkm/gtdbtk to the wrong nodes.

`publish_intermediates` defaults to False. The filter already existed in
nextflow_codegen and nothing outside one test ever set it, so a results folder
carried every per-step output. CollectResults still registers an unpublished file,
since that manifest entry is what lets a target name it as an ancestor.

The run view's file tree moves into the results card; the side panel becomes the
selected result -- its full ancestry above the preview, read off the parent graph
the collected library re-expands on load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWL4GMBLduxY85TUM5t3V5
# Conflicts:
#	src/metasmith/models/workflow/task.py
# Conflicts:
#	src/metasmith/agents/runner.py
`_write_input` already skips rewriting an input whose content matches, because a
leaf id is the file's path and mtime and several tests here model a re-run by
calling a builder twice against one tmp_path. `_build_transform_lib` never got
that treatment: it rewrote every transform .py, transforms.yml and index.yml
unconditionally, and reached for `shutil.copy` -- not copy2, so mtime is not
preserved -- for the types file. This extends the same rule to those five writes.

It fixes no currently-failing test, and it is not a cache fix. Measured: bumping
the mtime of all nine files of a built transform library leaves the hit set
identical to baseline, as does the samples library's own _metadata and the shared
types.yml. Transform-library mtimes do not reach a cache key. The change is here
because a fixture that rewrites identical bytes to model "nothing changed" is
saying something it does not mean, in a suite where mtime is load-bearing
elsewhere -- not because anything currently depends on it.

What does reach a cache key is a leaf inside a GIVEN data library, and there one
file is enough: bump one and the hit set empties, including steps that never read
it, since the re-keyed leaf moves its consumer's key and so on down the chain.
That is `stat_multihash_key(abs_path, st_mtime_ns)` with no content fallback, and
it is a property of the identity scheme rather than of anything in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWL4GMBLduxY85TUM5t3V5
… from it

The engine declared `metasmith_libraries` as a conda run dependency and nothing
had ever published that package, so `-bd`, `-bc`, `-bs` and `-uc` all died on an
unresolvable spec -- the Dockerfile's `mamba env create` and `conda mambabuild`
read the same base.yml. Removing that one line is what unblocks them.

It goes back to being vendored, which a52d497 deleted, but not to what a52d497
deleted. That reversal rested on two things, and the change that replaced
vendoring is what dissolves both. `clone_stdlib` now copies the library into the
project and compiles it there, so nothing shipped needs compiled metadata --
which removes the compile-before-vendor ordering hazard entirely rather than
guarding it. `--no-metadata` makes that explicit on the shared verb: fabfos
still ships a compiled bundle and is still refused a hollow one, metasmith ships
content and compiles its own copy. The other reason was that `templates/` was
never in the `--src` list, so a released GUI offered none; it is in it now, and
`_assert_library_bundle` counts them.

The bundle lands at `src/metasmith/vendor/` specifically because `_build_hash.py`
walks that tree. Library content therefore moves the engine's build hash, the
wheel, the image tag and the SIF together -- measured, not assumed: editing a
transform took the hash d7898e5 -> 99cdd1f and restoring it took it back. A
library and the engine that reads it can no longer disagree about which is
which, which is the property the monorepo exists to provide and the reason the
library stops being separately versioned and published at all.

There were two resolvers, not one. `agents/conda.py` finds the `envs/` recipes a
`--runtime mamba` deployment creates its tool envs from, and it had its own copy
of the find_spec lookup; fixing only `gui/stdlib.py` would have left mamba
resolving nothing. Both call `standard_library_root()` now: vendored bundle
first, an importable `metasmith_libraries` second, which is what a source
checkout has and an installed package never needs.

Also deletes `src/metasmith/std/`. It was generated by a research script, shipped
in the wheel, the conda package, the image and the SIF, and read by nothing in
`src/` -- its only consumer reads the `research/` copy. Shipping it beside the
vendored library would have put two transform libraries in one wheel.

Verified with `metasmith_libraries` not importable at all: the bundle resolves,
clones, compiles 13 `_metadata` directories, and yields 20 data types, 11
transform libraries and all 11 templates, stamped with the engine's own version.
That case -- a wheel, a `pip install`, a mamba or native runtime -- previously
got no library by any mechanism, because conda's solve was the only thing that
could ever have delivered one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lished

The staging guard proves the bundle was in the tree; it cannot prove it reached
the artifact. This is the same three-place shape the solver engine already has,
for the same reason and with the same failure mode: an image whose bundle is
absent or empty installs, imports and runs, and the only symptom is a blank type
panel and no templates -- which nobody sees until a user opens the page.

Runs at -bs and -ud, beside _assert_engine_in_image, and asks the metasmith
inside the image to resolve its own library and count what it found.
MSM_SKIP_LIBRARY_CHECK is the shared override, the same way the two solver checks
share MSM_SKIP_SOLVER_CHECK -- they are one claim checked in more than one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and docs that no longer vendor

The executor is sized by appending a literal `executor { }` block to the config
file rather than by `params`: the preset's block is evaluated at parse time,
before the `-params-file` merge, so every caller was heard at runtime and
ignored at submit.

downloadKofamDB unpacks profiles.tar.gz with --strip-components=1 and fails on
an empty profile set instead of moving the archive into the slot;
downloadInterProScanDB runs setup.py in a subshell so the exit-code trap still
fires in a writable cwd; kofamscan's staging error names both producers.

dev/metasmith.sh --idev/--ibase reach envs/metasmith. AGENTS.md drops
--vendor-library, the consolidation followup re-points the unrecoverable-pin
entry at data/fabfos/runs/aska/gpr, and RELEASE_PROTOCOL.md carries a measured
budget for the full sweep.

Tests follow the sandbox short-circuit and the Deploy on_phase signature; the
flow fan-out budget is split from the batching assertion and xfailed; the
setup-environment tests shut off the installed-library recipe root so a staged
envs/tools cannot change the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scope's own commit (8b6a796) was already contained in release verbatim, so
the merge resolves entirely to release's side: the merged tree is byte-identical
to release. The one conflict, in workflow_ops.py, was release adding the local
executor's free -b/nproc auto-detect ahead of the params-driven executor block
both sides already carried.
phy0x1a79ed and others added 28 commits September 11, 2026 03:09
The last untested code in the MAG lane has run. On 84,992 contigs of a 2019
groundwater assembly and ten million read pairs, the three binners give 52, 143
and 87 bins, the refinement scores all seven combinations plus the merged set,
and 34 bins survive at 50% completion and 10% contamination. The best is 95.79%
complete at 1.09% contamination.

That exercises the three things the twelve-contig test never reached: the
fan-out wrote 34 bin products, the contig-to-bin table has 6,612 rows across 34
distinct bins, and the statistics table carries one row per bin with the binner
each came from. MetaWRAP confirms the memory fix in its own words -- "There is 40
RAM and 12 threads available ... use 1 threads for pplacer" -- where before it
would have said zero.

The host database fetch needed --http1.1. Over HTTP/2 the portal drops the
stream part way into a 10 GiB chunk with INTERNAL_ERROR, every time, and six
array tasks each burnt 45 minutes on it. On 1.1 the transfer is still cut but
resumes, so the retry loop accumulates rather than restarting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes, each paid for by a run that failed without it.

`_stable_id` + `RegisterItem` pin the given instances' leaf ids. `AddItem`
stats the file to mint one and returns a fresh uuid4 when it cannot, which
is every input here -- the reads and the reference databases live on fir
while this driver runs on a workstation. The plan key hashes those ids, so
two identical submissions minutes apart keyed differently and `-resume`
never once fired. Measured `daqL9cFU` then `WwhBaN3k` before, one key after.

`ref::kofamscan_profiles` now points at the unpacked `profiles/` rather than
the `profiles.tgz` beside it. The transform asserts the staged path is a
directory, so every chunk failed instantly: 135 chunks, four tries, 540
FAILED rows reading as a dead tool. After the fix, 1,493 chunks and zero
failures.

comebin's allocation is 48 cores with a 3d wall, and `--comebin-device`
selects the lane. Measured on ten marine samples: 25.0 s an epoch on a 20 GB
MIG slice against 110.6 at 96 cores and 145 at 64, for a total of 2 h 20 m
against 6 h 28 m to 10 h 07 m. Under the old 8 h wall the CPU lane reached
191, 195 and 28 epochs of 200 across three attempts and never finished.

The comments carry the numbers and the one defect this does not fix: the
Leiden sweep forks a pool from a loky-threaded parent and hung on 2 of 10
samples at 96 cores, each burning the full wall.

Also fixes verify.sbatch resolving its helper through `$(dirname "$0")`,
which SLURM breaks by copying the batch script to a per-job spool dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZhY5iQVRrEHjbnsSWFzch
Brings `_stable_id` + `RegisterItem`, the kofamscan profiles path, and
comebin's device flags into the campaign branch. The two branches were
separate lineages, one commit against 27, which is why the engine lane's
merge survey missed this side.

`verify.sbatch` resolved to ours. Both sides fix the same `$(dirname "$0")`
defect that Slurm's spool copy creates; ours keeps the `VERIFY_PY` override
in front of it, so it is a superset rather than a competing fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZvtnGpGTeQ6JA54Meycr5
An untracked file in the tree blocks the aggregation merge, and this one is
worth keeping rather than ignoring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one commit release holds that feat/dev lacks. Cutting 0.23 without it
regresses the image back to building from a source distribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the caching store the rest of this aggregation builds on. An entry
says what made it, when it arrived and what it was called; admission is the
single write door; the pool projects into a data instance library. A direct
run binds items from a library and refuses a filesystem path, which is the
rule givens are about to adopt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three branches grew the direct-run model in parallel. The binding model is
direct-run's: a transform is located by the path of its .py, inputs name items
in a data instance library, and a filesystem path is refused. The viromics lane
brought the pieces that made collecting transforms runnable -- a slot-channel
map, a provenance union so SourceOf answers, and caller-stated cpus/memory/
attempt -- and those are re-fitted onto the binding API rather than merged
textually.

Two things from the viromics side are deliberately not taken. `host` was
declared and immediately discarded, so it is noise on the signature until relay
bounce exists. The agent stub is dropped: a transform runs on an agent's
runtime, and inventing one from the ambient environment hides a missing deploy
behind a run that does the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ispatch

KBase collapsed ExecWithEnv's container and virtual-env arms into one call and
added a scan that refuses the old names, because a body that still splits its
command per runtime is an unmigrated body. Nineteen transforms in the viromics
and metagenomics lanes were written against the old shape while that landed.
Every one of them passed the same command to both arms, so the collapse is
mechanical and loses nothing; gtdbtk's container-only binds carry over, because
the entry point drops mounts where there is no mount namespace to put them in.

Three content disagreements resolved by intent rather than by text. The lineage
stamp keeps KBase's comment, which says why an entry needs a KEY at all, and the
viromics provenance union and slot-channel map are additive on top. The
dereplicated MAG reference takes KBase's note: round 6 gave it a producer, so
the viromics "built once outside the DAG" reading is out of date. The metabuli
database keeps the viromics version, which pins a release, flattens a nesting
that moves between releases, and asserts the extracted database rather than the
command's exit code -- a campaign lost a day to the version that did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ransform

PruneTypes kept a type only when its property set equalled a declared type's,
which dropped every ancestor -- so a supertype target resolved to nothing unless
some unrelated transform happened to declare that ancestor by hand. Subset is
the right test, because a type is satisfied by any type holding at least its
properties.

The two SRA transforms fuse into one. Both were modified here only by the
dispatch migration, so the deletion is taken as it stands and the replacement is
migrated the same way. Its memory declaration drops from 64 GB to 8 GB, which is
the honest number for a download bounded by disk and network -- worth knowing on
a box whose executor ceiling refuses a step that asks for more than it has.

The aggregator reached the same per-binner gtdbtk declarations on both lanes.
One comment, keeping the failure this branch observed and the precedent the
other names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gain

Cherry-picked from the bake lane, where it blocked a reference build. The cache
pass rewrites each instance's id into its cache slot id after the plan the agent
reloads was serialised, so a step whose input came from another step in the same
plan looks its inputs up in the wrong id space and raises. The produces path had
always fallen back to the step's own dependency map for the mirror-image
mismatch; the requires path now does the same, one line away.

The conflict was an add-add at one seam: this branch grew a cache-promote helper
where the fix adds its resolver. Both are kept. The five tests that came with it
pass against the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`spades.py` was a hand-rolled metaSPAdes call. It now runs the DOE JGI
Metagenome Workflow's assembly step, so a pipeline built on it is citable to
a published protocol rather than defended: bbcms at mincount=2 and
highcountfraction=0.6, then `--only-assembler -k 33,55,77,99,127`, then a
200 bp floor over the contigs product. Each step carries the protocol's own
sentence verbatim in the comment above it.

One spades, not two. A sibling transform with its own product types was
written first and thrown away: it avoided disturbing anything, but a third
`assembly` subtype buys nothing the library mask does not already have to do,
and every extra matched property set is another chance to re-route something.

Three invariants survive unchanged, and the comments explaining them with
them. `-m` is a hard setrlimit at 95%, not a hint. `OMP_NUM_THREADS` is
re-exported inside each command, now bbcms's too, because every runtime here
pins it to 1 and SPAdes silently honours that over `-t`. All three products
ship, with only the contigs length-filtered, because the graph's path names
are scaffold names that a filtered contig set would no longer index against.

TWO THINGS THIS BREAKS, both recorded in REFERENCES.md.

The template fingerprint reports no difference. The transform's name and its
product types are unchanged, so the tool sees nothing -- it catches a
re-route, not a behaviour change inside a transform. Identical output from it
is not evidence that the library is unchanged.

Every shard `spades.py` wrote is retired, since a transform's identity hashes
its whole source file. And every template naming `spades_assembly` now
assembles under this protocol, `ecspr_survey_from_pooled_reads` included.
That template was written against the old behaviour; reconciling it is
deliberately out of scope.

REFERENCES.md is new: one entry per tool with its version, the parameters it
actually runs, its citation, and its deviations stated rather than hidden.
The JGI paper is a workflow description, not a benchmark -- it names both
metaSPAdes and MEGAHIT and compares neither -- so it supports "this is the
DOE JGI reference workflow" and nothing stronger.

`cache_probe.py` is the harness for a measurement not yet taken: whether a
member key survives a change in plan shape, not merely a re-submission of the
same plan. Two arms over one sample differing only at the binner. It waits on
a pinned engine, because a number about a tree nobody ships is not worth the
cluster time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZvtnGpGTeQ6JA54Meycr5
The assembly follows the DOE JGI Metagenome Workflow rather than a hand-rolled
call: bbcms error correction first, then --only-assembler with fixed kmers, then
a 200 bp contig floor over the contigs product alone. REFERENCES.md states each
tool's version, the parameters it actually runs, and its deviations.

Written against the two-arm dispatch, which this branch had already collapsed.
All three calls pass the same command to both arms, so the same mechanical
collapse applies and nothing about the commands changes.

Landed before the campaign pin on purpose. A transform's identity hashes its
whole source file, so this retires every shard spades wrote. Once is the cost of
the change; landing it after the pin would charge it a second time, mid-campaign,
where partial reuse reads as a broken cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viromics lane gave every input the union of every input's index, so
`SourceOf` could answer where a library recorded nothing. It also overwrote
ancestry that WAS recorded: two metadata files and two read files, each read
parented to its own metadata, came out with every read descending from both --
`AmbiguousProvenance` instead of an answer, and the direct-run lane's test for
exactly that case failed on the merged tree.

`given_index` already carries the one hop of parents the data library knows
about, which is a statement about these files rather than an inference from
their being in one run. So it wins whenever there is any, and the union is the
fallback for a library that records no parents at all.

The choice is made once for the whole entry, never per item. A root item has a
one-key index by definition, so a per-item fallback hands the union to exactly
the items whose ancestry matters most, and two roots each claiming to parent
every descendant is the ambiguity the recorded ancestry existed to prevent. That
version was written and it failed the same test.

Both provenance models now hold: 16 of 16 direct-run tests pass under Docker,
which is the check `research/viromics/implementation_handoff.md` records as
impossible to run at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cherry-picked from the viromics lane. `RenderLauncher` gains `background=False`
and an explicit workdir, so a caller that is itself already a durable host for
the run -- a Slurm batch job's own script -- runs the driver in the foreground
and lets the job's wall keep it alive. `METASMITH_DRIVER_SLURM=1` selects it.

Verified rather than taken on report: the default branch is textually the code
it replaced, and both renders come out at 24 lines with the right markers on
each side. The foreground variant writes RUN.pgid before the run, not after,
which matters because there is no backgrounding step to hand back a `$!` and
the recovery path reads that file.

The argument for it does not rest on a mechanism. Why one 29-hour JVM died is
unknown -- the cgroup's oom_kill counter is zero and the memory that looked
alarming was reclaimable page cache. What is true without a cause is that a
long-lived driver on a login node has no supervision and no record, and a
driver in a Slurm job has both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ridge

AMBER is the scorer the CAMI challenge uses. Making it a transform rather
than a script beside the run means the published ground truth is a declared
typed input, the score is a declared product, and provenance, caching and the
DAG cover it like anything else. A sample that cannot be scored now fails at
solve time rather than after three days.

THE BRIDGE, WHICH IS THE WHOLE REASON THIS IS NOT ONE TRANSFORM

CAMI's published `binning_gs.tsv` keys on the CAMI-provided gold-standard
assembly's own contig ids -- `S0C878739`. Our assemblies emit `k141_*` or
`NODE_*`. The two share no id space, so pointing a scorer at the published
gold standard joins on nothing and returns a number that is not wrong so much
as meaningless. Verified against the real file on fir, not inferred.

`cami_contig_truth.py` builds the gold standard AMBER actually needs: for each
of OUR contigs, majority-vote the true genome of the reads the BAM mapped
there, using CAMISIM's per-read truth table that sits beside the reads. The
vote is in `resources/lib/cami_gold_standard.py`.

`amber.py` requires the shared `contig_to_bin_table` supertype, so one
transform matches all three binners. Its label comes from the BOUND instance's
type rather than the requirement's, which is what keeps three binners writing
to three directories instead of colliding in one.

THREE THINGS REVIEW CAUGHT, ALL IN THE WIRING RATHER THAN THE TRANSFORMS

The driver never gave `resources/lib`, only `resources/env`, so
`lib::cami_gold_standard.py` had no producer and the chain was unsatisfiable.
The planner does not report that. It explores the whole library and then
blames every unrelated target, dead-ending at `ncbi::genome_name` and
`sequences::background_genome` -- a driver that reads as comprehensively
broken when one resource library is missing. The fix is one `Load`.

Amber was targeted once, pinned to the assembly, with a comment claiming it
would fan out per binner. It does not. A target is a slot the planner
satisfies once, so that scored ONE binner: a solve that succeeds and silently
answers a third of the question. Now pinned per binner's own table, exactly as
`checkm_stats` already was in this same file, and the solve shows three.

`amber_bin_metrics` was targeted alongside `amber_results`. Redundant -- amber
emits both products in one step -- and a target costs a slot whether or not it
buys anything.

Bisected rather than guessed: transforms, types and the new given alone leave
the plan byte-identical at 22 steps and key q4QR3rmL. The targets were the
whole difference. With the resource library given and amber pinned per binner:
26 steps, key hTsuobCM, three amber steps beside three checkm.

The two Pratama recovery transforms are designed and implemented but have
never run against real data, because our own dereplicated MAG and vOTU sets do
not exist yet. Their command shapes were checked against a real skani binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZvtnGpGTeQ6JA54Meycr5
…pe change

Added `--assembler` so the harness can drive the JGI-protocol spades route as
well as megahit. That was for calibration, but it answered the cache question
for free on the way past.

Two runs, different assemblers, different plan keys: `aNmwq814` (megahit) then
`9SsVv8oh` (spades). Both steps above the assembler hit the first run's shards.

    seqkit_reads   1m 07s paid  ->  36ms
    bbduk          1m 57s paid  ->  45.6s

So a member key does survive a change in plan SHAPE, not merely a
re-submission of the same plan, which is the property the campaign's reuse
measurement rests on. It cost no cluster time of its own.

Note the bbduk hit is 45.6s rather than milliseconds. A hit still copies the
shard's outputs into a task work dir, and bbduk's are gigabytes of cleaned
reads -- and metasmith is forcing publish-by-copy here because it reads one
Lustre directory bind-mounted twice as two mounts. Reuse is near-free in
compute and not free in I/O. Report it that way.

This is on the pre-pin tree. It re-runs against the pinned engine, where it
also serves as a check that the aggregation did not change plan shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZvtnGpGTeQ6JA54Meycr5
Four transforms, their types and an environment, reviewed on the viromics lane
before landing. The bridge exists because CAMI's published bin assignments key
on CAMISIM's own gold-standard contig ids while our assemblies emit k141_* or
NODE_*: the two share no id space, so a scorer pointed straight at the published
standard joins on nothing and returns a number that looks like an answer. The
truth transform majority-votes our contigs' reads against CAMISIM's read mapping
instead.

Lands after the campaign pin at 34401df on purpose. It adds transforms rather
than changing existing ones, so it retires no shard the pin did not already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four came from driving the aggregated engine at campaign scale, and none is
reachable from the test suite. Recorded now rather than at the end of the plan,
because each carries a measurement that would not survive being remembered.

The first is the one that reaches the caching architecture: a shard's manifest
attests that a file exists, never that it is whole, so a product with unflushed
holes was promoted and would have been served as a hit. The others are a missing
version lock between a library and the agent image, a planner that reports an
unsatisfiable requirement against every target except the one that caused it,
and a publish path that copies gigabytes because it reads one bind-mounted
directory as two filesystems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pool minted an import's id from the type and the name the caller
spelled, so importing one path twice was one entry and two physically
separate files handed one name collapsed into one. Neither is something
the pool can know: a declaration is not a statement about bytes, and
reading the bytes is the cost an import exists to avoid -- a reference is
17 GB and a folder is six hundred thousand files.

So the question changes from "what is this data" to "which act put it
here", which the pool observes rather than infers. `mint_import_id`
digests the declaration with a nonce, keeping the multihash shape and a
real preimage. Two acts are two entries whatever they are called, and
re-importing a path is how a caller says this declaration is a new thing.

What that costs, recorded beside the call: an assigned identity cannot be
recomputed. The pool is authoritative state rather than a cache of a
calculation, and every shard keyed on an import dies with the pool that
holds it.

Two things follow from the id no longer carrying the declaration. The
manifest records the name, because nothing else does and a reference by
name has to match on something. It also records the arrival time in
nanoseconds, because the store's `created_at` is whole seconds and two
imports of one path inside one second are ordinary -- the projection and
the path index both need to say which came second, and both now let the
newest claim win rather than the first. The displaced entry stays in the
store and stays reachable by id, so shards keyed on it are still valid.

The cache epoch does not move. An import's key was already outside it and
still is: a product is re-derivable and a bump may strand it, but an
import cannot be re-derived at all, so an id that moved with the epoch
would strand what may be the user's only copy on every bump.

fabfos's pinned references keep their derived identity, deliberately.
A DVC pin is an md5 DVC computed over the real bytes, so the name there
IS a content address and every host that checks out the pin agrees on it
-- a stronger claim than assignment, not a weaker one. The engine's old
function is renamed `pinned_import_id` with its payload frozen, which is
why `test_refs_pin.py`'s literals did not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A campaign's pool lives in an agent home on a cluster, and the client that
builds the plan has no mount there. It never needed one: a given is a
name, a type and an assigned identity, and all three are records the pool
keeps. Nothing has to be derived from bytes the client cannot open, which
is what the identity change bought.

`Agent.ReadPool` asks the agent's own metasmith what it holds and reads
the answer. One ssh command, the same transport as every other remote
read here, running the `msm` wrapper the deploy already writes -- so the
pool is read by the engine that owns it and this end parses JSON rather
than a database file. The agent's setup commands go first, because a
cluster reaches its container runtime through a module load and a
non-login ssh command inherits none of it. The wrapper prints its bind
list and the runtime prints its own noise, so the answer is taken as the
first well-formed JSON value rather than as the whole of stdout.

`ResolvePoolRefs` turns names into identities. A name the pool has never
seen is refused by naming the import call, because there is nothing this
end could mint for it. A name two imports share is refused by listing
both ids: re-importing under a name is exactly how a caller says the
second is a different thing, so the pool holding both is expected and
choosing between them is not this end's call.

A home on this host skips the shell entirely and reads in process.

`cache list` gains a `--name` filter and the rows carry the name, which
is the only field that tells two imports of one path apart now that the
key no longer carries the declaration. `metasmith agent pool` is the
command that exercises all of it.

The tests report paths that exist on no filesystem this process can
reach. A read that starts passing because a path happened to resolve is
the regression they are watching for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the campaign: the overlay carried a 229-sample run against an image
two API-breaking commits behind, and every user of it still hand-rolls
the rsync and the tar that populate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Agent.GivenLibrary` turns pool references into a data instance library
whose items carry the pool's identities. The pool already projected into
one and the planner already took that projection unchanged, so this is a
reference-resolution step in front of something that fits rather than a
new layer. Nothing reads the data: the paths are the agent's, the types
come from the libraries the caller attaches, and the ancestry is the edges
the pool recorded.

The measurement this exists for, now a test: two builds from one reference
plan to one key, and touching the file does not move it. The identity was
assigned at import and is a record from then on, so the only thing that
moves the key is the plan's shape. Before this, a given the client could
not stat took a fresh uuid4 per call, and a live campaign measured
`daqL9cFU` then `WwhBaN3k` from back-to-back dry runs of one driver --
which renamed the run directory and discarded `-resume`. The opposite
direction is a test too: re-importing under a new name does move the key,
because that is a caller saying this is a different thing.

Cache rows now carry an entry's ancestry as identities, and its lineage
payload where there is one, so a reader holding the rows can rebuild the
parent edges without the store. An edge to an entry not among the
references is dropped rather than written, because `Unpack` walks a parent
by path and would walk into nothing.

An import into a pool under a swept path now says so, once, at the act
that first gives the pool something to lose. An assigned identity cannot
be rebuilt, so a scratch filesystem deleting on age since creation has a
delete scheduled against the meaning of every shard keyed on an import
there. The campaign's own agent home is such a path. This warns and does
not refuse: where an agent home lives is the operator's call, and the
point is that the call is made knowingly rather than discovered sixty days
later with nothing to diagnose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A given's identity has to be a record. Registering a path mints one from the
filesystem this process can see: it moves when the file is touched, and it is
invented outright when the path belongs to another host, which is every input
of a run driven from a workstation. The plan key hashes the givens' ids and the
run directory is named after the key, so an invented identity discarded
-resume on every submission.

`_mint_leaf_id` now marks what it invented, and `CollectSolverInputs` refuses
what carries the mark, naming `metasmith data import` and the `GivenLibrary`
call that replaces it. `Pack` does not carry the mark, so the question survives
exactly one process: a library saved and loaded again is a record.

The test is a mint rather than a pool, and that is the limit of this refusal.
A shipped template, the env and container libraries and a DVC-pinned reference
all carry recorded leaf ids, and none of them is data a pool should hold, so
"imported or nothing" would refuse three categories that are right. What no
longer passes is an identity nobody wrote down.

An invalidate and a fork clear the mark after moving an id. Both act on a
record the library already held rather than inventing one, and an invalidate
has already refused every path it could not stat. An invalidate now skips an
imported item and says to import it again, which is what moves an assigned
identity, and a test pins that path.

A deferred given is exempt. There is nothing at the path yet, so there is
nothing to import, and `StageWorkflow.RefuseIfDeferred` is what catches one
that never got a source. Direct-run's refusal names the import too, so the
rule is stated one way.

The suite's fixtures import what they build. `metasmith.testing.pool_backed`
imports a hand-built library into a pool beside it and takes the identities
back, referencing an entry the pool already holds under the same name, which
is what lets a fixture be built twice and plan to one key. Three files about
leaf identity itself keep their leaves by saving and loading instead.

unit, flow, solver, cache, bootstrap, audit and gui: 2025 passed, 6 skipped,
5 xfailed, 0 failed.

Also records the campaign's report that a driver placed on a compute node by
METASMITH_DRIVER_SLURM finds no relay there and submits nothing, without
failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AddItem` did two jobs at once: it declared what a file was, and it minted an
identity for that file from a filesystem the driver often could not see. The
two are now separate. A driver says what it has and what to call it, the pool
assigns the identity once, and every run after that cites the name.

`Agent.PoolGivens()` is the collector a driver writes instead. `Add` declares
something already on the agent's host and returns the name, so a name is what
`parents` takes and an input-building function keeps its shape, the same
variables threading the same edges. `Value` is the counterpart of `AddValue`
for a small document the driver authors, minus its defect: that one wrote the
file and then read back the mtime it had just created, so identical content
was handed a new identity every call. `Build` imports whatever the pool lacks
and cites all of it, and `ensure=False` cites only -- so a run that is not
doing setup fails by name rather than importing as a side effect.

Three transports underneath, each the write counterpart of the read path.
`ImportToPool` runs the agent's own `data import`. `EnsurePoolEntries` reads
the pool once and imports only the names it does not hold, which is what keeps
a plan built today keying like the one built last week. `WriteImportable` puts
an authored document on the agent, by base64 rather than a quoted heredoc,
because the content is arbitrary and the command crosses a shell, a login
profile and possibly a container.

The CAMI campaign's driver loses `_stable_id` entirely. That workaround
supplied an identity by hand because nothing else would hold one; the pool
holds one now. `import` is its own subcommand, and `run --import-inputs` is the
same act inline.

fabfos's three pipelines move with it. `stage_ref` names a reference by its
type, so one pipeline has one pool entry for a 17 GB database however many
times it runs, and a stub gets a name that says it is a stub -- planning
against a stub and then against the real thing must not read as one reference
that moved. Copy staging and reference staging now reach the same entry rather
than forking the campaign in two.

fabfos: 64 passed. metasmith unit and the two pool axes: 686 passed, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three groups, and which group a driver is in is a decision rather than a
detail.

**On the pool.** The CAMI campaign driver, fabfos's three shipped pipelines
and four cluster drivers declare their givens and let the pool assign the
identities. Every one of them registers a path this host cannot stat, which is
the case the whole change exists for: `AddItem` answered that with a fresh
uuid4 per call, so two identical submissions planned to different keys and
-resume found nothing.

**Written down instead.** Template authoring, the DAG renderers, the figure
scripts and the KBase parity probe build synthetic placeholders that nothing
will ever cite again. `record_library` writes such a library down and reads it
back, which is what makes its ids a record rather than this process's
invention. Its docstring says plainly that it is wrong for a campaign's real
inputs, because it will not stop the ids moving the next time the declaration
is rebuilt.

**Left on the pin they already had.** The aspire r1 campaign and nine fabfos
benchmark drivers each wrote their identities into a file of their own. Moving
them to the pool would move every id and strand the shards those completed
benchmarks earned, so they stay. The shared pin now covers every given rather
than only the ones the client cannot see -- leaving the stattable ones alone
left half of each task key still moving -- and its docstring names it as the
pre-pool workaround and points new work at `Agent.PoolGivens`.

Tests for what this touches: the fabfos driver axes, 13 passed; templates, the
two pool axes and the remote pool read, 54 passed. The wider tiers were green
on the previous commit and nothing here reaches them.

Also records the wart this leaves: a driver that overrides identity through
the pipelines' `on_inputs` hook still causes the import to happen, so the
pool collects entries no plan ever cites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the Data tab could not do.

**A pool on another host was refused rather than read.** The tab checked the
agent's home type and told you to run the commands there. It no longer looks:
every store route hands to the agent, which runs its own metasmith over one ssh
command and answers in JSON. Listing, importing, tagging and forgetting all
work against a cluster home now, and a home on this machine takes the same path
in process. An unreachable host is reported as a failed read naming the pool
and the host, which is a different complaint from "we will not look".

**A plan's inputs could not cite the pool.** An input row gains a third mode
beside file and value: `pool`, carrying whose pool and the name the import
recorded. `sync` resolves it and registers the entry with the identity the
import assigned, so the plan key does not move between one solve and the next
and this machine never has to stat the file.

The row does not carry a type. The import is what said what the data is, and a
row disagreeing with it is a row about a different thing, so the pool's
declaration wins and a row that states one is corrected. A pool row binds no
sheet column either: a sheet expands a row over paths, and this one names an
identity.

The resolver is passed in rather than reached for. `ops/inputs` knows nothing
about which agents a project has, so the caller supplies the reader, and a row
with no reader is refused rather than quietly registered as a plain path --
which would mint a second identity for something the pool has already
identified. One read per agent for the length of a sync: fifty samples out of
one pool is one question.

gui, the given refusal and the remote pool read: 417 passed. The frontend
bundle builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes, two of them to paragraphs that were actively wrong.

The caution about a staged given's id folding the task key described a
mechanism 0.22.1 removed, and it cost a campaign a wrong diagnosis before
anyone noticed. It is replaced by the rule as it stands, including where the
rule stops: the test is whether this process invented the identity, not
whether a pool holds it, because a shipped template's placeholders, the env
and container libraries and a DVC-pinned reference all carry recorded leaf ids
and none of them is data a pool should hold.

The import paragraph named `structural_import_id`, which no longer exists. It
now carries the reversal and the argument for it -- a derived id answered
"what is this data" with what the caller spelled, and the question the pool can
actually observe is "which act put this here" -- plus the two things the
manifest has to record once the id stops carrying the declaration.

The pool's lifetime is written where an operator meets it, beside the agent
home. Reuse does not cross a campaign, shards die with the pool, and a
two-batch comparison has to finish inside the retention window. The failure is
silent and arrives sixty days after the placement that caused it.

The declined epoch bump is recorded beside the constant, with the campaign's
argument rather than an abstract one: every affected key moves on its own
arithmetic, so a bump protects against nothing and would delete the
measurement two pinned batches exist to produce. Say so before a future bump,
not after.

Two register entries change rather than close. Mtime sensitivity is closed for
givens and still open for a transform's own outputs. The stale directory hit
survives in a new form -- an import records a declaration and never re-reads
the data, which is what makes citing a 17 GB reference free -- so the entry now
asks for a change detector rather than for a re-read.

The user-facing data page gains the rule it was missing: registering here does
not make a given, and the import call is what does.

flow and cache: 350 passed, 3 skipped, 4 xfailed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@phy0x1a79ed phy0x1a79ed changed the title Release 0.22.1 Release 0.23.0 Sep 12, 2026
phy0x1a79ed added a commit to phy0x1a79ed/Metasmith that referenced this pull request Sep 12, 2026
The publish section claimed there is no standing PR to reuse, because each one
closes on merge. That holds only for a PR a maintainer actually merged. hallamlab#68 was
opened for 0.22.1, never merged, and was still open when 0.23.0 came to be
published -- and GitHub refuses a second pull request for the same head and base,
so the new commits simply moved the open one.

The instruction now says to look before opening, and what to do with what is
already there: retitle it, rewrite the body for the version now shipping, and say
which earlier version it still carries. The false sentence is gone rather than
annotated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants