diff --git a/README.md b/README.md index c914266b..3c650c00 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ npm run service:backup-check -- --input "/absolute/private/outside-repo/tinystud The export takes the shared service lock and rejects pending promotions, symlinks, special files, in-repo or existing destinations, unsafe permissions, and changed bytes. Restore only into a clean clone: verify the snapshot; copy `clients/`, `prospects/`, `service-decisions/`, and `runs/service-engine/outputs/` to the same relative paths without merging or overwriting; run `npm run service:queue -- --mode=prepare --scope all`; then run `npm run service:queue-check -- --scope all`. Keep the snapshot local until a human approves its storage destination, access, retention, and deletion policy. -The Friday retention-prep automation is optional only while there are no client records. Once a client exists, `retention:automation-check` fails closed and prints the exact replacement prompt. Independently of client records, the check refuses to pass on a checkout that is behind remote main (remote `refs/heads/main` must be an ancestor of the local HEAD, proven by bounded `ls-remote`/`fetch`) or on an isolated empty checkout whose canonical state roots (`clients/`, `prospects/`) do not exist as real directories in the repository's main worktree. +The Friday retention-prep automation is optional only while there are no client records. Once a client exists, `retention:automation-check` fails closed and prints the exact replacement prompt. Independently of client records, the check refuses to pass on a checkout that is behind remote main (remote `refs/heads/main` must be an ancestor of the local HEAD, proven by bounded `ls-remote`/`fetch`), on a canonical retention workspace that is itself behind remote main (the Friday loop would run its old gate code), or on an isolated empty checkout whose canonical state roots (`clients/`, `prospects/`) do not exist as real directories in the repository's main worktree. The canonical workspace is the git main worktree — the first entry of `git worktree list`, which owns the repository's `.git` — never a twin worktree that merely happens to hold the `refs/heads/main` branch while the main worktree is detached. Run the checks before treating a packet as ready: diff --git a/scripts/check-retention-automation.mjs b/scripts/check-retention-automation.mjs index 88076da6..f54472d3 100644 --- a/scripts/check-retention-automation.mjs +++ b/scripts/check-retention-automation.mjs @@ -4,7 +4,7 @@ import {dirname, join} from "node:path"; import {homedir} from "node:os"; import {fileURLToPath} from "node:url"; import {RETENTION_AUTOMATION_PROMPT} from "./lib/retention-automation.mjs"; -import {canonicalMainWorktree, normalizedPath, runPreflight, scriptRepoRoot} from "./lib/retention-preflight.mjs"; +import {canonicalMainWorktree, normalizedPath, proveFreshness, runPreflight, scriptRepoRoot} from "./lib/retention-preflight.mjs"; const automationId = "tinystudio-retention-checkups"; const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"); @@ -18,6 +18,17 @@ const clientCount = preflight.activeClients; const failures = [...preflight.failures]; const warnings = [...preflight.warnings]; +// The Friday loop runs from the canonical retention workspace, so that +// workspace itself must not be a stale checkout running old gate code. When +// the script runs from the same checkout the preflight already proved it; +// otherwise prove the canonical workspace directly. +const expectedRepo = normalizedPath(expectedCwd); +const canonicalFreshness = + normalizedPath(repoForFreshness) === expectedRepo ? preflight.freshness : proveFreshness(expectedCwd); +if (canonicalFreshness && canonicalFreshness.ok === false) { + failures.push(`retention workspace is stale: ${canonicalFreshness.reason}`); +} + function value(content, key) { const match = String(content || "").match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m")); return match ? match[1] : ""; @@ -84,7 +95,6 @@ const cadence = value(content, "rrule"); if (value(content, "id") !== automationId) failures.push("Automation id does not match TinyStudio retention checkups"); if (value(content, "kind") !== "cron") failures.push("Automation is not a cron automation"); if (value(content, "status") !== "ACTIVE") failures.push("Automation is not active"); -const expectedRepo = normalizedPath(expectedCwd); const configuredRepos = configuredWorkspacePaths(content).map(normalizedPath); if (!configuredRepos.includes(expectedRepo)) failures.push("Automation does not point at the TinyStudio repo"); if (!/^FREQ=WEEKLY;/.test(cadence) || !cadence.includes("BYDAY=FR")) failures.push("Automation is not scheduled as the weekly Friday retention loop"); diff --git a/scripts/lib/retention-preflight.mjs b/scripts/lib/retention-preflight.mjs index 69956985..7f01feea 100644 --- a/scripts/lib/retention-preflight.mjs +++ b/scripts/lib/retention-preflight.mjs @@ -64,6 +64,12 @@ export function normalizedPath(path) { } } +// Resolves the repository's main worktree: the entry that owns the repository's +// .git directory, which `git worktree list` always reports first. Hosting the +// `refs/heads/main` branch is not the same as being the main worktree — a +// detached main worktree leaves `main` checked out in some other worktree, and +// substituting that twin here would turn the canonical-state inspection into a +// hollow pass on the twin's (possibly stale, empty) state root. export function canonicalMainWorktree(repoRoot) { try { const output = execFileSync("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], { @@ -71,10 +77,8 @@ export function canonicalMainWorktree(repoRoot) { stdio: ["ignore", "pipe", "ignore"], timeout: GIT_TIMEOUT_MS }) - let worktree = "" for (const line of output.split("\n")) { - if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length) - if (line === "branch refs/heads/main" && worktree) return worktree + if (line.startsWith("worktree ")) return line.slice("worktree ".length) } } catch {} return repoRoot diff --git a/scripts/test-retention-automation.mjs b/scripts/test-retention-automation.mjs index 14015e39..5dd81ce8 100644 --- a/scripts/test-retention-automation.mjs +++ b/scripts/test-retention-automation.mjs @@ -37,8 +37,8 @@ function writeAutomationSingular(prompt, workspace = repoDir) { writeFileSync(automationPath, `id = "tinystudio-retention-checkups"\nkind = "cron"\nname = "TinyStudio retention checkups"\nprompt = "${prompt}"\nstatus = "ACTIVE"\nrrule = "FREQ=WEEKLY;BYDAY=FR;BYHOUR=9;BYMINUTE=0"\nworkspace = "${workspace}"\n`) } -function run(github = "false") { - return spawnSync(process.execPath, [script], {cwd: fixtureRoot, env: {...process.env, CODEX_HOME: codexHome, GITHUB_ACTIONS: github, SERVICE_REPO_ROOT: serviceRoot, TINYSTUDIO_PREFLIGHT_REPO: repoDir}, encoding: "utf8"}) +function run(github = "false", preflightRepo = repoDir) { + return spawnSync(process.execPath, [script], {cwd: fixtureRoot, env: {...process.env, CODEX_HOME: codexHome, GITHUB_ACTIONS: github, SERVICE_REPO_ROOT: serviceRoot, TINYSTUDIO_PREFLIGHT_REPO: preflightRepo}, encoding: "utf8"}) } try { @@ -222,6 +222,42 @@ try { eq(result.status, 0) eq(JSON.parse(result.stdout).status, "pass") + // Closed-twin regression: when the repository's main worktree is detached, + // `refs/heads/main` gets checked out in some other worktree (the twin). The + // twin must never become the canonical retention workspace: the gate has to + // keep inspecting the main worktree's state, and the automation still has + // to point at the main worktree, not the twin. + const twinDir = join(fixtureRoot, "twin") + runGit(repoDir, ["checkout", "--detach"]) + runGit(repoDir, ["worktree", "add", twinDir, "main"]) + + // Automation pointed at the main worktree still passes while the twin holds + // the main branch. + writeAutomation(RETENTION_AUTOMATION_PROMPT, repoDir) + result = run() + eq(result.status, 0) + eq(JSON.parse(result.stdout).status, "pass") + + // Automation pointed at the twin fails: the twin is not the TinyStudio repo. + writeAutomation(RETENTION_AUTOMATION_PROMPT, twinDir) + result = run() + neq(result.status, 0) + out = JSON.parse(result.stdout) + assert(out.failures.includes("Automation does not point at the TinyStudio repo"), "twin workspace must not be accepted as the TinyStudio repo") + + // A stale canonical workspace fails even when the gate runs from a fresh + // checkout elsewhere in the same repository: the Friday loop would run the + // canonical workspace's old gate code. + const staleBase = runGit(repoDir, ["rev-parse", "HEAD~1"]) + runGit(repoDir, ["reset", "--hard", staleBase]) + writeAutomation(RETENTION_AUTOMATION_PROMPT, repoDir) + result = run("false", twinDir) + neq(result.status, 0) + out = JSON.parse(result.stdout) + assert(out.failures.includes("retention workspace is stale: checkout is behind or diverged from remote main"), "stale canonical workspace must fail closed even when the gate runs from a fresh checkout") + assert(!out.failures.includes("Automation does not point at the TinyStudio repo"), "stale canonical workspace must fail on staleness, not on the workspace pointer") + runGit(repoDir, ["reset", "--hard", remoteSha]) + console.log("Retention automation applicability checks passed.") } finally { rmSync(fixtureRoot, {recursive: true, force: true})