From 13f974f70b4dc9cbb1cefa1c1c870794343f4955 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 20 Sep 2026 09:22:53 -0400 Subject: [PATCH 1/2] fix(sync): refresh self-update evidence before planning --- src/commands/sync.mjs | 9 +- src/lib/versions.mjs | 42 ++++++--- tests/kit/drift-freshness.test.mjs | 4 +- tests/kit/sync-self-freshness.test.mjs | 118 +++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/kit/sync-self-freshness.test.mjs diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 30dfcfd6..16cb8f49 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -72,10 +72,13 @@ export function recordApplyFailure(state, name, result) { /** Refresh every network-backed fact that can open an upgrade gate. Kept out * of run() so adding one release boundary does not grow the command's already - * broad orchestration complexity. Sequential: both probes persist kit.json. */ -async function refreshPlanDrift(flags, fetchLatest) { + * broad orchestration complexity. Sequential: these probes persist kit.json. */ +async function refreshPlanDrift(flags, fetchLatest, pkgRoot) { if (flags['dry-run'] || flags['no-upgrade']) return; await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) }); + // Self-update has its own TTL cache; refresh it before the collector decides + // whether a self action exists. An apply-time refresh cannot open that gate. + await selfDrift({ pkgRoot, force: true, ...(fetchLatest ? { fetchLatest } : {}) }); // Brain releases have a second executability fact beyond the tag: the // required ruvnet-brain.zip asset. A tag-only release is not actionable. if (loadKitConfig().ruvnetBrain) await ruvnetBrainDrift({ force: true }); @@ -500,7 +503,7 @@ export async function run({ // versions gate it needed to open). Dry-runs skip the refresh: it writes // kit.json, and --dry-run is pinned to touch nothing — so a dry-run // preview may be cache-stale by up to one TTL window. - await refreshPlanDrift(flags, fetchLatest); + await refreshPlanDrift(flags, fetchLatest, pkgRoot); const rows = await collectFn({ pkgRoot, cwd, dejaVuAdapter, dejaVuPlanOptions }); const plan = rows.filter((r) => r.fix) // Model lifecycle actions are explicit advisory commands. `ak status` must diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index b1057aba..e77723c2 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -120,12 +120,31 @@ export function releaseObservationLabel({ latestSource, latestObservedAt }) { export const KIT_PKG = '@pacphi/agentic-kit'; +/** Retain cached evidence only for a channel whose lookup failed. Do not + * renew the TTL unless the winning candidate was actually observed: a fresh + * latest response cannot make an older next observation fresh. */ +async function fetchSelfCandidate(tags, cachedBest, fetchLatest) { + let best = null; + let observed = false; + for (const tag of tags) { + const version = await fetchLatest(KIT_PKG, tag); + const live = isValidSemver(version); + const candidate = live ? { version, tag } : cachedBest?.tag === tag ? cachedBest : null; + if (candidate && (!best || newer(candidate.version, best.version))) { + best = candidate; + observed = live; + } + } + return { best, observed }; +} + /** Drift for the kit itself. Installed = the running copy's package.json * (pkgRoot). Prerelease installs also consult the `next` dist-tag — * prereleases publish there, so `latest` alone would never see them; the * higher of latest/next wins. Cached in kit.json alongside versionCheck. - * @param {{ pkgRoot?: string, force?: boolean }} [opts] */ -export async function selfDrift({ pkgRoot, force = false } = {}) { + * Failed lookups preserve eligible cached evidence without renewing its TTL. + * @param {{ pkgRoot?: string, force?: boolean, fetchLatest?: typeof latestVersion }} [opts] */ +export async function selfDrift({ pkgRoot, force = false, fetchLatest = latestVersion } = {}) { let installed = null; try { installed = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).version; @@ -133,16 +152,19 @@ export async function selfDrift({ pkgRoot, force = false } = {}) { const cfg = loadKitConfig(); const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; const cached = cfg.versionCheck?.self; - const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs; - let best = fresh ? cached.best ?? null : null; + const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest']; + const cachedBest = cached?.best && tags.includes(cached.best.tag) && isValidSemver(cached.best.version) + ? cached.best : null; + const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs + && (!cached.best || cachedBest); + let best = fresh ? cachedBest : null; if (!fresh) { - const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest']; - for (const tag of tags) { - const v = await latestVersion(KIT_PKG, tag); - if (v && (!best || newer(v, best.version))) best = { version: v, tag }; + const candidate = await fetchSelfCandidate(tags, cachedBest, fetchLatest); + best = candidate.best; + if (candidate.observed) { + cfg.versionCheck = { ...cfg.versionCheck, self: { last: Date.now(), best } }; + try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } } - cfg.versionCheck = { ...cfg.versionCheck, self: { last: Date.now(), best } }; - try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } } return { pkg: KIT_PKG, diff --git a/tests/kit/drift-freshness.test.mjs b/tests/kit/drift-freshness.test.mjs index f84ffc06..344dd496 100644 --- a/tests/kit/drift-freshness.test.mjs +++ b/tests/kit/drift-freshness.test.mjs @@ -14,7 +14,7 @@ import { const HOME = sandboxHome('ak-drift-fresh'); const paths = await import('../../src/lib/paths.mjs'); -const { driftReport } = await import('../../src/lib/versions.mjs'); +const { driftReport, KIT_PKG } = await import('../../src/lib/versions.mjs'); const sync = await import('../../src/commands/sync.mjs'); const { loadKitConfig } = await import('../../src/lib/config.mjs'); assertSandboxed(paths, HOME); @@ -99,7 +99,7 @@ test('sync (non-dry) force-refreshes drift BEFORE building the plan, so a fresh- const { out } = await inSandboxProject(() => captureLog(() => sync.run({ flags: FLAGS(), pkgRoot: PKG_ROOT, - fetchLatest: async (pkg) => (pkg === 'ruflo' ? '9.9.12' : '9.9.9'), // npm knows better + fetchLatest: async (pkg) => pkg === KIT_PKG ? '0.0.0' : pkg === 'ruflo' ? '9.9.12' : '9.9.9', // isolate the Ruflo upgrade }))); assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.12 available/, diff --git a/tests/kit/sync-self-freshness.test.mjs b/tests/kit/sync-self-freshness.test.mjs new file mode 100644 index 00000000..9d0defb6 --- /dev/null +++ b/tests/kit/sync-self-freshness.test.mjs @@ -0,0 +1,118 @@ +// Exercise the real self-status collector and sync planner against isolated +// cache/package files; replace only registry I/O and the package install step. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, assertSandboxed, writeKitConfig, offlineKitConfig, captureLog } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-sync-self'); +const paths = await import('../../src/lib/paths.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const sync = await import('../../src/commands/sync.mjs'); +const selfSection = (await import('../../src/commands/status/sections/self.mjs')).default; +const { selfDrift, KIT_PKG } = await import('../../src/lib/versions.mjs'); +assertSandboxed(paths, home); +const pkgRoot = path.join(home, 'installed-kit'); +const project = path.join(home, 'project'); +fs.mkdirSync(pkgRoot, { recursive: true }); +fs.mkdirSync(project, { recursive: true }); + +function seed(version = '4.0.0-alpha.49') { + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version })); + const cfg = offlineKitConfig(); + cfg.versionCheck.self = { last: Date.now(), best: { version, tag: version.includes('-') ? 'next' : 'latest' } }; + writeKitConfig(home, cfg); +} +const flags = extra => ({ 'dry-run': false, 'no-upgrade': false, yes: true, json: false, ...extra }); +async function run(options) { + const previous = process.cwd(); process.chdir(project); + try { return await captureLog(() => sync.run({ pkgRoot, flags: flags(), + collectFn: args => selfSection.collect(args), ...options })); } + finally { process.chdir(previous); } +} + +test('normal sync discovers and schedules a self-update hidden by a fresh stale-version cache', async t => { + seed(); + const lookups = [], installs = []; + t.mock.method(sync.SYNC_STEPS.find(step => step.id === 'self'), 'run', async ctx => { + const state = await selfDrift({ pkgRoot: ctx.pkgRoot }); + installs.push(state.latest); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: state.latest })); + }); + const { result, out } = await run({ fetchLatest: async (pkg, tag) => { + lookups.push([pkg, tag]); + return pkg === KIT_PKG ? (tag === 'next' ? '4.0.0-alpha.50' : '4.0.0-alpha.0') : '9.9.9'; + } }); + assert.equal(result, 0); + assert.deepEqual(installs, ['4.0.0-alpha.50']); + assert.match(out, /\[self\].*4\.0\.0-alpha\.50/); + assert.deepEqual(lookups.filter(([pkg]) => pkg === KIT_PKG).map(([, tag]) => tag), ['latest', 'next']); + assert.equal(loadKitConfig().versionCheck.self.best.version, '4.0.0-alpha.50'); +}); + +for (const mode of ['dry-run', 'no-upgrade']) { + test(`${mode} does not force registry refresh or change the fresh self cache`, async () => { + seed(); + const before = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + let lookups = 0; + const { result } = await run({ flags: flags({ [mode]: true }), fetchLatest: async () => { lookups++; return '4.0.0-alpha.50'; } }); + assert.equal(result, 0); + assert.equal(lookups, 0); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), before); + }); +} + +test('stable installations refresh only latest and do not enter the prerelease channel', async () => { + seed('4.0.0'); + const tags = []; + await run({ fetchLatest: async (pkg, tag) => { + if (pkg === KIT_PKG) tags.push(tag); + return pkg === KIT_PKG ? '4.0.0' : '9.9.9'; + } }); + assert.deepEqual(tags, ['latest']); +}); + +test('failed forced self lookups preserve known updates and do not renew the cache TTL', async () => { + seed(); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 1, best: { version: '4.0.0-alpha.50', tag: 'next' } }; + writeKitConfig(home, cfg); + const before = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async () => null }); + assert.equal(result.latest, '4.0.0-alpha.50'); + assert.equal(result.outdated, true); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), before); +}); + +test('a failed next lookup retains its cached candidate without claiming a fresh observation', async () => { + seed(); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 1, best: { version: '4.0.0-alpha.50', tag: 'next' } }; + writeKitConfig(home, cfg); + const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => tag === 'latest' ? '4.0.0-alpha.0' : null }); + assert.equal(result.latest, '4.0.0-alpha.50'); + assert.equal(loadKitConfig().versionCheck.self.last, 1); +}); + +test('stable installs reject cached next-channel candidates when latest is unavailable', async () => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self.best = { version: '5.0.0-alpha.1', tag: 'next' }; + writeKitConfig(home, cfg); + const tags = []; + const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => { tags.push(tag); return null; } }); + assert.deepEqual(tags, ['latest']); + assert.equal(result.latest, null); + assert.equal(result.outdated, false); +}); + +test('successful registry observations supersede cached versions even after a channel rollback', async () => { + seed(); + const cfg = loadKitConfig(); + cfg.versionCheck.self.best = { version: '4.0.0-alpha.99', tag: 'next' }; + writeKitConfig(home, cfg); + const result = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => tag === 'next' ? '4.0.0-alpha.50' : '4.0.0-alpha.0' }); + assert.equal(result.latest, '4.0.0-alpha.50'); + assert.equal(loadKitConfig().versionCheck.self.best.version, '4.0.0-alpha.50'); +}); From bed1e44e7770fbc43ad9ce813c749b39513a3da7 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 20 Sep 2026 09:22:53 -0400 Subject: [PATCH 2/2] docs(sync): explain self-update freshness and channel handling --- docs/TROUBLESHOOTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8fcafb23..bae930bc 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -28,6 +28,7 @@ ak sync # apply it | Undo is unavailable or refuses current state | The receipt is irreversible, its provider/version is missing, or the target no longer matches the recorded postimage | Preserve the current state and inspect the receipt. Undo is deliberately unavailable when it could overwrite later changes; use the provider's documented manual workflow if one exists. | | An owned skill remains report-only | Catalog identity is weaker than removal authority; the tree lacks a complete current `agentic-kit.skill-tree-ownership/v1` receipt or contains drift, symlinks, special files, or a plugin-cache path | Preserve it. Only a complete recursive manifest, exact allowed root/current owner, and exact current shape/digest can authorize archive. Never promote an issue #198 entrypoint digest into tree ownership. | | Maintenance reports `partial-recovery-required` and blocks new changes | A provider effect may have happened, but the durable receipt cannot yet prove wholly preimage or wholly verified postimage | Run `ak maintain recover --receipt RECEIPT_ID --yes`. Recovery only inspects and reconciles; it never retries or rolls back. If state is mixed/drifted, a provider is missing, or refresh fails, repair that evidence problem and retry recovery. | +| `ak sync` misses a newly published kit release | Older versions could plan from the kit's separate 24-hour self-update cache | Normal sync now refreshes the kit's own release channels before planning. Prerelease installs check `latest` and `next`; stable installs check `latest` only. `--dry-run` and `--no-upgrade` skip this forced refresh. Failed lookups retain eligible cached evidence without renewing its timestamp. | | `ak sync` launched from a checkout/local dependency created a global `ak` | The self-update step deliberately installs the resolved replacement globally and runs last | use `ak sync --no-upgrade` when the checkout or lockfile must remain authoritative | | Different users or Node versions see different global stacks | npm `-g` means the active prefix, which can be per-user and per-Node-version | standardize the Node manager/prefix per user; do not repair this with `sudo ak setup` | | `status` shows a deja-vu schema or capability warning | The CLI is older than 0.19.0, doctor JSON is missing/malformed/newer than schema 2, or an explicit enabled-host target is absent | update the owned installation with `ak sync`; update an external installation with its owner. Agentic Kit fails closed instead of guessing; see the [deja-vu runbook](DEJA-VU.md) |