Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d84efb6
fix(retention): gate must target the git main worktree, not the twin …
nish3451 Aug 11, 2026
133a96d
docs(retention): pin canonical workspace resolution in the gate contract
nish3451 Aug 11, 2026
211eca0
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
981a71a
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
05515b1
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
d88ca20
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
aa3404e
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
0288a66
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
2a6aabf
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
3972b22
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
51b53fc
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
d8ab667
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 12, 2026
68aa6f9
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 13, 2026
ea3debc
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 13, 2026
4b242f7
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 13, 2026
105f474
Merge branch 'main' into fix/retention-gate-canonical-main-worktree
nish3451 Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
14 changes: 12 additions & 2 deletions scripts/check-retention-automation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject workspace overrides that select a linked twin

When TINYSTUDIO_AUTOMATION_WORKSPACE is set to a linked twin, expectedCwd refers to that twin, so this freshness check validates the twin and the later workspace comparison also accepts an automation configured there. Meanwhile, stateRoot still defaults to the actual main worktree, allowing the gate to pass while Friday runs the twin's potentially stale gate code—the exact failure this change intends to prevent. Derive the canonical workspace independently of this override, or reject an override that differs from canonicalMainWorktree(repoForFreshness).

Useful? React with 👍 / 👎.

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] : "";
Expand Down Expand Up @@ -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");
Expand Down
10 changes: 7 additions & 3 deletions scripts/lib/retention-preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,21 @@ 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"], {
encoding: "utf8",
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
Expand Down
40 changes: 38 additions & 2 deletions scripts/test-retention-automation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
Expand Down