diff --git a/README.md b/README.md index d6f2a74..c894136 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,11 @@ Running two or more sessions concurrently under one identity — two terminals, ### Resolving a retained superseded farm -Swapping in a resynced farm carries the identity's own real local data (credentials, `identity.json`, daemon/runtime state — anything that isn't a symlink or a directory the previous resync itself materialised) across from the superseded farm into the new one. When a top-level name exists in both, the swap never overwrites in either direction — it leaves the superseded farm on disk and reports it (`FARM_PREVIOUS_RETAINED`, or `FARM_SWAP_RECOVERED` when a crash-recovery pass on a later launch rediscovers it) rather than guessing which copy is more important. This can only happen when the underlying data genuinely differs in a way the tool has no way to judge safely on its own — the category system only tracks whether data is *shared across identities*, not whether it's *precious vs. disposable*. +Swapping in a resynced farm carries the identity's own real local data (credentials, `identity.json`, daemon/runtime state — anything that isn't a symlink or a directory the previous resync itself materialised) across from the superseded farm into the new one. When a top-level name exists in both, the swap does not guess which copy matters more — for most categories the tool has no way to judge that safely: `categories` only tracks whether data is *shared across identities*, not whether it's *precious vs. disposable*, and overwriting either copy could discard something real. -`claude-use identity resolve ` walks every retained `.{name}.previous.*` directory for that identity and resolves each collision interactively: keep the current farm's copy, keep the superseded farm's copy, or skip it for now (leaving it exactly as-is for a later run to ask about again). A superseded directory is only removed once every one of its own conflicts has been decided; skipping even one leaves the whole directory retained. +One category is the exception. `runtime`'s own definition (see the [category table](#category-based-sharing) above) is specifically "live per-process or per-machine artifacts" — a daemon lock, an MCP auth-needed cache, an update-check result — that make no sense being fought over at all, let alone asked about. A colliding name whose category resolves to `runtime` is discarded from the superseded copy automatically, with nothing kept from the old side and nothing asked: `keep-new` is not a judgement call for this category, it is what the category already means. This needs only the name's static classification, never the resolved shared/not-shared decision for the current directory — a `runtime` entry is disposable whether or not this identity currently chooses to share it. + +For everything else, the swap leaves the superseded farm on disk and reports it (`FARM_PREVIOUS_RETAINED`, or `FARM_SWAP_RECOVERED` when a crash-recovery pass on a later launch rediscovers it, naming what it auto-resolved and what it could not) rather than guessing. `claude-use identity resolve ` walks every retained `.{name}.previous.*` directory for that identity, auto-resolving any further `runtime` collisions it finds the same way, and asks about the rest interactively: keep the current farm's copy, keep the superseded farm's copy, or skip it for now (leaving it exactly as-is for a later run to ask about again). A superseded directory is only removed once every one of its own conflicts has been decided; skipping even one leaves the whole directory retained. ## Portable config: `.claude-use.json` @@ -624,6 +626,7 @@ the resolver's cascade and materialisation logic is exactly the kind of thing th - The exact two-phase merge algorithm: a shallow layer's specific entry surviving a later, deeper layer's blanket category flip on the same category; an exact literal key beating a glob from an earlier layer; two globs from different layers resolving to the later layer's value; two globs from the *same* layer resolving by longest-literal-prefix and then source order; two layers setting the identical category resolving to plain last-layer-wins - Conditional entries with injectable/fake mtimes, a fake resolved branch, and a fake env snapshot (never real filesystem/git/environment state, so tests aren't time-dependent, git-dependent, or slow) — a `newerThan` condition including a fresh file and excluding a stale one under the same glob, a `branch` condition applying only on a matching branch, an `env` condition applying only when the right variable is set, and a conditionally-matched subtree always being materialised rather than symlinked - A materialised directory reconciling any real (non-symlink) children written since the last resync back into `~/.claude` before re-deciding, and collapsing back into a plain symlink once its split condition no longer holds +- A `runtime`-category collision between a superseded farm and the current one resolving automatically (`carryOver`'s own `classification` parameter, covered directly, and end-to-end through both `resyncFarm`'s crash-recovery path and `resolveFarmConflicts`'s interactive one) — the decision never calling the caller's `decide`/prompt at all, a genuinely ambiguous collision alongside it in the same superseded farm still reaching that prompt, and the whole auto-resolution falling back to the old fully-manual behaviour when no `classification` is given `identityManager.ts`, `configProfiles.ts`, `directoryRules.ts`, and `configure.ts` stay thin adapters over the resolver, so most of their correctness rides on the resolver's own test coverage above. The one exception is `listIdentities`, whose own tests cover a deliberate departure from the "throw a validation error and let it propagate" convention: an `identity.json` that is present but unreadable — malformed JSON, or valid JSON this version's `IdentitySchema` rejects — is reported as that one identity's own unreadable entry, so a single bad file never hides every *other* identity from `claude-use identity list` at the moment they most need to be visible. Only those two content-shaped failures are absorbed; a permission error still propagates. A wholly *absent* `identity.json` remains a silent skip rather than a problem, and both it and `doctor`'s own enumeration filter out directories whose name starts with `.`, since `IdentitySchema` requires an identity name to start with a letter or digit and a resync's own `..scratch.`/`..previous.` directories are therefore never identities to report on. `launcher.ts` carries three separately-testable responsibilities of its own that aren't covered by the resolver's purity, and need their own coverage: translating a resolved `Map` into real filesystem side effects (creating/removing symlinks, materialising/collapsing directories, diffing against the farm's prior state, the per-identity lock and atomic-swap behaviour from [Directory rules](#directory-rules)) against a fake/in-memory filesystem; invoking the real `claude` binary via an injected `spawn` function (argv/env construction, exit-code propagation), never a real subprocess in a unit test; and the ambient-credential guard — given a fake `process.env`, refusing to proceed when any of the six named variables is set and the active identity's `allowAmbientCredential` is unset/false, proceeding when it's true, and proceeding when `CLAUDE_USE_ALLOW_AMBIENT_CREDENTIAL=1` is set for that one call regardless of the identity's own setting. diff --git a/src/check.ts b/src/check.ts index 5e7669b..8f78b23 100644 --- a/src/check.ts +++ b/src/check.ts @@ -3,17 +3,14 @@ import path from "node:path"; import type { Command } from "commander"; import { z } from "zod"; -import categoriesDefaultJson from "./config/categories.default.json"; +import { loadClassification } from "./config/classify"; import { cosmiconfigReader } from "./config/load"; import { - CategoryClassificationOverlaySchema, - CategoryClassificationSchema, SHIPPED_CATEGORY_DEFAULTS, type CategoryClassification, type CategoryClassificationOverlay, type Identity, } from "./config/schema"; -import { readJson } from "./config/store"; import { loadCascadeInput, readDirectorySelections } from "./launcher/cascade"; import { buildEntryFacts } from "./launcher/farm"; import { AMBIENT_CREDENTIAL_VARS, evaluateAmbientCredentialGuard, type AmbientCredentialGuardResult } from "./launcher/guard"; @@ -406,11 +403,7 @@ export function registerCheckCommand(program: Command, paths: LayoutPaths): void const claudeHome = resolveClaudeHome(); const read = cosmiconfigReader(); - const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema); - const classification = { - defaults: CategoryClassificationSchema.parse(categoriesDefaultJson), - ...(overlay === undefined ? {} : { overlay }), - }; + const classification = loadClassification(paths); const loaded = loadCascadeInput({ paths, home, cwd, read }); const selections = readDirectorySelections(loaded); diff --git a/src/cli.ts b/src/cli.ts index c038912..6e8cae9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,11 +3,9 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { Command } from "commander"; -import categoriesDefaultJson from "./config/categories.default.json"; import packageJson from "../package.json"; +import { loadClassification } from "./config/classify"; import { cosmiconfigReader } from "./config/load"; -import { CategoryClassificationOverlaySchema, CategoryClassificationSchema } from "./config/schema"; -import { readJson } from "./config/store"; import { registerCheckCommand } from "./check"; import { CliError } from "./cliError"; import { realPromptsPort, registerConfigureCommand, runProfileWizard } from "./configure"; @@ -86,11 +84,7 @@ function buildFarmRuntime(paths: LayoutPaths): { const home = os.homedir(); const cwd = process.cwd(); const read = cosmiconfigReader(); - const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema); - const classification = { - defaults: CategoryClassificationSchema.parse(categoriesDefaultJson), - ...(overlay === undefined ? {} : { overlay }), - }; + const classification = loadClassification(paths); const loaded = loadCascadeInput({ paths, home, cwd, read }); const selections = readDirectorySelections(loaded); const git = resolveGitBranch(realRunPort, cwd); diff --git a/src/config/classify.test.ts b/src/config/classify.test.ts index 063a75e..0bd7416 100644 --- a/src/config/classify.test.ts +++ b/src/config/classify.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import categoriesDefaultJson from "./categories.default.json"; -import { classifyEntries, compileClassificationPatterns, isExactPattern } from "./classify"; +import { classifyEntries, compileClassificationPatterns, isExactPattern, loadClassification } from "./classify"; import { CategoryClassificationSchema } from "./schema"; +import { buildLayoutPaths, type LayoutPaths } from "../paths"; const defaults = CategoryClassificationSchema.parse(categoriesDefaultJson); @@ -96,3 +100,32 @@ describe("compileClassificationPatterns", () => { expect(compiled.some((pattern) => pattern.pattern === "extra" && pattern.source === "local")).toBe(true); }); }); + +describe("loadClassification", () => { + let root: string; + let paths: LayoutPaths; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "classify-test-")); + paths = buildLayoutPaths(root); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("returns the shipped defaults with no overlay when categories.local.json does not exist", () => { + const loaded = loadClassification(paths); + expect(loaded.defaults).toEqual(defaults); + expect(loaded.overlay).toBeUndefined(); + }); + + it("includes the local overlay once one has been written", () => { + fs.mkdirSync(path.dirname(paths.categoriesLocalFile), { recursive: true }); + fs.writeFileSync(paths.categoriesLocalFile, JSON.stringify({ knowledge: ["a-user-answered-this"] }), "utf8"); + + const loaded = loadClassification(paths); + expect(loaded.overlay).toEqual({ knowledge: ["a-user-answered-this"] }); + expect(classifyEntries(["a-user-answered-this"], loaded).classification.get("a-user-answered-this")).toBe("knowledge"); + }); +}); diff --git a/src/config/classify.ts b/src/config/classify.ts index 32a300d..cb06776 100644 --- a/src/config/classify.ts +++ b/src/config/classify.ts @@ -1,11 +1,16 @@ import picomatch from "picomatch"; +import categoriesDefaultJson from "./categories.default.json"; import { CATEGORY_NAMES, + CategoryClassificationOverlaySchema, + CategoryClassificationSchema, type CategoryClassification, type CategoryClassificationOverlay, type CategoryName, } from "./schema"; +import { readJson } from "./store"; +import type { LayoutPaths } from "../paths"; /** Which map a classification pattern came from. A `local` pattern is an answer the user gave to an "unclassified entry" prompt; a `default` pattern is shipped with the tool. */ type ClassificationSource = "default" | "local"; @@ -98,6 +103,26 @@ function compareClassificationPatterns(a: ClassificationPattern, b: Classificati return a.ordinal - b.ordinal; } + +/** The classification input every real call site needs: the shipped defaults plus this user's own local overlay, when one exists. */ +export interface LoadedClassification { + readonly defaults: CategoryClassification; + readonly overlay?: CategoryClassificationOverlay; +} + +/** + * Loads the classification input `classifyEntries` needs — the shipped `categories.default.json`, plus `categories.local.json` when the user has answered at least one "unclassified entry" prompt. + * + * Real-wired convenience over reading and validating the two files. Every command that classifies anything (`cli.ts`'s farm runtime, `check`, `configure`, `identity resolve`) was independently repeating this exact pair of `readJson`/`.parse()` calls before this existed; centralising it here means the four command files stay thin call sites rather than each holding its own copy of a validation step that never varies between them. + */ +export function loadClassification(paths: LayoutPaths): LoadedClassification { + const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema); + return { + defaults: CategoryClassificationSchema.parse(categoriesDefaultJson), + ...(overlay === undefined ? {} : { overlay }), + }; +} + /** * Classifies a list of real top-level `~/.claude` entry names against the shipped category map plus an optional local overlay. * diff --git a/src/configure.ts b/src/configure.ts index 1e2e6cc..eddf9a3 100644 --- a/src/configure.ts +++ b/src/configure.ts @@ -4,11 +4,9 @@ import path from "node:path"; import type { Command } from "commander"; import * as clack from "@clack/prompts"; -import categoriesDefaultJson from "./config/categories.default.json"; +import { loadClassification } from "./config/classify"; import { cosmiconfigReader } from "./config/load"; import { - CategoryClassificationOverlaySchema, - CategoryClassificationSchema, OVERRIDABLE_CATEGORIES, PortableConfigSchema, SHIPPED_CATEGORY_DEFAULTS, @@ -362,11 +360,7 @@ function buildConfigureContext(deps: RunConfigureDeps, params: RunConfigureParam } const read = cosmiconfigReader(); - const overlay = readJson(deps.paths.categoriesLocalFile, CategoryClassificationOverlaySchema); - const classification = { - defaults: CategoryClassificationSchema.parse(categoriesDefaultJson), - ...(overlay === undefined ? {} : { overlay }), - }; + const classification = loadClassification(deps.paths); const globalConfig = readGlobalConfig(deps.paths); const preliminary = loadCascadeInput({ paths: deps.paths, home: params.home, cwd: params.cwd, read }); diff --git a/src/identityManager.ts b/src/identityManager.ts index 1c1d29c..9cdf6b4 100644 --- a/src/identityManager.ts +++ b/src/identityManager.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; +import { loadClassification } from "./config/classify"; import { ConfigValidationError } from "./config/load"; import { applyPatch, readJson, writeJsonAtomic, writeTextAtomic } from "./config/store"; import { IdentitySchema, type Identity } from "./config/schema"; @@ -302,6 +303,7 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v fs: realFarmFs, identitiesDir: paths.identitiesDir, identity: name, + classification: loadClassification(paths), decide: async (conflict) => { const choice = await realPromptsPort.select({ message: @@ -317,8 +319,16 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v }, }); + if (result.autoResolved.length > 0) { + console.log( + `Auto-resolved ${result.autoResolved.length} disposable runtime entr${result.autoResolved.length === 1 ? "y" : "ies"} ` + + `with no prompt (${result.autoResolved.join(", ")}) — per-process/per-machine state, never worth asking about.`, + ); + } if (result.resolved.length === 0) { - console.log(`No superseded farm data to resolve for identity "${name}".`); + if (result.autoResolved.length === 0) { + console.log(`No superseded farm data to resolve for identity "${name}".`); + } return; } for (const conflict of result.resolved) { diff --git a/src/launcher.ts b/src/launcher.ts index aab59d2..d749d2e 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -112,6 +112,7 @@ export function runLauncher(params: RunLauncherParams): void { identity: farmIdentity, now: farm.now, lock: farm.lock, + classification: farm.classification, }); } catch (error) { if (error instanceof IdentityLockBusyError) { diff --git a/src/launcher/farm.test.ts b/src/launcher/farm.test.ts index 8ed28fc..540d522 100644 --- a/src/launcher/farm.test.ts +++ b/src/launcher/farm.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { createFakeFarmFs, fakeSleep, FAKE_CLAUDE_HOME, FAKE_HOME, FAKE_NOW_MS, shippedClassification, type FakeFarmFs } from "../test-helpers"; import type { CascadeInput } from "../resolve/walk"; -import { FARM_MANIFEST_FILENAME, readFarmManifest, resyncFarm, type ResyncFarmParams } from "./farm"; +import { FARM_MANIFEST_FILENAME, readFarmManifest, recoveryDiagnostics, resyncFarm, type RecoveryResult, type ResyncFarmParams } from "./farm"; import { IdentityLockBusyError, identityLockPath } from "./lock"; const IDENTITIES_DIR = `${FAKE_HOME}/.claude-use/identities`; @@ -231,6 +231,22 @@ describe("resyncFarm", () => { expect(fs.lstat(`${IDENTITIES_DIR}/.work.previous.crashed`)).toBeUndefined(); }); + it("discards a runtime-category collision automatically rather than retaining the superseded farm for it", () => { + const fs = createFakeFarmFs(CANONICAL); + resyncFarm(params(fs)); + fs.seed({ + [`${FARM}/mcp-needs-auth-cache.json`]: "current", + [`${IDENTITIES_DIR}/.work.previous.crashed/mcp-needs-auth-cache.json`]: "stale, from the crashed launch", + }); + + const result = resyncFarm(params(fs, { uniqueSuffix: "recovered" })); + + expect(result.recovery.autoResolved).toEqual(["mcp-needs-auth-cache.json"]); + expect(result.recovery.retained).toEqual([]); + expect(fs.lstat(`${IDENTITIES_DIR}/.work.previous.crashed`)).toBeUndefined(); + expect(fs.readFileUtf8(`${FARM}/mcp-needs-auth-cache.json`)).toBe("current"); + }); + it("keeps a superseded farm on disk rather than discarding data the new farm also has an entry for", () => { const fs = createFakeFarmFs(CANONICAL); resyncFarm(params(fs)); @@ -292,3 +308,42 @@ describe("resyncFarm", () => { expect(strays).toEqual([]); }); }); + +describe("recoveryDiagnostics", () => { + const base: RecoveryResult = { removedScratch: [], completed: [], autoResolved: [], retained: [], recovered: false }; + + it("reports nothing when recovery found nothing to do", () => { + expect(recoveryDiagnostics(base, "work")).toEqual([]); + }); + + it("mentions what was auto-resolved, by name, distinctly from what still needs a human", () => { + const [diagnostic] = recoveryDiagnostics( + { ...base, recovered: true, autoResolved: ["mcp-needs-auth-cache.json"] }, + "work", + ); + expect(diagnostic?.message).toContain("discarded 1 superseded runtime entry (mcp-needs-auth-cache.json)"); + expect(diagnostic?.message).not.toContain("identity resolve"); + }); + + it("pluralises the count correctly for more than one auto-resolved entry", () => { + const [diagnostic] = recoveryDiagnostics( + { ...base, recovered: true, autoResolved: ["mcp-needs-auth-cache.json", ".last-cleanup"] }, + "work", + ); + expect(diagnostic?.message).toContain("discarded 2 superseded runtime entries"); + }); + + it("still points at identity resolve for a genuinely retained collision, alongside a separate auto-resolved one", () => { + const [diagnostic] = recoveryDiagnostics( + { + ...base, + recovered: true, + autoResolved: ["mcp-needs-auth-cache.json"], + retained: [`${IDENTITIES_DIR}/.work.previous.crashed`], + }, + "work", + ); + expect(diagnostic?.message).toContain("discarded 1 superseded runtime entry"); + expect(diagnostic?.message).toContain("claude-use identity resolve work"); + }); +}); diff --git a/src/launcher/farm.ts b/src/launcher/farm.ts index eade339..c8f80f0 100644 --- a/src/launcher/farm.ts +++ b/src/launcher/farm.ts @@ -203,13 +203,19 @@ export interface CarryOverParams { readonly previousRoot: string; /** The new farm, already swapped into place. */ readonly farmRoot: string; + /** + * When given, a colliding name classified as `runtime` is resolved automatically rather than reported — see `CarryOverResult.autoResolved` for why that is always safe. Optional because a caller with no classification loaded (there is none, today) should get the old, fully-manual behaviour rather than a crash; every real call site passes it. + */ + readonly classification?: { readonly defaults: CategoryClassification; readonly overlay?: CategoryClassificationOverlay }; } -/** What `carryOver` moved and what it could not. */ +/** What `carryOver` moved, resolved on its own, and could not. */ export interface CarryOverResult { /** Top-level names moved from the superseded farm into the new one. */ readonly carried: readonly string[]; - /** Top-level names left behind because the new farm has its own entry of that name. */ + /** Top-level names that collided but were resolved automatically because their category is `runtime` — see the doc comment on `carryOver` for why discarding the old copy is always safe here. */ + readonly autoResolved: readonly string[]; + /** Top-level names left behind because the new farm has its own entry of that name, and resolving them needs a human — everything that collided but was not classified `runtime`. */ readonly collided: readonly string[]; } @@ -220,7 +226,11 @@ export interface CarryOverResult { * * Anything the previous resync built itself is skipped rather than carried: a symlink is a view of the canonical tree with no data of its own, and a directory the manifest records as materialised had its real children adopted into `~/.claude` before the swap ever started. Everything else is real local data and moves across by rename, so a credential file is relocated rather than duplicated — never briefly existing as two copies on disk. * - * A name that exists in both is reported rather than resolved automatically here — overwriting the new farm's own entry would discard whatever the resync just decided; overwriting the old one would discard data. The caller keeps the superseded farm on disk in that case. Exported so `launcher/farmResolve.ts` can reuse this exact collision detection for `claude-use identity resolve`'s interactive pass, rather than a second implementation that could drift from this one. + * A name that exists in both is genuinely ambiguous in general — overwriting the new farm's own entry would discard whatever the resync just decided; overwriting the old one would discard data — so by default it is reported rather than resolved here, and the caller keeps the superseded farm on disk in that case. + * + * One category is not ambiguous, though: `runtime`'s own definition (see `config/categories.default.json`'s category table in the README) is specifically "live per-process or per-machine artifacts" — daemon locks, an MCP auth-needed cache, an update-check result — that make no sense being preserved across a swap at all, let alone fought over. When `classification` is given, a colliding name whose category resolves to `runtime` is discarded from the superseded copy and left exactly as the new farm already has it, with no data ever moved: `keep-new` is not a judgement call for this category, it is what the category already means. This needs only the name's *static* classification, never the resolved shared/not-shared decision for the current directory — a `runtime` entry is disposable whether or not this identity currently chooses to share it, so no cascade resolution is needed to make the call. + * + * Exported so `launcher/farmResolve.ts` can reuse this exact collision detection (and the same `runtime` auto-resolution) for `claude-use identity resolve`'s interactive pass, rather than a second implementation that could drift from this one. */ export function carryOver(params: CarryOverParams): CarryOverResult { const manifest = readFarmManifest(params.fs, params.previousRoot); @@ -233,6 +243,7 @@ export function carryOver(params: CarryOverParams): CarryOverResult { } const carried: string[] = []; + const autoResolved: string[] = []; const collided: string[] = []; for (const name of [...params.fs.readdir(params.previousRoot)].sort()) { @@ -244,6 +255,15 @@ export function carryOver(params: CarryOverParams): CarryOverResult { continue; } if (params.fs.lstat(path.join(params.farmRoot, name)) !== undefined) { + const category = + params.classification === undefined + ? undefined + : classifyEntries([name], params.classification).classification.get(name); + if (category === "runtime") { + params.fs.removeRecursive(path.join(params.previousRoot, name)); + autoResolved.push(name); + continue; + } collided.push(name); continue; } @@ -251,7 +271,7 @@ export function carryOver(params: CarryOverParams): CarryOverResult { carried.push(name); } - return { carried, collided }; + return { carried, autoResolved, collided }; } /** Inputs to `buildScratchTree`. */ @@ -293,6 +313,7 @@ interface SwapInParams { readonly scratchRoot: string; /** Where the superseded farm is renamed to. Must be a sibling of `farmRoot` so the rename stays within one filesystem. */ readonly previousRoot: string; + readonly classification?: { readonly defaults: CategoryClassification; readonly overlay?: CategoryClassificationOverlay }; } /** What the swap did. */ @@ -314,13 +335,18 @@ function swapIn(params: SwapInParams): SwapInResult { if (params.fs.lstat(params.farmRoot) === undefined) { params.fs.mkdirp(path.dirname(params.farmRoot)); params.fs.rename(params.scratchRoot, params.farmRoot); - return { carried: [], collided: [] }; + return { carried: [], autoResolved: [], collided: [] }; } params.fs.rename(params.farmRoot, params.previousRoot); params.fs.rename(params.scratchRoot, params.farmRoot); - const result = carryOver({ fs: params.fs, previousRoot: params.previousRoot, farmRoot: params.farmRoot }); + const result = carryOver({ + fs: params.fs, + previousRoot: params.previousRoot, + farmRoot: params.farmRoot, + ...(params.classification === undefined ? {} : { classification: params.classification }), + }); if (result.collided.length === 0) { params.fs.removeRecursive(params.previousRoot); return result; @@ -334,6 +360,7 @@ interface RecoverInterruptedSwapParams { readonly identitiesDir: string; readonly identity: string; readonly farmRoot: string; + readonly classification?: { readonly defaults: CategoryClassification; readonly overlay?: CategoryClassificationOverlay }; } /** What recovery found and did. */ @@ -344,7 +371,9 @@ export interface RecoveryResult { readonly restoredFrom?: string; /** Superseded farms whose carry-over was completed and which were then discarded. */ readonly completed: readonly string[]; - /** Superseded farms left on disk because they still held colliding data. */ + /** Top-level names, across every superseded farm processed, resolved automatically because their category is `runtime` — see `carryOver`'s own doc comment for why that needs no human decision. */ + readonly autoResolved: readonly string[]; + /** Superseded farms left on disk because they still held colliding data a human still needs to decide. */ readonly retained: readonly string[]; /** True when recovery changed anything, in which case the farm cannot be assumed to match its own manifest. */ readonly recovered: boolean; @@ -378,10 +407,17 @@ function recoverInterruptedSwap(params: RecoverInterruptedSwapParams): RecoveryR } const completed: string[] = []; + const autoResolved: string[] = []; const retained: string[] = []; for (const name of previous) { const previousRoot = path.join(params.identitiesDir, name); - const result = carryOver({ fs: params.fs, previousRoot, farmRoot: params.farmRoot }); + const result = carryOver({ + fs: params.fs, + previousRoot, + farmRoot: params.farmRoot, + ...(params.classification === undefined ? {} : { classification: params.classification }), + }); + autoResolved.push(...result.autoResolved); if (result.collided.length === 0) { params.fs.removeRecursive(previousRoot); completed.push(name); @@ -394,8 +430,14 @@ function recoverInterruptedSwap(params: RecoverInterruptedSwapParams): RecoveryR removedScratch, ...(restoredFrom === undefined ? {} : { restoredFrom }), completed, + autoResolved, retained, - recovered: removedScratch.length > 0 || restoredFrom !== undefined || completed.length > 0 || retained.length > 0, + recovered: + removedScratch.length > 0 || + restoredFrom !== undefined || + completed.length > 0 || + autoResolved.length > 0 || + retained.length > 0, }; } @@ -406,6 +448,7 @@ export interface RecoverFarmParams { readonly identity: string; readonly now: () => number; readonly lock: ResyncFarmParams["lock"]; + readonly classification?: { readonly defaults: CategoryClassification; readonly overlay?: CategoryClassificationOverlay }; } /** @@ -435,6 +478,7 @@ export function recoverFarm(params: RecoverFarmParams): RecoveryResult { identitiesDir: params.identitiesDir, identity: params.identity, farmRoot, + ...(params.classification === undefined ? {} : { classification: params.classification }), }); } finally { lock.release(); @@ -633,6 +677,7 @@ export function resyncFarm(params: ResyncFarmParams): ResyncFarmResult { identitiesDir: params.identitiesDir, identity: params.identity, farmRoot, + classification: params.classification, }); const previousManifest = readFarmManifest(params.fs, farmRoot); @@ -713,7 +758,7 @@ export function resyncFarm(params: ResyncFarmParams): ResyncFarmResult { const scratchRoot = path.join(params.identitiesDir, `.${params.identity}.scratch.${params.uniqueSuffix}`); const previousRoot = path.join(params.identitiesDir, `.${params.identity}.previous.${params.uniqueSuffix}`); buildScratchTree({ fs: params.fs, scratchRoot, plan: resolved.farm, manifest }); - const swap = swapIn({ fs: params.fs, farmRoot, scratchRoot, previousRoot }); + const swap = swapIn({ fs: params.fs, farmRoot, scratchRoot, previousRoot, classification: params.classification }); if (swap.retainedPrevious !== undefined) { diagnostics.push({ @@ -757,6 +802,9 @@ export function recoveryDiagnostics(recovery: RecoveryResult, identity: string): if (recovery.completed.length > 0) { parts.push(`finished carrying local state out of ${recovery.completed.length} superseded farm(s)`); } + if (recovery.autoResolved.length > 0) { + parts.push(`discarded ${recovery.autoResolved.length} superseded runtime entr${recovery.autoResolved.length === 1 ? "y" : "ies"} (${recovery.autoResolved.join(", ")}) — disposable per-machine state, safe to drop without asking`); + } if (recovery.retained.length > 0) { parts.push( `kept ${recovery.retained.join(", ")}, which still holds data the current farm also has an entry for — ` + diff --git a/src/launcher/farmResolve.test.ts b/src/launcher/farmResolve.test.ts index af8a741..eb6334e 100644 --- a/src/launcher/farmResolve.test.ts +++ b/src/launcher/farmResolve.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createFakeFarmFs, FAKE_HOME } from "../test-helpers"; +import { createFakeFarmFs, FAKE_HOME, shippedClassification } from "../test-helpers"; import { resolveFarmConflicts, type FarmConflict, type FarmConflictChoice } from "./farmResolve"; const IDENTITIES_DIR = `${FAKE_HOME}/.claude-use/identities`; @@ -23,7 +23,7 @@ describe("resolveFarmConflicts", () => { decide: fixedAnswer("skip"), }); - expect(result).toEqual({ resolved: [], removed: [], retained: [] }); + expect(result).toEqual({ resolved: [], autoResolved: [], removed: [], retained: [] }); }); it("carries over non-colliding data and removes the superseded farm without asking anything", async () => { @@ -150,6 +150,77 @@ describe("resolveFarmConflicts", () => { expect(result.retained).toEqual([previousA]); }); + it("auto-resolves a runtime-category collision without ever calling decide, and removes the superseded farm once nothing else remains", async () => { + const fs = createFakeFarmFs({ + [`${FARM}/mcp-needs-auth-cache.json`]: "new", + [`${PREVIOUS}/mcp-needs-auth-cache.json`]: "stale, from a crashed launch", + }); + + let decideCalls = 0; + const result = await resolveFarmConflicts({ + fs, + identitiesDir: IDENTITIES_DIR, + identity: "work", + classification: { defaults: shippedClassification }, + decide: () => { + decideCalls += 1; + return Promise.resolve("skip"); + }, + }); + + expect(decideCalls).toBe(0); + expect(result.resolved).toEqual([]); + expect(result.autoResolved).toEqual(["mcp-needs-auth-cache.json"]); + expect(result.removed).toEqual([PREVIOUS]); + expect(result.retained).toEqual([]); + expect(fs.readFileUtf8(`${FARM}/mcp-needs-auth-cache.json`)).toBe("new"); + }); + + it("auto-resolves a runtime collision but still asks about a genuine one alongside it in the same superseded farm, retaining the directory until that one is decided", async () => { + const fs = createFakeFarmFs({ + [`${FARM}/settings.json`]: "new settings", + [`${PREVIOUS}/settings.json`]: "old settings", + [`${FARM}/mcp-needs-auth-cache.json`]: "new", + [`${PREVIOUS}/mcp-needs-auth-cache.json`]: "stale", + }); + + const seen: string[] = []; + const result = await resolveFarmConflicts({ + fs, + identitiesDir: IDENTITIES_DIR, + identity: "work", + classification: { defaults: shippedClassification }, + decide: (conflict) => { + seen.push(conflict.name); + return Promise.resolve("skip"); + }, + }); + + expect(seen).toEqual(["settings.json"]); + expect(result.autoResolved).toEqual(["mcp-needs-auth-cache.json"]); + expect(result.resolved).toEqual([{ previousRoot: PREVIOUS, farmRoot: FARM, name: "settings.json", choice: "skip" }]); + expect(result.retained).toEqual([PREVIOUS]); + expect(fs.lstat(`${PREVIOUS}/mcp-needs-auth-cache.json`)).toBeUndefined(); + expect(fs.readFileUtf8(`${PREVIOUS}/settings.json`)).toBe("old settings"); + }); + + it("falls back to asking about a runtime-category collision when no classification is given at all", async () => { + const fs = createFakeFarmFs({ + [`${FARM}/mcp-needs-auth-cache.json`]: "new", + [`${PREVIOUS}/mcp-needs-auth-cache.json`]: "stale", + }); + + const result = await resolveFarmConflicts({ + fs, + identitiesDir: IDENTITIES_DIR, + identity: "work", + decide: fixedAnswer("keep-new"), + }); + + expect(result.autoResolved).toEqual([]); + expect(result.resolved).toEqual([{ previousRoot: PREVIOUS, farmRoot: FARM, name: "mcp-needs-auth-cache.json", choice: "keep-new" }]); + }); + it("never asks about a directory the manifest recorded as materialised by the prior resync", async () => { const fs = createFakeFarmFs({ [`${FARM}/projects`]: { dir: true }, diff --git a/src/launcher/farmResolve.ts b/src/launcher/farmResolve.ts index b5590e9..e6e6cff 100644 --- a/src/launcher/farmResolve.ts +++ b/src/launcher/farmResolve.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { carryOver } from "./farm"; +import type { CategoryClassification, CategoryClassificationOverlay } from "../config/schema"; import type { FarmFs } from "./ports"; /** What to do with one colliding top-level name between a superseded farm and the current one. */ @@ -26,14 +27,18 @@ export interface ResolveFarmConflictsParams { readonly fs: FarmFs; readonly identitiesDir: string; readonly identity: string; - /** Decides one conflict at a time, called once per colliding name across every retained previous farm, in a stable (sorted) order. */ + /** Decides one conflict at a time, called once per colliding name across every retained previous farm, in a stable (sorted) order. A `runtime`-category collision never reaches this callback at all — see `classification` below. */ readonly decide: (conflict: FarmConflict) => Promise; + /** When given, threaded straight through to `carryOver`, so a colliding name classified `runtime` is resolved automatically (its old copy discarded) rather than asked about — the same auto-resolution an ordinary resync already applies, available here too since a superseded farm can sit retained for a long time before anyone thinks to run this command. */ + readonly classification?: { readonly defaults: CategoryClassification; readonly overlay?: CategoryClassificationOverlay }; } /** What `resolveFarmConflicts` did. */ export interface ResolveFarmConflictsResult { - /** Every conflict encountered, in the order it was decided, alongside what was chosen for it. */ + /** Every conflict a human decided, in the order it was decided, alongside what was chosen for it. Never includes a `runtime`-category collision — those are counted in `autoResolved` instead, having never reached `decide`. */ readonly resolved: readonly ResolvedFarmConflict[]; + /** Top-level names, across every previous farm processed, resolved automatically because their category is `runtime` — see `carryOver`'s own doc comment for why that needs no human decision. */ + readonly autoResolved: readonly string[]; /** Previous-farm directories with every conflict decided (none skipped) and therefore removed. */ readonly removed: readonly string[]; /** Previous-farm directories still holding at least one skipped conflict, and therefore still on disk. */ @@ -43,7 +48,7 @@ export interface ResolveFarmConflictsResult { /** * Walks every `..previous.*` directory still on disk and, for each top-level name that collides with the current farm, asks `decide` what to do rather than leaving it for a human to resolve by hand outside the tool. * - * Reuses `carryOver`'s own collision detection rather than a second implementation: anything that does *not* collide has already been carried across automatically by an earlier resync, so this only ever has to ask about genuine conflicts — `carryOver`'s own `carried` list is not otherwise interesting here. + * Reuses `carryOver`'s own collision detection rather than a second implementation: anything that does *not* collide has already been carried across automatically by an earlier resync, so this only ever has to ask about genuine conflicts — `carryOver`'s own `carried` list is not otherwise interesting here, and (when `classification` is given) its `autoResolved` list means `decide` is only ever called for a collision `carryOver` itself could not already settle. * * `keep-new` discards the old copy outright. `keep-old` removes the current farm's own entry at that name and moves the old copy into its place — the same rename `carryOver` already uses for a non-colliding name, just preceded by clearing the spot it collided with. `skip` leaves both copies exactly as they were, and the directory they live in is not removed, so a later run of this same function finds the exact same conflict again rather than silently losing track of it. */ @@ -56,11 +61,19 @@ export async function resolveFarmConflicts(params: ResolveFarmConflictsParams): .map((name) => path.join(params.identitiesDir, name)); const resolved: ResolvedFarmConflict[] = []; + const autoResolved: string[] = []; const removed: string[] = []; const retained: string[] = []; for (const previousRoot of previousRoots) { - const { collided } = carryOver({ fs: params.fs, previousRoot, farmRoot }); + const carryOverResult = carryOver({ + fs: params.fs, + previousRoot, + farmRoot, + ...(params.classification === undefined ? {} : { classification: params.classification }), + }); + const { collided } = carryOverResult; + autoResolved.push(...carryOverResult.autoResolved); let anySkipped = false; for (const name of collided) { @@ -87,5 +100,5 @@ export async function resolveFarmConflicts(params: ResolveFarmConflictsParams): } } - return { resolved, removed, retained }; + return { resolved, autoResolved, removed, retained }; }