Skip to content

Consolidate the SWE and PR-AF nodes in the app, and make installs superseded-aware - #873

Merged
AbirAbbas merged 6 commits into
mainfrom
feat/desktop-consolidate-nodes
Aug 4, 2026
Merged

Consolidate the SWE and PR-AF nodes in the app, and make installs superseded-aware#873
AbirAbbas merged 6 commits into
mainfrom
feat/desktop-consolidate-nodes

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Agent-Field/SWE-AF#122 and Agent-Field/pr-af#64 each collapse a repo's Python and Go nodes into one, using the superseded_by: manifest key from #864. This is the AgentField side of that: one catalog row per product, and an install path that is honest about what a redirect actually installed.

One PR-AF row, and both consolidated nodes install by repo URL

The catalog offered PR-AF twice — a Python row and a Go row shipping the same reasoners under a name the user had to know to type. In the Install view the two cards were indistinguishable except for the -go suffix, which is an implementation detail leaking into a product list. Now one pr-af row, language go, mirroring what #864 already did for SWE.

Both consolidated rows install from the bare repo URL rather than naming //go. Naming the subdirectory installs the same node, but it skips the redirect — and the redirect is the part that carries an existing install across: it puts the successor in place first, migrates node-scoped secrets, and only then retires the predecessor. Someone already on the Python node gets migrated by pressing Update; someone naming //go would only collide with it.

That changes the rule both catalogs are written against, and both header comments now say the new one: an entry's name must equal the name the package ends up registered under once the install settles. Under a redirect that is not the name: in the manifest at the source, and it may live in a subdirectory the catalog never mentions.

sec-af and cloudsecurity-af are untouched — neither ships a second implementation, so neither has anything to collapse.

The install job was reporting the wrong package — sometimes nothing at all

Manager.run inferred the installed package by diffing the registry's names before and after the install. That inference breaks on exactly the case superseded_by exists for.

A successor may declare its predecessor's name — an in-place rename, which is what both repo PRs use and what keeps a node id, its triggers, and its node-scoped secrets intact across the swap. The set of installed names is then identical before and after, the diff finds nothing, and the job reports an empty package name. The desktop app streams that job's output, so the user watched a successful install end in install completed: with the name missing.

When the successor's name does differ the diff happened to work, but only by luck — it returns the first registry name that is new, so an unrelated entry appearing during the install is misattributed to this job.

The installer already knows: GitInstaller tracks installedName and propagates it through a redirect. This exports it, threads it out through the package service as InstallPackageWithResult, and has the job prefer it — keeping the before/after diff as a fallback for installers that cannot report a name. Node-dependency discovery used the same idiom and is switched over too.

Updates take the authoritative name as well. StartUpdate pre-seeds the job with the name being updated, so the installer's answer was previously discarded — and an update whose recorded source redirects to a differently named successor would then try to restart the package the redirect had just uninstalled. It now reports and restarts the node that exists.

The app says what landed

Every install result was phrased from the request — the row's name, or the URL pasted. With the real name available, the app repeats that instead: a pasted repo says pr-af installed rather than Installed from https://github.com/Agent-Field/pr-af; a catalog install names the successor if it ever disagrees with the row (drift worth seeing, not hiding); an update that followed a rename reads <old> replaced by <new>. Each falls back to the previous wording when the control plane names nothing, so an older control plane behaves exactly as today.

The agentfield-use skill's executions/active example still targeted pr-af-go. skillkit installs that skill into Claude Code, Codex, Cursor and the rest, so its examples are the ids a harness will actually try — updated in both the source and embedded copies, which stay byte-identical.

Validation

Ran the literal steps from .github/workflows/desktop.yml and control-plane.yml:

  • npm ci, npm run typecheck, npm test in desktop/393 passed (19 files)
  • go build ./..., go vet ./... in control-plane/ — clean
  • go test ./... — every package passes except TestDevServiceRunDev, which fails identically on unmodified origin/main (same 3 subtests, same 120s port-discovery timeouts; verified in a detached worktree at 4bc8ce7). Pre-existing, environment-specific, untouched by this diff.
  • Touched packages re-run individually: skillkit, cli, packages, packagejobs — all green, including the skill mirror byte-identity test.

New tests are derived from behaviours, not from the code: a same-name redirect reports that name rather than empty; a different-name redirect reports the successor; a plain install is unchanged; a failed install claims no completion; an unrelated concurrent registry addition is not misattributed; an in-place replacement leaves exactly one registry entry recorded against the successor's source; an update that renamed restarts the successor. The catalog guard test generalizes to both repos — one row each, product-named, bare URL, language go, and the retired -go name absent from the whole catalog, so a re-added row fails here instead of quietly reappearing.

Merge order — this lands FIRST

Merge and release this before Agent-Field/pr-af#64 and Agent-Field/SWE-AF#122. That is the opposite of what it looks like, so the reasoning:

Selecting a subdirectory bypasses the redirect. resolvePackageRoot resolves //go and parsePackageMetadata then reads only go/agentfield-package.yaml — the root manifest, and its superseded_by, is never consulted (git.go, InstallFromGit steps 2–4). So the moment a repo PR renames its go/ manifest, every already-shipped client carrying a <node>-go catalog row pointed at //go starts installing a package under a different name than the row claims.

Verified against a real installer built from origin/main, using a local repo fixture carrying pr-af#64's manifests:

  • the row labelled pr-af-go installs a package registered as pr-af, with no redirect warning — so its card never flips to Installed, while the other row's card does;
  • a user who already has the Python pr-af gets a hard package pr-af already installed (use --force to reinstall) — a click that succeeds today;
  • af run pr-af-go, which a shipped af catalog still advertises, fails: agent node pr-af-go not installed.

v0.1.120 ships that row (git show v0.1.120:desktop/src/shared/catalog.ts), and no merge order protects binaries already on disk — they read the manifest live. But landing this first removes the stale rows from every subsequent build, so the exposed population is only users who have not updated yet.

It also fixes a desync that exists today: main's swe-planner row is sourced at SWE-AF//go, whose manifest is still swe-planner-go, so that row currently installs under a name the catalog does not match. Repointing it at the bare repo URL makes the name agree again in the pre-#122 window (it installs the Python node until #122 lands — the language: go label is the only thing that reads early).

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits August 4, 2026 16:31
…lly installed

`Manager.run` inferred the installed package by diffing the registry's names
before and after: whichever name is new must be the one this job installed.
That inference breaks on exactly the case `superseded_by` was added for.

A successor may declare its predecessor's name — an in-place rename, which is
what both Agent-Field/SWE-AF#122 and Agent-Field/pr-af#64 use, and what keeps a
node id, its triggers, and its node-scoped secrets intact across the swap. The
set of installed names is then identical before and after, so the diff finds
nothing and the job reports an empty package name. AgentField Desktop streams
that job's output, so the user watched a successful install end in
"install completed: " with the name missing.

When the successor's name *does* differ, the diff happened to work, but only by
luck: it returns the first registry name that is new, so any unrelated entry
appearing during the install is misattributed to this job.

The installer already knows the answer — `GitInstaller` tracks it in
`installedName` and propagates it through a redirect. Export it, thread it out
through the package service as `InstallPackageWithResult`, and have the job
prefer it, keeping the before/after diff as the fallback for installers that
cannot report a name. Node-dependency discovery used the same diff idiom and is
switched to the authoritative name too, which also stops it from walking the
dependencies of a package some other caller installed concurrently.

Updates take the authoritative name as well. `StartUpdate` pre-seeds the job
with the name being updated, so previously the installer's answer was
discarded — and an update whose recorded source redirects to a differently
named successor would then try to restart the package the redirect had just
uninstalled. It now reports and restarts the node that exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every install result in the app was phrased from the request: the catalog row's
name, or the URL that was pasted. A `superseded_by:` redirect makes that a
guess — the manifest at the source hands the install off to a successor, which
may register under its own name.

Now that the control plane reports what it installed, repeat that instead:

  - a pasted repo says "pr-af installed" rather than "Installed from
    https://github.com/Agent-Field/pr-af", which is the more useful half of the
    sentence and the only one that tells you what to run next;
  - a catalog install names the successor if it ever disagrees with the row —
    the two agree for every entry today (that is the invariant catalog.ts
    documents), so a disagreement is drift worth seeing rather than hiding
    behind the row's own label;
  - an update that followed a rename reads "<old> replaced by <new>" instead of
    claiming it updated a node that no longer exists.

Each falls back to the previous wording when the control plane names nothing,
so an older control plane behaves exactly as it does today.

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

The catalog offered PR-AF twice — a Python row and a Go row shipping the same
reasoners under a name the user had to know to type. The two were
indistinguishable in the Install view except by the `-go` suffix, which is an
implementation detail leaking into a product list. Agent-Field/pr-af#64 collapses
them the way Agent-Field/SWE-AF#122 collapsed the SWE fleet: the root manifest
redirects to `//go`, and the Go node takes the product's name. So this is one
`pr-af` row, language go.

Both consolidated rows now install from the bare repo URL rather than naming
`//go` directly. Selecting the subdirectory would install the same node, but it
skips the redirect — and the redirect is the part that carries an existing
install across: it puts the successor in place first, migrates node-scoped
secrets, and only then retires the predecessor. Someone who already has the
Python node gets migrated by pressing Update; someone naming `//go` would only
collide with it. Naming the repo and letting the manifest decide is also simply
what a user can be told to type.

That changes the rule both catalogs are written against, so both header comments
now say the new one: an entry's `name` must equal the name the package ends up
REGISTERED under once the install settles, which under a redirect is not the
`name:` in the manifest at the source, and may live in a subdirectory the
catalog never mentions.

sec-af and cloudsecurity-af are untouched — neither ships a second
implementation, so neither has anything to collapse.

The SWE guard test generalizes to cover both repos: exactly one row per repo,
named for the product, sourced at the bare URL, language go, and the retired
implementation-suffixed name absent from the whole catalog — so a re-added row
fails here instead of quietly reappearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agentfield-use skill is what a harness reads to learn how to call the nodes
on this machine, and skillkit installs it into Claude Code, Codex, Cursor and
the rest — so its examples are the ids an agent will actually try. Its
`executions/active` sample still showed a run targeting `pr-af-go`, a name that
stops existing once Agent-Field/pr-af#64 lands.

Applied identically to the embedded copy under skillkit/skill_data so the two
stay byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 4, 2026 20:39
…installed

The job reaches the authoritative name through a type assertion, and a failed
assertion is silent — it falls back to inferring the name from a registry diff,
which is exactly the path that returns nothing for an in-place `superseded_by`
replacement. Every other test in this file uses a stub that satisfies the
interface by construction, so none of them would notice a production wiring
change (a decorator, a swapped implementation) that quietly reverted the fix.

This one asserts against the service the server actually constructs.

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

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.00% 87.40% ↓ -0.40 pp 🟡
sdk-go 92.70% 92.00% ↑ +0.70 pp 🟢
sdk-python 93.82% 93.73% ↑ +0.09 pp 🟢
sdk-typescript 91.05% 90.42% ↑ +0.63 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.59% 85.75% ↓ -0.16 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 57 96.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

Switching dependency discovery from a registry snapshot to the authoritative
installed name dropped the only thing that stopped a cycle.

The snapshot version terminated by accident but reliably: the recursive call
received a snapshot that already contained the package just reinstalled, so the
second lap skipped it. Recursing on a single name removed that, and the
remaining guard — `depName != "" && isPackageInstalled(depName)` — cannot
substitute. It only knows a dependency's name for `af://registry/…` refs, and a
forced install reinstalls whatever is already there. Every update is forced
(`StartUpdate` → `startJob(JobUpdate, …, true)`), so two packages declaring each
other by bare git URL or local path recursed until the process died — with the
package-job manager's `active` latch held, blocking every later install.

Tracks the packages this install pass has walked instead, which does not depend
on ref form, on Force, or on registry state.

The accompanying suite pins the seam's behaviour end to end through the real git
installer rather than a stub: a redirect reports the successor — including when
the successor takes the predecessor's own name, the case a registry diff cannot
see and the reason this seam exists — a failed install reports no name at each
stage it can fail, an uninstallable dependency does not fail its parent, and a
cycle terminates. That last one fails in 30s against this fix reverted.

`manager_test.go` covers the other side: an installer that cannot report a name
still installs and falls back to the registry diff, so the old path stays intact
for anything that does not implement the newer seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit f70d5da into main Aug 4, 2026
33 checks passed
@AbirAbbas
AbirAbbas deleted the feat/desktop-consolidate-nodes branch August 4, 2026 23:41
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.

1 participant