From 91c0feaa87c910b7c6a91f76ced8c2ed949d2345 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 27 Jul 2026 03:47:20 +0000 Subject: [PATCH] fix(cli): recognize isolation marker before mount exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup window between writeIsolationOwner and isoStart left the base dir holding only the marker file and no `m` mount, so classifyDir returned null and scanWorktrees classified it as a stray — which a non-`--all` clear removes, defeating the ownership guard mid-setup. classifyDir now treats the presence of the ownership marker as a task-isolation signal (in addition to the mount dir), so an in-progress sandbox with a live owner is preserved throughout backend setup. Fixes #6761 --- packages/coding-agent/src/cli/worktree-cli.ts | 37 ++++++++++++------- .../test/cli/worktree-clear-isolation.test.ts | 7 ++++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/cli/worktree-cli.ts b/packages/coding-agent/src/cli/worktree-cli.ts index c554373c1..5ca0067a2 100644 --- a/packages/coding-agent/src/cli/worktree-cli.ts +++ b/packages/coding-agent/src/cli/worktree-cli.ts @@ -20,7 +20,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import { hasLiveIsolationOwner } from "../task/isolation-ownership"; +import { hasLiveIsolationOwner, ISOLATION_OWNER_FILE } from "../task/isolation-ownership"; import * as git from "../utils/git"; type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray"; @@ -214,19 +214,30 @@ async function classifyDir(dir: string): Promise { if (gitStat?.isFile()) { return classifyPrCheckout(dir, gitEntry); } - for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) { - const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null); - if (!mountStat?.isDirectory()) continue; - const live = await hasLiveIsolationOwner(dir); - return { - path: dir, - kind: "task-isolation", - // Only after confirming no live owner is the "no live task" claim true. - // A running subagent's sandbox stays live so `clear` won't delete it. - orphanReason: live ? undefined : "task-isolation leftover (no live task owns it)", - }; + // A task-isolation sandbox is identified by its ownership marker — written + // before the backend materialises the mount — or by the `m`/`merged` mount + // dir itself (legacy dirs and crashed pre-marker runs). Recognizing the + // marker alone keeps an in-progress sandbox from being mistaken for a stray + // during the window between marker creation and mount materialisation. + let isIsolation = await Bun.file(path.join(dir, ISOLATION_OWNER_FILE)).exists(); + if (!isIsolation) { + for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) { + const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null); + if (mountStat?.isDirectory()) { + isIsolation = true; + break; + } + } } - return null; + if (!isIsolation) return null; + const live = await hasLiveIsolationOwner(dir); + return { + path: dir, + kind: "task-isolation", + // Only after confirming no live owner is the "no live task" claim true. + // A running subagent's sandbox stays live so `clear` won't delete it. + orphanReason: live ? undefined : "task-isolation leftover (no live task owns it)", + }; } async function classifyPrCheckout(dir: string, gitEntry: string): Promise { diff --git a/packages/coding-agent/test/cli/worktree-clear-isolation.test.ts b/packages/coding-agent/test/cli/worktree-clear-isolation.test.ts index 6e0e03efe..2c03a5387 100644 --- a/packages/coding-agent/test/cli/worktree-clear-isolation.test.ts +++ b/packages/coding-agent/test/cli/worktree-clear-isolation.test.ts @@ -57,6 +57,12 @@ describe("worktree clear task-isolation ownership", () => { const corrupt = await makeSandbox("tbad00004"); await Bun.write(path.join(corrupt, ISOLATION_OWNER_FILE), "{ not json"); + // Setup race: marker written before the backend materialises `m`. The + // dir holds only the live-owner marker and no mount yet. + const pending = path.join(base, "tpend0005"); + await fs.mkdir(pending, { recursive: true }); + await writeIsolationOwner(pending, "pend0005"); + await clearWorktrees({ all: false, dryRun: false, json: true }); const exists = async (p: string): Promise => @@ -68,5 +74,6 @@ describe("worktree clear task-isolation ownership", () => { expect(await exists(dead)).toBe(false); expect(await exists(orphan)).toBe(false); expect(await exists(corrupt)).toBe(false); + expect(await exists(pending)).toBe(true); }); });