Skip to content

pilotctl: self-heal the next-steps cache so the installed fleet gets graphs#399

Open
Alexgodoroja wants to merge 6 commits into
mainfrom
feat/next-steps-self-heal
Open

pilotctl: self-heal the next-steps cache so the installed fleet gets graphs#399
Alexgodoroja wants to merge 6 commits into
mainfrom
feat/next-steps-self-heal

Conversation

@Alexgodoroja

Copy link
Copy Markdown
Collaborator

Why

Next-steps graphs shipped (v1.12.7, catalogue #398), but the cache is written only at install and the call path reads only that local file. So the feature reached only apps installed after their graph shipped — the existing fleet, and any republished graph, got nothing without a manual pilotctl appstore install <id> --force. That's a blocker: a feature that doesn't reach the installed fleet isn't really shipped.

Root cause: the daemon never fetches the catalogue (it only scans local files), so pilotctl is the only actor that can refresh the cache, and it only did so at install.

What

ensureNextStepsFresh(appID) — called once per appstore call, before the IPC call, off the render path:

call → stat freshness marker
  ├─ fresh (<12h)      → return instantly (steady state: no network, pure local)
  ├─ recently failed   → back off (retry cooldown)
  └─ stale/absent      → ONE bounded 400ms catalogue fetch
                           ├─ graph      → cache it (this call renders)
                           ├─ no graph   → drop any stale cache
                           └─ unreachable→ short retry-cooldown marker
  • The fleet heals on the next call, not on reinstall. An app installed before its graph existed renders on its very next call — a bounded ~400ms inline fetch on the (rare) cache miss.
  • Republished graphs propagate within the 12h TTL automatically — closes the staleness gap too (a graph edit no longer needs an app_version bump to reach hosts).
  • The hot path stays local. Steady state is a single stat; network happens at most once per app per 12h. Two tiny marker files (.next-steps.checked, .next-steps.retry) gate it; an offline host backs off instead of re-fetching every call.
  • Best-effort and confined. Never errors out of a call; all writes/removes re-confined to the install root via resolveUnder (gosec clean).

Proof

Live against the production catalogue on a fresh host: cleared the cache to simulate a pre-feature install → call #1 rendered the graph with no --force and populated the cache + marker → call #2 did zero network (strace: 0 connect()), pure local read.

Unit tests: marker freshness, steady-state short-circuit (no fetch when checked), failure→retry-cooldown, disabled no-op, traversal refusal. Full cmd/pilotctl suite green; gofmt/vet/gosec clean.

Ships in the next release

This is client code — it reaches users when the next v* tag is cut (like v1.12.7). No catalogue change needed; it reads the metadata already live on main.

…fleet)

The graph was cached only at install, so the feature reached only apps installed
AFTER their graph shipped — the existing fleet, and any republished graph, never
got hints without a manual `install --force`. That is a blocker: a feature that
doesn't reach installed apps isn't shipped.

ensureNextStepsFresh closes the gap from the call path, off the render:
- steady state is a single stat of a freshness marker and returns instantly — the
  hot-path-is-a-local-read guarantee holds for all but the rare re-check;
- when the 12h TTL has elapsed it spends ONE bounded (400ms) catalogue fetch, so
  an app installed before its graph existed renders on its very NEXT call, and a
  republished graph propagates within the TTL;
- a fetch that can't reach the catalogue drops a short retry-cooldown marker so an
  offline host backs off instead of re-fetching every call;
- an app the catalogue no longer publishes a graph for has its stale cache dropped.

Proven live against the production catalogue: an app with no cache rendered on its
first call (no --force), and the next call did zero network. Marker gating,
failure cooldown, disabled no-op, and traversal-refusal are unit-tested; gosec
clean (writes/removes re-confined to the install root via resolveUnder).
@Alexgodoroja Alexgodoroja requested a review from TeoSlayer as a code owner July 16, 2026 22:28
@Alexgodoroja Alexgodoroja enabled auto-merge (squash) July 16, 2026 22:43
Completes the "keep all apps current" story on top of the metadata self-heal.

outdated/upgrade now detect a SAME-VERSION republish, not just a version bump:
install records the catalogue bundle sha to $APP/.bundle-sha256, and findOutdated
flags an app "rebuilt" when the catalogue's current bundle sha differs from the
recorded one at an unchanged app_version — exactly the aegis argv-fix shape, which
a version compare silently misses. A pre-feature install with no recorded sha
degrades to version-only detection (behaves as before). Logic split into the pure
outdatedReason() and unit-tested.

The updater keeps the app ADAPTERS current too, not just the pilot binaries: on
the same cadence and behind the same enable switch, it runs
`pilotctl appstore upgrade --all`, so a new version or a republish reaches the
fleet with no manual upgrade. Each upgrade re-runs the full catalogue-signature +
manifest-signature + trust-anchor gate, so this is automation, not new trust.
Opt out with PILOT_UPDATER_NO_APP_UPGRADE; gated by the same {"enabled":bool}
control file as binary updates (fail-safe off), unit-tested.
Comment thread cmd/pilotctl/appstore_update.go Fixed
Alexgodoroja added 4 commits July 16, 2026 16:10
The new .bundle-sha256 read in scanInstalledApps uses the identical
install-root + direntry + constant-filename pattern as the manifest read right
above it (a pre-existing accepted finding). Annotate it so the PR introduces no
new code-scanning alert; the read is confined by construction.
The installer aborted under set -e before the skill-injection first-pass on
exactly the hosts agents run on: with systemctl present but systemd not PID 1
(containers/WSL/CI), 'sudo systemctl daemon-reload' returned non-zero and killed
the script ~200 lines before 'pilotctl skills check'. Fresh installs were left
with binaries + config but no service, no skill, and a non-zero exit.

- Gate the systemd block on a booted system ([ -d /run/systemd/system ]), not
  just the systemctl binary, and guard daemon-reload/enable with || true so a
  degraded systemd can never abort the install.
- Add a non-systemd Linux fallthrough that prints the portable start path
  (pilotctl daemon start) instead of leaving the agent with no daemon.
- Correct per-tool skill paths (skills/pilot-protocol -> skills/pilotctl, the
  real entrypoint), fix PicoClaw heartbeat file (HEARTBEAT.md), and list Goose.
'pilotctl skills' (status) and 'skills paths' ran ForceTick, which WROTE files
and then reported their pre-write state — so the first status both created the
skill files and labelled them 'absent - next: create', a false failure an agent
reads as a broken install. Switch the display surfaces (status, paths, and the
info summary helpers) to skillinject.Plan(), the read-only dry run built for
exactly this. Mutating actions (check, enable) keep ForceTick.

Also correct the 'supported tools' list to match the injection manifest: add
PicoClaw and Goose (real targets), drop Cursor (never a target). Adds a
regression test pinning the list.

Plan() lands in skillinject after v0.2.3; pin main HEAD via pseudo-version so
this builds today. Go-live can repin to a tagged skillinject release.
…ough

The post-install hint was four lines (PATH, daemon start, info, ping) and never
showed an agent the two things it actually needs on a fresh node: the send-with
--wait / read-newest-inbox idiom, and the three ways to get value (pilot-director
for live data, list-agents for known specialists, appstore for local
capabilities), plus the peers/trust half. Replace it with a numbered GET STARTED
block covering all of it, with copy-paste examples. Ends by pointing at the full
operator manual (the injected SKILL.md).

@Alexgodoroja Alexgodoroja left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — this PR is 6 distinct changes under a one-feature title

Read the full diff. The title/description cover only the next-steps self-heal (①), but the branch carries six independent changes. I've dropped inline markers ①–⑥ at each change boundary so the diff is navigable, with review notes attached.

# Change Files
Next-steps self-heal (titular) appstore.go (call hook), appstore_nextsteps.go (+test)
Same-version republish detection (.bundle-sha256, rebuilt) appstore.go (install hook), appstore_update.go (+test)
Updater auto-upgrades app adapters cmd/updater/main.go (+test)
skills status/paths/info read-only (planTick) + accurate target list skills.go, zz_skills_test.go, go.mod/go.sum
install.sh: non-systemd / headless hosts install.sh
install.sh: full GET STARTED walkthrough install.sh

Overall: quality is high across all six — pure-function splits, good test coverage, consistent resolveUnder confinement, fail-safe gates. Nothing looks broken.

Recommendations

  1. Scope — this is really 3–4 PRs. ①+② are related (both about propagating catalogue changes to the fleet); ③, ④, and ⑤/⑥ are independent. Splitting would make review, changelog, and bisect far cleaner. At minimum the body should enumerate all six: ③ ④ ⑤ ⑥ are currently invisible in the description.
  2. ③ is a supply-chain posture change — a signed catalogue republish now auto-deploys fleet-wide (that's how the aegis fix reaches hosts, by design). State it explicitly and document the PILOT_UPDATER_NO_APP_UPGRADE opt-out.
  3. ④ is an unmentioned real bugfixstatus was mutating disk just by being looked at. Arguably more user-facing than the titled feature; worth surfacing.

Not blocking — primarily a scope/documentation ask.

Comment thread cmd/pilotctl/appstore.go
fatalHint("invalid_argument", "json-args must be valid JSON", "%v", err)
}
}
// Lazily self-heal the cached graph before the call so it is current for

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

① Next-steps self-heal — the titular feature. The one-line hook: ensureNextStepsFresh runs before every appstore call. Steady state is a single stat; on a TTL miss it spends one bounded ~400ms fetch. Clean. One thing worth a sentence in the body: this is an inline, pre-IPC fetch on the call's critical path (once per app per 12h).

// ensureNextStepsFresh is called once per `appstore call`, before the IPC call,
// so the cache is current for whichever outcome renders. It never blocks longer
// than nextStepsFetchBudget and never errors out of the call.
func ensureNextStepsFresh(appID string) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

① core. The marker design (.next-steps.checked / .next-steps.retry) and the authoritative-vs-transient split (fetchNextStepsAuthoritative returning ok) is the right shape — it correctly separates "no graph, stop asking for 12h" from "couldn't reach, retry in 2m". resolveUnder on every write/remove matches the existing cache path. LGTM.

// timeout it reports ok=false (treated as a transient failure) and lets the
// goroutine finish and fall off on its own — it never writes anything, so an
// abandoned fetch can't race the cache.
func fetchNextStepsTimed(appID string, budget time.Duration) (*nextStepsGraph, bool) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (not blocking): fetchNextStepsTimed returns on the 400ms deadline, but the spawned goroutine keeps running fetchNextStepsAuthoritative until the underlying HTTP finishes. The buffered channel avoids a send-leak, but the fetch itself is only bounded by whatever timeout loadCatalogue/loadAppMetadata carry. Worth confirming that path has its own deadline — otherwise a slow host accumulates one lingering goroutine per call past TTL.

Comment thread cmd/pilotctl/appstore.go
// catalogue simply means no hints — never a failed install.
cacheNextSteps(finalDir, fetchNextStepsForInstall(m.ID))

// Record the catalogue bundle sha we just installed so `outdated`/`upgrade`

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

② Same-version republish detection starts here (unrelated to ①). Records the catalogue bundle sha at install into .bundle-sha256 so outdated can flag a rebuilt-but-same-version adapter (the aegis argv-fix shape a version compare misses). Best-effort, catalogue-installs-only — correct.

// that a version compare alone misses. Requires both shas; a pre-feature
// install with no recorded sha degrades to version-only detection.
// - "": up to date (or not enough information to say otherwise).
func outdatedReason(installedVer, catVer, installedBundleSHA, catBundleSHA string) string {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

② core. outdatedReason is a clean pure function, nicely split out for testability and well covered (version / rebuilt / degrade-when-sha-missing). The graceful degradation — no recorded sha ⇒ version-only detection, exactly as before — is the right call for the pre-feature fleet.

Comment thread cmd/updater/main.go
// does, so this adds automation, not trust. Opt out with
// PILOT_UPDATER_NO_APP_UPGRADE for hosts that want binary-only updates.
if !envBool("PILOT_UPDATER_NO_APP_UPGRADE") {
go appUpgradeLoop(*installDir, *statePath, *interval)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

③ The updater now auto-upgrades app ADAPTERS, not just pilot binaries — a genuinely new behavior and the most consequential change in this PR, yet unmentioned in the body. It's gated correctly (enable-file + PILOT_UPDATER_NO_APP_UPGRADE opt-out) and re-runs the full signature/trust gate. But the net effect is a supply-chain posture change: any signed catalogue republish now auto-propagates to every auto-update host (by design — that's how ② reaches the fleet). Please state this explicitly and document the opt-out env var in the description.

Comment thread cmd/pilotctl/skills.go
// skill state never mutates the filesystem — and never reports a file as
// "absent — next: create" in the same breath that a mutating tick just created
// it (the pre-write-state skew that ForceTick-backed status suffered from).
func planTick() (*skillinject.Report, error) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skills status/paths/info are now read-onlyplanTickskillinject.Plan instead of a mutating ForceTick. This is a real correctness fix: looking at skill state was writing to disk and could report "absent — next: create" about a file the same tick had just created. More user-impacting than the PR title implies, and the reason for the skillinject v0.2.4 bump in go.mod. Worth a line in the body. (The supported-list cleanup — drop Cursor, add PicoClaw/Goose — rides here too.)

Comment thread install.sh
# --- Set up system service ---

if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⑤ install.sh: reach non-systemd / headless hosts — booted-systemd gate (/run/systemd/system), daemon-reload || true, guarded enable, and a fallback that prints the manual start path. Sound hardening for containers / WSL / CI. Independent concern.

Comment thread install.sh
echo ""
echo "Get started:"
echo "============================================"
echo " GET STARTED"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⑥ install.sh: full GET STARTED walkthrough (heredoc). Docs/UX only, purely additive. Quoted heredoc (<<'PILOT_GET_STARTED') so no accidental expansion — good. Independent of everything above.

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