Offer a single SWE node, and let a manifest declare itself superseded - #864
Conversation
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
santoshkumarradha
left a comment
There was a problem hiding this comment.
Thanks for tightening the catalog and docs. I don’t think we should merge this as-is because it knowingly strands existing desktop users who already installed : after they upgrade, the app loses the update/uninstall path for that node. Please add a migration path or compatibility handling for the old install before we collapse the catalog entry.
santoshkumarradha
left a comment
There was a problem hiding this comment.
Thanks for tightening the catalog and docs. I do not think we should merge this as-is because it knowingly strands existing desktop users who already installed swe-planner: after they upgrade, the app loses the update/uninstall path for that node. Please add a migration path or compatibility handling for the old install before we collapse the catalog entry.
`af catalog` listed the SWE fleet twice — a root Python node and its Go counterpart — which forced a harness to pick between two rows that ship the same reasoners. Keep only the Go node (installed via the `//go` source selector) and give it the full fleet description. Tighten the pretty-output assertion to `swe-planner-go` (the old `swe-planner` substring matched either row) and add a guard test that pins the invariant: exactly one entry installs from Agent-Field/SWE-AF, its source ends in `//go`, and no entry is named exactly `swe-planner`, so a re-added root entry fails loudly instead of quietly reappearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors the `af catalog` change: the Install view listed the SWE fleet twice with identical copy, so the two rows were indistinguishable to a user. Keep only the Go node sourced from `//go`, with the same description wording the CLI catalog now uses. Add a test pinning the invariant — `swe-planner-go` is present, its source ends in `//go`, exactly one entry installs from Agent-Field/SWE-AF, and no entry is named exactly `swe-planner`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The catalog now offers the SWE fleet as a single Go node, so the docs that still told users to install the bare repo root and call `swe-planner` were advertising a node the catalog no longer lists. Update the README quickstart to `af install …/SWE-AF//go` plus `af run`/`af call swe-planner-go`, and switch the MCP example flow and the agentfield-use skill examples to the same node id. The `--path go` examples in installing-agent-nodes.md stay as they are — they document the subdirectory selector itself; only the surrounding framing is reworded so the Go node reads as the advertised install rather than a port of the root node. The skill edit is applied identically to the embedded copy under internal/skillkit/skill_data so the two stay byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The catalog collapse rewrote this card's copy to match the CLI catalog's phrasing, which reads out of place next to the other entries in this file. Restore the original line — it describes the surviving Go node just as accurately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A subdirectory can reach the installer two ways: the `//subdir` selector on the URL, or the --path flag. The install API takes the second route — it splits the selector off the URL and passes it as an option — so info.URL arrives bare and the registry records the REPO ROOT as the source. The next update resolves that bare source and installs whatever manifest lives at the repo root, which is a different package than the one installed. For a repo shipping a Python root and a Go port side by side, updating the Go node silently replaces it with the Python one. Put the selector back when it came from the flag, so the recorded source round-trips through ParseGitURL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ackage
A node author who renames or replaces their own node has no way to carry
existing users across: `af install <their url>` keeps installing the old
package forever, because the manifest at that source is the only thing the
installer looks at.
Add an optional `superseded_by:` key naming an installable source. Installing
a superseded package installs the successor instead, and replaces the old one
when it is already present. The redirect lives in the package's own manifest,
so the control plane needs no knowledge of any particular node — any author
gets this, and no catalog or table here has to name them.
Ordering and safety:
- The successor is installed FIRST; only then is the old package retired, so
a failed install leaves the user's existing node exactly as it was.
- The redirect is taken before the force check and before anything is
copied, so it never half-installs the package it redirects away from.
- Node-scoped secrets move to the successor before the old package is
uninstalled, which would otherwise delete that scope outright. Values
already set on the successor win. Global secrets are shared and untouched.
- Retiring the old package never fails the install: the successor is already
working, so a leftover is a cleanup chore, not a failure.
- A chain is bounded at 3 hops so two manifests pointing at each other fail
loudly instead of cloning forever.
The user is warned before the swap, naming what will be replaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e fix
One test per behaviour, driven through the real InstallFromGit against the
existing fake-git harness rather than against the internals:
- a superseded package installs its successor, and its own name never
reaches the registry
- an already-installed superseded package is replaced: successor present,
old entry and old package directory gone
- node-scoped secrets follow the swap, a value already set on the successor
wins, and global secrets are untouched
- with nothing to replace it is a plain install, no error
- two manifests pointing at each other fail with a bounded-chain error and
install nothing
- a recorded --path source round-trips through ParseGitURL back to the same
repo AND subdir
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239e1c1 to
7c43de9
Compare
A node that renames itself takes the name its predecessor held, and `superseded_by` could not express that: the redirect installed the successor without carrying the user's consent to replace, so the successor's own force check rejected it with "already installed (use --force to reinstall)". The redirect has already printed an explicit replacement warning by then, so it now carries that consent through. Same name means there is nothing to retire afterwards and node-scoped secrets are already in the right scope, both of which the existing short-circuit handles. That makes the failure mode worse, though, and this fixes it too: copyPackage clears the destination before the replacement is copied, and long before its dependencies build. A replace that dies in the dependency step — a missing toolchain is enough — used to leave the user with neither the package they had nor a working new one. The existing directory is now set aside first and put back on any failure before the registry is updated, which also covers a plain `af install --force` on any package. Also restores the doc comment on updateRegistryWithGit, which an earlier commit in this branch left attached to the wrong function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog named it swe-planner-go, after the implementation. That was only ever a workaround for the two SWE manifests needing distinct registry keys, and the node now declares itself swe-planner — a name that survives the implementation changing under it, and the one its triggers already use. Catalog `name` must equal the manifest `name`, so this follows rather than leads. The install command in the README drops the `//go` selector too: the root manifest redirects, so the bare repo URL is the whole instruction. Both catalog tests keep their guard against a second SWE row, and now pin the surviving row's name rather than merely asserting the Python one is absent — the assertion that would have caught this rename going half-done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The key shipped undocumented, which defeats the point of making it generic — a node author cannot use a manifest field they cannot find. Documents what it accepts, and the ordering guarantees that make a redirect safe to run against an installed node: resolved before anything is copied, successor installed first, node-scoped secrets carried across, retiring never fails the install, chains bounded. Also corrects the claim just above it that a root node and a `--path` node from one repo always coexist. They coexist when their names differ, and replace each other when they do not — which is exactly what SWE-AF, the example named there, now does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(packages): installing this repo gives you the Go node, as pr-af
The Go node is the maintained PR review node — same reasoners, same
interface, one static binary, no per-node venv to build. But it lived in
`go/` under the name `pr-af-go`, so getting it meant knowing to type
`//go`, and anyone who had already installed the Python `pr-af` stayed on
it forever.
Two changes make the bare repo URL the whole story. The root manifest
carries a redirect:
superseded_by: https://github.com/Agent-Field/pr-af//go
and the Go node drops its `-go` suffix to take the product's name. The
suffix existed so the port could run beside the Python node during the
migration; it is not something a user should ever have had to type.
Since both manifests now declare `pr-af`, `af install
https://github.com/Agent-Field/pr-af` replaces an existing install in
place: same node id, same triggers, node-scoped secrets untouched because
the scope name never changes. `pr-af.review` means the same thing before
and after, whichever implementation is serving it.
The mechanism behind the redirect is a generic manifest key in AgentField
(Agent-Field/agentfield#864) — including the part that lets a successor
share its predecessor's name, which is what an in-place rename needs. This
repo only supplies the data.
The Python package is untouched and still what `python -m pr_af.app` and
docker-compose.yml run. Because the two now answer to the same ids,
running both against one control plane needs an explicit NODE_ID on one of
them — docker-compose.go.yml does exactly that, and is now the only place
carrying `-go` ids. To install the Python node deliberately, clone the
repo and install the checkout as a local path; local-path installs do not
follow the redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(go): the node's own identity is pr-af, not pr-af-go
Follows the manifest rename through the code that carries the node's
runtime identity: the NODE_ID default, the port doc comments, the package
docs, and every test that asserted the old default. Callers now reach
`pr-af.review`, matching what the manifest registers.
The 16 semantic role tags are unchanged — they were never node-identity
tags, and the comment that explained the distinction by contrast with
SWE-AF's `-go` role tags no longer has anything to contrast with.
`-go` survives in exactly one place: docker-compose.go.yml, which runs the
Go node beside the Python one against a single control plane and therefore
has to override NODE_ID to keep the two distinguishable. The functional
compose stack does not — it runs the Go node alone, so it uses the real id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: the install story is the bare repo URL
Both READMEs still told the reader the Python node was the default and
that the Go node was an opt-in sibling reached by typing `-go` into the
reasoner path. Neither is true once the root manifest redirects: `af
install https://github.com/Agent-Field/pr-af` gives you the Go node as
`pr-af`, and an existing Python install is replaced in place.
Documents the local-path escape hatch in both files, since that is now the
only way to install the Python node deliberately, and keeps the Go add-on
Compose file described as what it is — the way to run both at once during
a changeover, which is why it still carries `-go` ids.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog advertised two SWE nodes — the Python
swe-plannerat the repo root andswe-planner-gounder//go. Installing SWE should give you one thing, so this collapses them to a single entry namedswe-planner, and adds the mechanism that carries an existing install across — the open question the first version of this PR flagged and left unanswered.One catalog entry
control-plane/internal/cli/catalog.go— drop the Python entry; the surviving one keeps the fuller product description and is namedswe-planner, after the product rather than the implementation. Catalog goes 6 → 5 entries.desktop/src/shared/catalog.ts— same collapse in the app's curated list. The two lists have no automated cross-check, so they are edited in lockstep; the desktop card keeps its own wording.swe-plannernode id, so a first-time user's first command after a catalog install actually works.docs/mcp-integration.md,skills/agentfield-use/SKILL.mdand its byte-identicalskillkit/skill_datamirror updated to match.superseded_by— a package retires itself, without the control plane knowing who it isCollapsing the catalog on its own strands anyone who already installed the old node: the entry disappears, and Desktop's update/uninstall path refuses names it cannot find in the catalog. Fixing that must not teach AgentField about any particular node, so the redirect is an optional manifest key rather than a table of names in here:
The value is any installable source
af installaccepts, including a//subdirselector and an@ref. Installing a superseded package installs the successor instead and replaces the old one. Any node author can rename or replace their own node this way; the control plane stays agnostic, and the product-specific data lives in the product's own repo. Documented indocs/installing-agent-nodes.md— a generic key nobody can find is not much use.What the user sees when the superseded node is installed — an explicit warning before anything is touched:
The ordering is the whole design, and each rule has a test:
--force)" check. Nothing needs retiring afterwards and node-scoped secrets are already in the right scope.UninstallPackagedeletes that scope outright, so everyaf secrets set KEY --node <old>value would otherwise vanish in the swap. A value already set on the successor wins: the user set that one deliberately, and later. Global secrets are shared and untouched.The redirect is a git-install behaviour only. A local-path install ignores it, which is the documented way to install a superseded node on purpose.
Safety net: a failed reinstall no longer eats the package it was replacing
copyPackageclears the destination before the replacement is copied, and long before its dependencies build. Replacing a package in place therefore had a window where a failure in the dependency step — a missing Go toolchain is enough, and that is precisely the machine most likely to hit it — left the user with neither the node they had nor a working new one. The existing directory is now set aside and put back on any failure before the registry is updated.This is not specific to the redirect: plain
af install --forceover any installed package had the same hole. The test asserts the predecessor's own files are back on disk after a deliberately failing install, and fails without the fix.Bug found alongside: the
//subdirselector was dropped from the recorded sourceupdateRegistryWithGitrecordedinfo.URL— the URL afterParseGitURLhas split the subdirectory off. That was invisible for the//goselector form, whose caller re-parses the original string, but not when the subdirectory arrives via the--pathflag or the install API, both of which pass it alongside a bare URL. Those installs recorded a repo root as their source, so updating a//go-installed node would reinstall the repo root instead — the exact inverse of what this PR exists to do. Kept as its own commit so it can be reviewed on its own terms.Tests
git_supersede_test.go— redirect to successor; replace an existing install; same-name replace-in-place (registry, files, and the absence of an "already installed" error); secrets surviving a same-name replace; secret migration across a renaming replace, including successor-wins and globals-survive; the no-prior-install case; the bounded cycle; restore-after-a-failed-reinstall; the stash/restore/discard unit;appendSubdirSelectorround-tripping back throughParseGitURL; and the--pathsource recording.The two behavioural fixes were checked by reverting each and confirming the corresponding test goes red — the restore test reports the predecessor's files simply missing, which is the data loss it exists to prevent.
The pre-existing catalog assertion was
Contains(out, "swe-planner"), which"swe-planner-go"also satisfies as a substring — it would not have caught a mistyped name or a re-added Python entry. Both catalog tests now pin the surviving row's name rather than only asserting the other one is absent.Validation
GOFLAGS=-buildvcs=false go build ./...,go vet ./...,go test ./...(51 packages), andnpm ci/npm run typecheck/npm test(387 tests) indesktop/— all clean.Verified end to end with a real
af installagainst a local repo carrying the redirect, with the Python node already installed and holding one node-scoped secret:swe-planner— no--force, no errorswe-plannerentry with its source recorded as…//go, so the next update resolves the same packagepackages/NODE_IDunset, registers asnode_id: swe-plannerPairs with Agent-Field/SWE-AF#122, which puts the
superseded_byline in the SWE-AF root manifest and renames the node.🤖 Generated with Claude Code