fix(worktree): disable automatic database backups (#10520)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip creates isolated instances for linked git worktrees so
development does not affect the primary instance
> - Those instances inherited the source instance's automatic
database-backup setting and also repaired older configs without
overriding it
> - As worktrees accumulated, each isolated instance could schedule its
own backup stream, producing redundant backup churn for disposable
database clones
> - This pull request makes backup disablement an invariant of worktree
config creation and repair
> - The benefit is that automatic backups remain focused on the durable
primary instance while isolated development instances stop accumulating
redundant backup files

## Linked Issues or Issue Description

No public GitHub issue exists for this bug, so the report is included
here. The closest related open change is Refs #10266, which hardens
where worktree config repair may write; this PR changes the backup
policy applied by that repair and by worktree initialization.

### What happened?

Isolated worktree instances copied `database.backup.enabled` from their
source config. When the source instance enabled automatic backups (the
normal default), every linked worktree also enabled a scheduled backup
stream. Existing worktree configs kept that state during startup repair,
so the redundant backups continued after the policy changed.

### Expected behavior

Automatic database backups are disabled for isolated worktree instances
created by `paperclipai worktree init` or `paperclipai worktree:make`,
and legacy worktree configs are migrated to that policy during normal
startup repair. The durable primary/default instance keeps its existing
backup behavior.

### Steps to reproduce

1. Start from a Paperclip instance whose database backup setting is
enabled.
2. Create or initialize a linked worktree with `paperclipai worktree
init`.
3. Inspect the generated worktree config and environment.
4. Before this change, the config retained `database.backup.enabled:
true` and the environment had no disabling override; after this change,
the config is false and `PAPERCLIP_DB_BACKUP_ENABLED=false` is
persisted.

### Paperclip version, deployment mode, and environment

- Reproduced against `master` before commit `ea5e0a0269`.
- Deployment mode: local trusted development with linked git worktrees
and embedded PostgreSQL.
- Environment: Node.js 22, pnpm workspace install.

## What Changed

- Always generate isolated worktree configs with automatic backups
disabled.
- Persist `PAPERCLIP_DB_BACKUP_ENABLED=false` in generated worktree
environments.
- Repair existing isolated worktree configs and environments that still
enable backups.
- Add CLI and server regression coverage for creation and legacy repair
paths.
- Document the worktree-specific backup policy and primary-instance
exception.

## Verification

- `pnpm exec vitest run cli/src/__tests__/worktree.test.ts
server/src/__tests__/worktree-config.test.ts` — 52 tests passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- All repository commands above were run with inherited worktree runtime
identity variables removed.

## Risks

- Low operational risk: the change is limited to explicitly isolated
worktree instances.
- Operators who intentionally relied on automatic backups of disposable
worktree databases will now need to run a manual backup or explicitly
manage those files outside the scheduled worktree runtime.
- No schema, migration, API, UI, lockfile, or workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex based on GPT-5 (the runtime does not expose a more
specific snapshot ID or context-window value), using reasoning, tool
use, local code execution, and GitHub CLI integration.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-30 17:35:04 -07:00 committed by GitHub
parent 276ae3a75d
commit 7cfb655f60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 148 additions and 7 deletions

View File

@ -276,6 +276,7 @@ describe("worktree helpers", () => {
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
);
expect(config.database.embeddedPostgresPort).toBe(54339);
expect(config.database.backup.enabled).toBe(false);
expect(config.server.port).toBe(3110);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
expect(config.storage.localDisk.baseDir).toBe(
@ -289,6 +290,7 @@ describe("worktree helpers", () => {
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");

View File

@ -197,7 +197,7 @@ export function buildWorktreeConfig(input: {
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
embeddedPostgresPort: databasePort,
backup: {
enabled: source?.database.backup.enabled ?? true,
enabled: false,
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
retentionDays: source?.database.backup.retentionDays ?? 30,
dir: paths.backupDir,
@ -258,6 +258,7 @@ export function buildWorktreeEnvEntries(
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_DB_BACKUP_ENABLED: "false",
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
};

View File

@ -355,6 +355,7 @@ This command:
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
- picks a free app port and embedded PostgreSQL port
- disables automatic database backups for the isolated instance
- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot
Seed modes:
@ -374,6 +375,7 @@ Provisioned git worktrees also pause seeded routines that still have enabled sch
That repo-local env also sets:
- `PAPERCLIP_IN_WORKTREE=true`
- `PAPERCLIP_DB_BACKUP_ENABLED=false`
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
@ -655,6 +657,11 @@ schemas. Defaults:
- retain 30 days
- backup dir: `~/.paperclip/instances/default/data/backups`
Automatic backups are disabled for isolated worktree instances created with
`paperclipai worktree init` or `paperclipai worktree:make`. Existing worktree
configs are migrated to the disabled setting when their server next starts. The
main/default instance keeps the normal enabled-by-default behavior.
Configure these in:
```sh

View File

@ -101,6 +101,10 @@ function buildIsolatedConfig(instanceRoot: string, serverPort: number, databaseP
database: {
...config.database,
embeddedPostgresPort: databasePort,
backup: {
...config.database.backup,
enabled: false,
},
},
server: {
...config.server,
@ -155,6 +159,7 @@ describe("worktree config repair", () => {
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
expect(repairedConfig.database.backup.enabled).toBe(false);
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
@ -163,10 +168,64 @@ describe("worktree config repair", () => {
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"');
expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"');
expect(repairedEnv).toContain("PAPERCLIP_AGENT_JWT_SECRET=shared-secret");
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
expect(process.env.PORT).toBe("3101");
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
});
it("disables backups in an otherwise isolated existing worktree config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-backup-migration-"));
const worktreeRoot = path.join(tempRoot, "disable-worktree-backups");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const instanceRoot = path.join(isolatedHome, "instances", "disable-worktree-backups");
await fs.mkdir(paperclipDir, { recursive: true });
const legacyIsolatedConfig = buildIsolatedConfig(instanceRoot, 3110, 54339);
legacyIsolatedConfig.database.backup.enabled = true;
await fs.writeFile(configPath, JSON.stringify(legacyIsolatedConfig, null, 2) + "\n", "utf8");
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"# Keep this operator note during repair",
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
'PAPERCLIP_INSTANCE_ID="disable-worktree-backups"',
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
'PAPERCLIP_DB_BACKUP_ENABLED="true" # managed worktree policy',
'PAPERCLIP_IN_WORKTREE="true"',
'PAPERCLIP_WORKTREE_NAME="disable-worktree-backups"',
"# Keep this trailing note too",
"",
].join("\n"),
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "disable-worktree-backups";
process.env.PAPERCLIP_CONFIG = configPath;
process.env.PAPERCLIP_DB_BACKUP_ENABLED = "true";
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "disable-worktree-backups";
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
const repairedEnv = await fs.readFile(envPath, "utf8");
expect(result).toEqual({ repairedConfig: true, repairedEnv: true });
expect(repairedConfig.database.backup.enabled).toBe(false);
expect(repairedEnv).toContain(
'PAPERCLIP_DB_BACKUP_ENABLED="false" # managed worktree policy',
);
expect(repairedEnv).toContain("# Keep this operator note during repair");
expect(repairedEnv).toContain("# Keep this trailing note too");
expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
});
it("preserves an externally supplied PORT while repairing worktree config", async () => {
@ -601,7 +660,7 @@ describe("worktree config repair", () => {
expect(result).toEqual({
repairedConfig: true,
repairedEnv: false,
repairedEnv: true,
});
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db"));
expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups"));
@ -611,6 +670,7 @@ describe("worktree config repair", () => {
path.join(stableInstanceRoot, "secrets", "master.key"),
);
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"');
expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`);
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
});

View File

@ -85,6 +85,64 @@ function formatEnvEntries(entries: Record<string, string>): string {
].join("\n");
}
function trailingEnvComment(rawValue: string): string {
let quote: "\"" | "'" | null = null;
let escaped = false;
for (let index = 0; index < rawValue.length; index += 1) {
const character = rawValue[index];
if (escaped) {
escaped = false;
continue;
}
if (quote === "\"" && character === "\\") {
escaped = true;
continue;
}
if (quote !== null) {
if (character === quote) quote = null;
continue;
}
if (character === "\"" || character === "'") {
quote = character;
continue;
}
if (character !== "#" || index === 0 || !/\s/.test(rawValue[index - 1] ?? "")) continue;
let commentStart = index;
while (commentStart > 0 && /\s/.test(rawValue[commentStart - 1] ?? "")) {
commentStart -= 1;
}
return rawValue.slice(commentStart);
}
return "";
}
function updateEnvFileContents(contents: string, entries: Record<string, string>): string {
const newline = contents.includes("\r\n") ? "\r\n" : "\n";
const missingEntries = new Map(Object.entries(entries));
const lines = contents.split(/\r?\n/).map((rawLine) => {
const match = rawLine.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/);
if (!match) return rawLine;
const [, prefix, key, separator, rawValue] = match;
const value = entries[key];
if (value === undefined) return rawLine;
missingEntries.delete(key);
return `${prefix}${key}${separator}${JSON.stringify(value)}${trailingEnvComment(rawValue)}`;
});
const insertionIndex = lines.at(-1) === "" ? lines.length - 1 : lines.length;
lines.splice(
insertionIndex,
0,
...Array.from(missingEntries, ([key, value]) => `${key}=${JSON.stringify(value)}`),
);
return lines.join(newline);
}
function isPathInside(candidatePath: string, rootPath: string): boolean {
const candidate = path.resolve(candidatePath);
const root = path.resolve(rootPath);
@ -355,6 +413,7 @@ function buildIsolatedWorktreeConfig(
embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort,
backup: {
...config.database.backup,
enabled: false,
dir: context.backupDir,
},
}
@ -399,6 +458,9 @@ function needsWorktreeConfigRepair(
context: WorktreeRuntimeContext,
): boolean {
if (config.database.mode === "embedded-postgres") {
if (config.database.backup.enabled) {
return true;
}
if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) {
return true;
}
@ -562,23 +624,32 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
}
const existingEnvEntries = readEnvEntries(context.envPath);
const desiredEnvEntries: Record<string, string> = {
...existingEnvEntries,
const managedEnvEntries: Record<string, string> = {
PAPERCLIP_HOME: context.homeDir,
PAPERCLIP_INSTANCE_ID: context.instanceId,
PAPERCLIP_CONFIG: context.configPath,
PAPERCLIP_CONTEXT: context.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_DB_BACKUP_ENABLED: "false",
PAPERCLIP_WORKTREE_NAME: context.worktreeName,
};
const repairedEnv = Object.entries(desiredEnvEntries).some(
process.env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
const repairedEnv = Object.entries(managedEnvEntries).some(
([key, value]) => existingEnvEntries[key] !== value,
);
if (repairedEnv) {
fs.mkdirSync(path.dirname(context.envPath), { recursive: true });
fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 });
const existingContents = fs.existsSync(context.envPath)
? fs.readFileSync(context.envPath, "utf8")
: null;
const repairedContents =
existingContents === null
? formatEnvEntries(managedEnvEntries)
: updateEnvFileContents(existingContents, managedEnvEntries);
fs.writeFileSync(context.envPath, repairedContents, { mode: 0o600 });
}
return { repairedConfig, repairedEnv };