Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions codev-skeleton/resources/commands/codev.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ Verifies that all required dependencies are installed and properly configured:
- Gemini (Antigravity CLI, `agy`)
- Codex (`@openai/codex`)

**Migration Backups (report-only):**
- Leftover copies of pre-migration state — whole-directory `~/.agent-farm.bak-*` / `~/agent-farm-db-backup-*` copies and the `*.pre-merge-<timestamp>` renames left by `afx db consolidate` — listed with age and size on disk.
- Nothing reaps these automatically, so doctor surfaces each one together with the exact `rm -rf` command and leaves the decision to you. It never deletes anything.
- Backups younger than 7 days are listed but not counted as warnings; the section is omitted entirely when there are none.

**Exit Codes:**
- `0` - All OK or warnings only
- `1` - Required dependencies missing
Expand Down
17 changes: 17 additions & 0 deletions codev/projects/1239-agent-farm-migration-backups-a/status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
id: '1239'
title: agent-farm-migration-backups-a
protocol: air
phase: pr
plan_phases: []
current_plan_phase: null
gates:
pr:
status: approved
requested_at: '2026-07-25T09:52:22.852Z'
approved_at: '2026-07-25T12:37:53.126Z'
iteration: 1
build_complete: false
history: []
started_at: '2026-07-25T09:40:41.705Z'
updated_at: '2026-07-25T12:37:53.126Z'
pr_ready_for_human: false
5 changes: 5 additions & 0 deletions codev/resources/commands/codev.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ Verifies that all required dependencies are installed and properly configured:
- Gemini (Antigravity CLI, `agy`)
- Codex (`@openai/codex`)

**Migration Backups (report-only):**
- Leftover copies of pre-migration state — whole-directory `~/.agent-farm.bak-*` / `~/agent-farm-db-backup-*` copies and the `*.pre-merge-<timestamp>` renames left by `afx db consolidate` — listed with age and size on disk.
- Nothing reaps these automatically, so doctor surfaces each one together with the exact `rm -rf` command and leaves the decision to you. It never deletes anything.
- Backups younger than 7 days are listed but not counted as warnings; the section is omitted entirely when there are none.

**Exit Codes:**
- `0` - All OK or warnings only
- `1` - Required dependencies missing
Expand Down
66 changes: 66 additions & 0 deletions codev/state/air-1239_thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# air-1239 — migration backups are never reaped

## Implement phase

**Investigation finding (important):** no code in this repo creates the
`~/.agent-farm.bak-*` directory named in the issue. `git grep -i backup` over
`packages/**` returns nothing. Those directories were produced by ad-hoc
migration procedure during the #1118 state consolidation (a manual `cp -R`),
not by a shipped code path. On this machine they exist as
`~/agent-farm-db-backup-20260702-113930` (4.5M) and
`~/agent-farm-db-backup-premulti-20260702-115840` (4.2M).

Consequence: the issue's **first** proposal ("migrations that create a `.bak`
should record it and reap it") has no code hook to attach to — there is no
migration that creates one. The *only* migration leftover the codebase itself
creates is `state.db.pre-merge-<timestamp>` (`db/consolidate.ts:335`
`renameWithSidecars`), which is also never reaped.

So I'm implementing the issue's **second** proposal, which is the explicitly
sanctioned minimum and the one that actually fits the code: `codev doctor`
surfaces every migration-backup leftover with age + size and the exact removal
command. Deliberately NOT auto-deleting: these are multi-GB copies of user
state, and silently `rm -rf`-ing them is exactly the class of action that needs
human consent.

Scope covers both leftover families:
1. `~/{.,}agent-farm*bak*` sibling backups of the agent-farm home dir
2. `*.pre-merge-*` renames inside `~/.agent-farm/` and the workspace
`.agent-farm/` (the ones `db/consolidate.ts` genuinely creates)

New lib `packages/codev/src/lib/migration-backup-audit.ts` + doctor section,
following the `pr-gate-audit.ts` / `gitignore.ts` audit-lib precedent.

**Bug the tests caught, worth remembering:** the first match pattern was
`/bak/i`. It reads as obviously correct and is wrong — `backup` contains
`back`, not `bak` — so every `agent-farm-db-backup-*` dir (i.e. both of the
ones actually on the reporter's disk) was silently skipped. The test asserting
the *literal* directory names from #1118 is what pinned it. Generic pattern
tests would have passed. Pattern is now `/bac?k/i`.

## PR phase

Implement checks green (build + unit tests). Full suite: 183 files / 3655
tests passed. Ran the built `codev doctor` against the real home dir — found
all five leftovers including the `*.pre-merge-*` files, sizes cross-checked
against `du -sh`.

PR #1242 created, review in the body (AIR ships no review file). Architect
notified. CMAP consultation (codex + claude) running — skipping the gemini/agy
lane, which is known broken for `--type` reviews (no VERDICT emitted).

Gotcha for siblings: `consult --protocol air --type impl` from a builder
worktree did NOT auto-detect the project as the skill doc claims — it printed
the full multi-project list and bailed. `--issue 1239` is required.

CMAP results: **codex APPROVE / HIGH**, **claude APPROVE / HIGH**, no key
issues from either. Codex poked at a doc-sync concern between
`codev/resources/commands/codev.md` and the skeleton mirror but did not raise
it as an issue — the two files were already drifted before this change (the
skeleton is missing the `codev update --agent` docs); the sections I added are
byte-identical in both trees, which claude independently verified. That
pre-existing drift is out of scope here.

PR-phase checks green (`pr_exists`, `e2e_tests`). `porch gate 1239` registered
the **pr gate — waiting for human approval**. Not running `porch approve`;
that's the human's call.
182 changes: 182 additions & 0 deletions packages/codev/src/__tests__/migration-backup-audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Tests for the migration-backup audit (#1239).
*
* Verifies that both leftover families are found (whole-directory home copies
* and `*.pre-merge-*` renames), that age drives the stale flag, and that
* unrelated entries are left alone.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import {
findMigrationBackups,
formatMigrationBackup,
formatBytes,
totalBytes,
STALE_BACKUP_AGE_DAYS,
} from '../lib/migration-backup-audit.js';

const NOW = new Date('2026-07-25T12:00:00Z');

/** Create a directory with one file in it, aged to `daysOld`. */
function makeAgedDir(parent: string, name: string, daysOld: number): string {
const dir = path.join(parent, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'global.db'), 'x'.repeat(4096));
const mtime = new Date(NOW.getTime() - daysOld * 86_400_000);
fs.utimesSync(dir, mtime, mtime);
return dir;
}

/** Create a file aged to `daysOld`. */
function makeAgedFile(parent: string, name: string, daysOld: number): string {
fs.mkdirSync(parent, { recursive: true });
const file = path.join(parent, name);
fs.writeFileSync(file, 'x'.repeat(1024));
const mtime = new Date(NOW.getTime() - daysOld * 86_400_000);
fs.utimesSync(file, mtime, mtime);
return file;
}

describe('migration-backup-audit', () => {
let home: string;

beforeEach(() => {
home = fs.mkdtempSync(path.join(tmpdir(), 'migration-backup-'));
});

afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});

it('finds nothing in a clean home', () => {
fs.mkdirSync(path.join(home, '.agent-farm'), { recursive: true });
fs.writeFileSync(path.join(home, '.agent-farm', 'global.db'), 'x');
expect(findMigrationBackups({ home, now: NOW })).toEqual([]);
});

it('finds a dotted whole-directory home copy', () => {
const dir = makeAgedDir(home, '.agent-farm.bak-20260702-113930', 23);
const found = findMigrationBackups({ home, now: NOW });
expect(found).toHaveLength(1);
expect(found[0].path).toBe(dir);
expect(found[0].kind).toBe('home-copy');
expect(found[0].ageDays).toBe(23);
expect(found[0].stale).toBe(true);
});

it('finds undotted home copies from the #1118 consolidation', () => {
makeAgedDir(home, 'agent-farm-db-backup-20260702-113930', 23);
makeAgedDir(home, 'agent-farm-db-backup-premulti-20260702-115840', 23);
const found = findMigrationBackups({ home, now: NOW });
expect(found.map(b => path.basename(b.path)).sort()).toEqual([
'agent-farm-db-backup-20260702-113930',
'agent-farm-db-backup-premulti-20260702-115840',
]);
});

it('ignores the live agent-farm dir and unrelated entries', () => {
fs.mkdirSync(path.join(home, '.agent-farm'), { recursive: true });
makeAgedDir(home, 'Documents', 100);
makeAgedDir(home, 'my-backups', 100);
makeAgedDir(home, 'agent-farm-notes', 100);
expect(findMigrationBackups({ home, now: NOW })).toEqual([]);
});

it('requires the agent-farm prefix, not just a backup marker', () => {
// `my-backups/` in a real home must never be reported, let alone recommended
// for `rm -rf`.
makeAgedDir(home, 'my-backups', 100);
makeAgedDir(home, 'Backup', 100);
expect(findMigrationBackups({ home, now: NOW })).toEqual([]);
});

it('finds *.pre-merge-* renames inside the agent-farm home', () => {
makeAgedFile(path.join(home, '.agent-farm'), 'state.db.pre-merge-20260702-113930', 23);
const found = findMigrationBackups({ home, now: NOW });
expect(found).toHaveLength(1);
expect(found[0].kind).toBe('pre-merge');
});

it('finds *.pre-merge-* renames inside the workspace .agent-farm', () => {
const workspaceRoot = fs.mkdtempSync(path.join(tmpdir(), 'migration-backup-ws-'));
try {
makeAgedFile(path.join(workspaceRoot, '.agent-farm'), 'state.db.pre-merge-20260702-113930', 30);
makeAgedFile(path.join(workspaceRoot, '.agent-farm'), 'state.db.pre-merge-20260702-113930-wal', 30);
const found = findMigrationBackups({ home, workspaceRoot, now: NOW });
expect(found).toHaveLength(2);
expect(found.every(b => b.kind === 'pre-merge')).toBe(true);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});

it('does not scan a workspace when none is given', () => {
const workspaceRoot = fs.mkdtempSync(path.join(tmpdir(), 'migration-backup-ws-'));
try {
makeAgedFile(path.join(workspaceRoot, '.agent-farm'), 'state.db.pre-merge-1', 30);
expect(findMigrationBackups({ home, workspaceRoot: null, now: NOW })).toEqual([]);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});

it('marks backups stale only at or beyond the verification window', () => {
makeAgedDir(home, '.agent-farm.bak-fresh', STALE_BACKUP_AGE_DAYS - 1);
makeAgedDir(home, '.agent-farm.bak-due', STALE_BACKUP_AGE_DAYS);
const found = findMigrationBackups({ home, now: NOW });
const byName = Object.fromEntries(found.map(b => [path.basename(b.path), b]));
expect(byName['.agent-farm.bak-fresh'].stale).toBe(false);
expect(byName['.agent-farm.bak-due'].stale).toBe(true);
});

it('sorts newest first', () => {
makeAgedDir(home, '.agent-farm.bak-old', 90);
makeAgedDir(home, '.agent-farm.bak-new', 2);
const found = findMigrationBackups({ home, now: NOW });
expect(found.map(b => path.basename(b.path))).toEqual([
'.agent-farm.bak-new',
'.agent-farm.bak-old',
]);
});

it('measures size on disk', () => {
makeAgedDir(home, '.agent-farm.bak-sized', 10);
const found = findMigrationBackups({ home, now: NOW });
expect(found[0].sizeBytes).toBeGreaterThan(0);
expect(totalBytes(found)).toBe(found[0].sizeBytes);
});

it('treats unmeasurable backups as zero in the total', () => {
expect(totalBytes([
{ path: '/a', kind: 'home-copy', ageDays: 9, sizeBytes: null, stale: true },
{ path: '/b', kind: 'home-copy', ageDays: 9, sizeBytes: 2048, stale: true },
])).toBe(2048);
});

it('formats bytes at human scale', () => {
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(18 * 1024 ** 3)).toBe('18.0 GB');
});

it('renders age and size, and says so when size is unknown', () => {
expect(formatMigrationBackup({
path: '/home/u/.agent-farm.bak-20260702-113930',
kind: 'home-copy',
ageDays: 23,
sizeBytes: 18 * 1024 ** 3,
stale: true,
})).toBe('/home/u/.agent-farm.bak-20260702-113930 — 23 days old, 18.0 GB');

expect(formatMigrationBackup({
path: '/home/u/.agent-farm.bak-x',
kind: 'home-copy',
ageDays: 1,
sizeBytes: null,
stale: false,
})).toBe('/home/u/.agent-farm.bak-x — 1 day old, size unknown');
});
});
45 changes: 44 additions & 1 deletion packages/codev/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ import {
formatDriftFinding,
formatStaleness,
} from '../lib/protocol-drift-audit.js';
import {
findMigrationBackups,
formatMigrationBackup,
formatBytes,
totalBytes,
STALE_BACKUP_AGE_DAYS,
} from '../lib/migration-backup-audit.js';
import { resolveAgyBin, AGY_OAUTH_MARKERS } from './consult/index.js';

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -733,8 +740,44 @@ export async function doctor(): Promise<number> {

console.log('');

// Check codev directory structure (only if we're in a codev project)
const workspaceRoot = findWorkspaceRoot();

// Migration backups (#1239): state migrations leave copies of the pre-migration
// state behind — whole-directory `~/.agent-farm.bak-*` copies and the
// `*.pre-merge-*` renames from db/consolidate.ts — and nothing ever removes
// them. One was found at 18 GB three weeks after the migration that made it.
// Report-only: these are the user's own state copies, so doctor surfaces age +
// size + the removal command and leaves the decision to the human. QUIET BY
// DEFAULT — the section prints only when a leftover exists.
const migrationBackups = findMigrationBackups({ workspaceRoot });
if (migrationBackups.length > 0) {
const reclaimable = totalBytes(migrationBackups);
console.log(chalk.bold('Migration Backups') + ' (leftover copies of pre-migration state)');
console.log('');
for (const backup of migrationBackups) {
if (backup.stale) {
console.log(` ${chalk.yellow('⚠')} ${formatMigrationBackup(backup)}`);
warnings++;
warningDetails.push({
name: 'Migration backup',
issue: formatMigrationBackup(backup),
recommendation: `nothing reaps these — if the migrated state is healthy, run: rm -rf "${backup.path}"`,
});
} else {
console.log(
` ${chalk.dim('○')} ${formatMigrationBackup(backup)}` +
chalk.dim(` (within the ${STALE_BACKUP_AGE_DAYS}-day verification window)`)
);
}
}
if (reclaimable > 0) {
console.log('');
console.log(chalk.dim(` ${formatBytes(reclaimable)} reclaimable across ${migrationBackups.length} backup(s).`));
}
console.log('');
}

// Check codev directory structure (only if we're in a codev project)
if (workspaceRoot && existsSync(resolve(workspaceRoot, 'codev'))) {
console.log(chalk.bold('Codev Structure') + ' (project configuration)');
console.log('');
Expand Down
Loading
Loading