fix(worktrees): preserve nested repositories during cleanup (#129454)

* fix(worktrees): preserve nested repositories during cleanup

Automatic idle, limit, and run-end cleanup now treats nested repositories and linked worktrees as separate lifecycle boundaries. This prevents repeated cleanup warnings and avoids deleting unregistered nested user state.

Closes #129414

* test: isolate media auth fixture from plugin loading

Keep the OAuth fixture outside the refresh window and stub only the unrelated retired-profile plugin lookup so the media auth contract completes deterministically in CI.
This commit is contained in:
Peter Steinberger
2026-08-25 14:16:44 -07:00
committed by GitHub
parent 73294a85f2
commit 34067fcc2e
5 changed files with 151 additions and 52 deletions
+3 -1
View File
@@ -61,7 +61,9 @@ The resulting managed worktree is owned by the session, and every agent run in t
## Snapshots, cleanup, and restore
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, then pins it at `refs/openclaw/snapshots/<id>`. Ignored files never enter the repository object database. OpenClaw stores only the ignored files it actually provisioned in chunked shared-state database rows; the recorded path set remains authoritative even if `.worktreeinclude` later changes or disappears. Restore reads those bytes from the immutable snapshot and reapplies their complete modes. Automatic cleanup preserves a live worktree when a recorded path can no longer be snapshotted safely. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot.
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, then pins it at `refs/openclaw/snapshots/<id>`. Ignored files never enter the repository object database. OpenClaw stores only the ignored files it actually provisioned in chunked shared-state database rows; the recorded path set remains authoritative even if `.worktreeinclude` later changes or disappears. Restore reads those bytes from the immutable snapshot and reapplies their complete modes. Automatic cleanup preserves a live worktree when a recorded path can no longer be snapshotted safely. If snapshot creation fails, removal stops unless an explicit force delete discards snapshot safety.
Nested Git repositories and linked worktrees are separate ownership boundaries. Automatic cleanup preserves the outer worktree even when a nested linked worktree shares its Git common directory. A nested OpenClaw-managed worktree is cleaned only through its own managed-worktree record.
OpenClaw applies these cleanup rules:
+71 -18
View File
@@ -4,8 +4,9 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWarnLogCapture } from "../../logging/test-helpers/warn-log-capture.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { getRegistryWorktree } from "./registry.js";
import { findLiveRegistryWorktreeByPath, getRegistryWorktree } from "./registry.js";
import {
IDLE_GC_MS,
ManagedWorktreeService,
@@ -91,7 +92,7 @@ describe("ManagedWorktreeService garbage collection", () => {
expect(await fs.stat(manual.path)).toBeTruthy();
});
it("garbage collects an ignored nested linked worktree", async () => {
it("preserves an ignored unregistered nested linked worktree without cleanup warnings", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".claude/\n");
await git(repo, "add", ".gitignore");
await git(repo, "commit", "-m", "ignore agent checkout state");
@@ -102,13 +103,23 @@ describe("ManagedWorktreeService garbage collection", () => {
await git(repo, "worktree", "add", "--detach", nested, "HEAD");
expect((await fs.stat(path.join(nested, ".git"))).isFile()).toBe(true);
await fs.writeFile(path.join(nested, "local.txt"), "ignored agent state\n");
expect(findLiveRegistryWorktreeByPath(env, nested)).toBeUndefined();
expect(await git(created.path, "ls-files", "--others", "--exclude-standard")).toBe("");
now += IDLE_GC_MS + 1;
expect((await service.gc()).removed).toEqual([created.id]);
await expect(fs.stat(created.path)).rejects.toMatchObject({ code: "ENOENT" });
expect(await git(repo, "worktree", "list", "--porcelain")).not.toContain(nested);
expect((await new ManagedWorktreeService({ env, now: () => now }).gc()).removed).toEqual([]);
const warnLogs = createWarnLogCapture("openclaw-worktree-gc-nested-linked");
try {
expect((await service.gc()).removed).toEqual([]);
expect((await service.gc()).removed).toEqual([]);
expect(await warnLogs.findText(`idle cleanup failed for ${created.id}`)).toBeUndefined();
expect(await fs.readFile(path.join(nested, "local.txt"), "utf8")).toBe(
"ignored agent state\n",
);
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(nested);
expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined();
} finally {
warnLogs.cleanup();
}
});
it.each([
@@ -133,12 +144,19 @@ describe("ManagedWorktreeService garbage collection", () => {
expect(await git(created.path, "ls-files", "--others", "--exclude-standard")).toBe("");
now += IDLE_GC_MS + 1;
expect((await service.gc()).removed).toEqual([]);
expect((await fs.stat(path.join(nested, ".git"))).isDirectory()).toBe(true);
if (hasLocalState) {
expect(await fs.readFile(localState, "utf8")).toBe("keep foreign repository state\n");
const warnLogs = createWarnLogCapture("openclaw-worktree-gc-nested-foreign");
try {
expect((await service.gc()).removed).toEqual([]);
expect((await service.gc()).removed).toEqual([]);
expect(await warnLogs.findText(`idle cleanup failed for ${created.id}`)).toBeUndefined();
expect((await fs.stat(path.join(nested, ".git"))).isDirectory()).toBe(true);
if (hasLocalState) {
expect(await fs.readFile(localState, "utf8")).toBe("keep foreign repository state\n");
}
expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined();
} finally {
warnLogs.cleanup();
}
expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined();
});
it("garbage collects modified provisioned files into the immutable snapshot", async () => {
@@ -198,18 +216,26 @@ describe("ManagedWorktreeService garbage collection", () => {
expect(await fs.stat(created.path)).toBeTruthy();
});
it("continues garbage collection after one worktree cannot be snapshotted", async () => {
it("protects a visible nested repository while collecting another idle worktree", async () => {
const removable = await materializeRunOwnedFixture("removable", "workboard");
now += 1;
const nestedRecord = await materializeRunOwnedFixture("nested-idle", "workboard");
await initializeNestedRepository(nestedRecord.path, "nested");
const nested = await initializeNestedRepository(nestedRecord.path, "nested");
await fs.writeFile(path.join(nested, "local.txt"), "visible nested state\n");
now += IDLE_GC_MS + 1;
const result = await service.gc();
expect(result.removed).toEqual([removable.id]);
expect(getRegistryWorktree(env, nestedRecord.id)?.removedAt).toBeUndefined();
await expect(fs.stat(removable.path)).rejects.toMatchObject({ code: "ENOENT" });
const warnLogs = createWarnLogCapture("openclaw-worktree-gc-nested-visible");
try {
expect((await service.gc()).removed).toEqual([removable.id]);
expect(await warnLogs.findText(`idle cleanup failed for ${nestedRecord.id}`)).toBeUndefined();
expect(getRegistryWorktree(env, nestedRecord.id)?.removedAt).toBeUndefined();
expect(await fs.readFile(path.join(nested, "local.txt"), "utf8")).toBe(
"visible nested state\n",
);
await expect(fs.stat(removable.path)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
warnLogs.cleanup();
}
});
it("continues garbage collection when one repository control path is missing", async () => {
@@ -266,6 +292,33 @@ describe("ManagedWorktreeService garbage collection", () => {
expect(getRegistryWorktree(env, activeOldest.id)?.removedAt).toBeUndefined();
});
it.each([
{ limit: "count", limits: { maxCount: 1 } },
{ limit: "size", limits: { maxTotalSizeBytes: 60_000 } },
])("protects nested repositories during $limit limit eviction", async ({ limits }) => {
const protectedRecord = await materializeRunOwnedFixture("limit-nested", "workboard");
const nested = await initializeNestedRepository(protectedRecord.path, "nested");
await fs.writeFile(path.join(nested, "local.txt"), "protected nested state\n");
now += 1;
const removable = await materializeRunOwnedFixture("limit-removable", "workboard");
await fs.writeFile(path.join(removable.path, "blob.bin"), Buffer.alloc(100_000));
const warnLogs = createWarnLogCapture("openclaw-worktree-gc-nested-limit");
try {
expect((await service.gc({ limits })).removed).toEqual([removable.id]);
expect(
await warnLogs.findText(`cleanup limit removal failed for ${protectedRecord.id}`),
).toBeUndefined();
expect(getRegistryWorktree(env, protectedRecord.id)?.removedAt).toBeUndefined();
expect(await fs.readFile(path.join(nested, "local.txt"), "utf8")).toBe(
"protected nested state\n",
);
await expect(fs.stat(removable.path)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
warnLogs.cleanup();
}
});
it("evicts oldest worktrees until total size fits the size limit", async () => {
const oldest = await materializeRunOwnedFixture(
"size-oldest",
@@ -201,6 +201,43 @@ describe("ManagedWorktreeService run-end cleanup outcomes", () => {
await expect(fs.readFile(dirtyFile, "utf8")).resolves.toBe("retain me\n");
});
it.each([
{ kind: "independent", linked: false },
{ kind: "same-repository linked", linked: true },
])("retains an ignored nested $kind repository at run end", async ({ linked }) => {
await fs.writeFile(path.join(repo, ".gitignore"), "nested/\n");
await git(repo, "add", ".gitignore");
await git(repo, "commit", "-m", "ignore nested checkout state");
await git(repo, "push", "origin", "main");
const created = await materialize(linked ? "nested-linked" : "nested-independent");
const nested = path.join(created.path, "nested", "checkout");
await fs.mkdir(linked ? path.dirname(nested) : nested, { recursive: true });
if (linked) {
await git(repo, "worktree", "add", "--detach", nested, "HEAD");
} else {
await git(nested, "init", "-b", "main");
}
const localState = path.join(nested, "local.txt");
await fs.writeFile(localState, "keep nested checkout state\n");
expect(await git(created.path, "status", "--porcelain")).toBe("");
expect(await git(created.path, "log", "HEAD", "--not", "--remotes", "--oneline")).toBe("");
await service.acquire(created.id);
await expect(service.removeIfLossless(created.id)).resolves.toBe(false);
expect(getRegistryWorktree(env, created.id)).toMatchObject({
runEndCleanup: { outcome: "retained-dirty", at: now },
});
expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined();
expect(await fs.readFile(localState, "utf8")).toBe("keep nested checkout state\n");
if (linked) {
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(nested);
} else {
expect((await fs.stat(path.join(nested, ".git"))).isDirectory()).toBe(true);
}
});
it("records unpushed retention", async () => {
const created = await materialize("unpushed");
await service.acquire(created.id);
+32 -32
View File
@@ -434,13 +434,23 @@ async function rawPathExists(target: string | Buffer): Promise<boolean> {
}
async function containsSnapshotGitMarker(
record: ManagedWorktreeRecord,
snapshotPaths: Iterable<Buffer>,
checkoutRoot: string,
snapshotPaths?: Iterable<Buffer>,
): Promise<boolean> {
const visiblePaths = snapshotPaths ? [...snapshotPaths] : [];
if (!snapshotPaths) {
const indexEntries = splitNullBuffer(
await requireGitBuffer(checkoutRoot, ["ls-files", "--stage", "-z"]),
);
if (indexEntries.some((entry) => entry.subarray(0, 7).toString() === "160000 ")) {
return true;
}
const visibleGitPaths = ["ls-files", "-z", "--cached", "--others", "--exclude-standard"];
visiblePaths.push(...splitNullBuffer(await requireGitBuffer(checkoutRoot, visibleGitPaths)));
}
const checked = new Set<string>();
let ownedWorktrees: Set<string> | undefined;
const ignoredPaths = splitNullBuffer(
await requireGitBuffer(record.path, [
await requireGitBuffer(checkoutRoot, [
"ls-files",
"-z",
"--others",
@@ -448,28 +458,17 @@ async function containsSnapshotGitMarker(
"--exclude-standard",
]),
);
for (const [paths, ignored] of [
[snapshotPaths, false],
[ignoredPaths, true],
] as const) {
for (const gitPath of paths) {
for (let end = gitPath.indexOf(47); end !== -1; end = gitPath.indexOf(47, end + 1)) {
const directory = gitPath.subarray(0, end);
const key = gitPathKey(directory);
if (checked.has(key)) {
continue;
}
checked.add(key);
const marker = Buffer.concat([directory, Buffer.from("/.git")]);
if (!(await rawPathExists(checkoutPathFromGitBytes(record.path, marker)))) {
continue;
}
ownedWorktrees ??= new Set(
(await listGitWorktrees(record.repoRoot)).map((entry) => path.resolve(entry.path)),
);
if (!ignored || !ownedWorktrees.has(path.resolve(record.path, directory.toString()))) {
return true;
}
for (const gitPath of [...visiblePaths, ...ignoredPaths]) {
for (let end = gitPath.indexOf(47); end !== -1; end = gitPath.indexOf(47, end + 1)) {
const directory = gitPath.subarray(0, end);
const key = gitPathKey(directory);
if (checked.has(key)) {
continue;
}
checked.add(key);
const marker = Buffer.concat([directory, Buffer.from("/.git")]);
if (await rawPathExists(checkoutPathFromGitBytes(checkoutRoot, marker))) {
return true;
}
}
}
@@ -553,8 +552,7 @@ async function snapshotWorktree(
addSnapshotPath(entry);
}
}
// Only Git-owned ignored worktrees are disposable; foreign repositories must stay protected.
if (await containsSnapshotGitMarker(record, snapshotPaths.values())) {
if (await containsSnapshotGitMarker(record.path, snapshotPaths.values())) {
throw new Error("nested git repositories cannot be snapshotted losslessly");
}
await requireGit(record.path, ["read-tree", "HEAD"], { env });
@@ -1194,7 +1192,9 @@ export class ManagedWorktreeService {
? "retained-unpushed"
: ignoredDrift
? "retained-provisioned-drift"
: undefined;
: (await containsSnapshotGitMarker(record.path))
? "retained-dirty"
: undefined;
if (retainedOutcome) {
abortWorktreeRemoval(this.env, id, claimToken);
recordOutcome(retainedOutcome);
@@ -1287,8 +1287,8 @@ export class ManagedWorktreeService {
}
/**
* Shared auto-removal guard for idle and limit cleanup: owner protection, live
* run leases, and live/foreign git locks veto removal; a dead lock is cleared.
* Shared auto-removal guard: owners, leases, nested repositories, and live or
* foreign Git locks veto removal; a dead lock is cleared.
*/
private async isProtectedFromAutoRemoval(
record: ManagedWorktreeRecord,
@@ -1318,7 +1318,7 @@ export class ManagedWorktreeService {
if (state.kind === "dead") {
await requireGit(record.repoRoot, ["worktree", "unlock", record.path]);
}
return false;
return await containsSnapshotGitMarker(record.path);
}
/**
@@ -55,6 +55,12 @@ vi.mock("../plugins/providers.js", async (importOriginal) => ({
resolveOwningPluginIdsForProviderRef: () => [],
}));
vi.mock("../plugins/provider-runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/provider-runtime.js")>()),
// This plugin-free suite must not load bundled plugins to find retired profiles.
resolveProviderDeprecatedAuthProfileIds: () => [],
}));
const AUTH_ENV = {
LOCAL_AUDIO_API_KEY: undefined,
REMOTE_AUDIO_API_KEY: undefined,
@@ -293,7 +299,8 @@ describe("runCapability local no-auth audio providers", () => {
provider: "openai",
access: "oauth-chat-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
// Stay outside the five-minute refresh window to exercise API-key selection.
expires: Date.now() + 10 * 60_000,
},
},
};