From bb47829711ec9f9f05dc6cde9b911e585dca0d9d Mon Sep 17 00:00:00 2001 From: "codex.41 [OpenClaw]" Date: Wed, 18 Mar 2026 18:21:49 +0000 Subject: [PATCH 1/2] Fix task worktree lifecycle and stale context cleanup --- middle/daemon.ts | 37 +++- middle/http.ts | 60 +++++-- middle/journal.ts | 3 +- middle/test/http.test.ts | 111 +++++++++++- middle/test/worktree.test.ts | 129 ++++++++++++++ middle/worktree.ts | 109 +++++++++++- scripts/cleanup-stale-worktrees.ts | 260 +++++++++++++++++++++++++++++ 7 files changed, 691 insertions(+), 18 deletions(-) create mode 100644 middle/test/worktree.test.ts create mode 100644 scripts/cleanup-stale-worktrees.ts diff --git a/middle/daemon.ts b/middle/daemon.ts index df53bbb..afbbf59 100644 --- a/middle/daemon.ts +++ b/middle/daemon.ts @@ -6,7 +6,7 @@ import { loadConfig } from "./config.js"; import { createHttpServer, cleanupPendingDecompositions } from "./http.js"; import { exportState } from "./state-export.js"; import { initJournalRepo } from "./journal.js"; -import { cleanupStaleWorktrees } from "./worktree.js"; +import { cleanupStaleWorktrees, getWorktreePath, removeWorktree } from "./worktree.js"; // --------------------------------------------------------------------------- // Lock file @@ -102,6 +102,40 @@ function reconcileOrphanedTasks(core: OrchestrationCore): void { } } +function cleanupClosedTaskWorktrees( + core: OrchestrationCore, + config: ReturnType, +): void { + if (!fs.existsSync(config.worktreeBaseDir)) return; + + let cleaned = 0; + const state = core.getState(); + for (const entry of fs.readdirSync(config.worktreeBaseDir)) { + const match = /^(journal|code)-T(.+)$/.exec(entry); + if (!match) continue; + + const kind = match[1] as "journal" | "code"; + const taskId = match[2]!; + const task = state.tasks[taskId]; + if (!task || task.terminal === null) continue; + + if (kind === "journal") { + removeWorktree(config.journalRepoPath, getWorktreePath(config.worktreeBaseDir, taskId, "journal")); + cleaned++; + continue; + } + + const targetRepo = (task.metadata["repo"] as string | undefined) || config.defaultCodeRepo || undefined; + if (!targetRepo) continue; + removeWorktree(targetRepo, getWorktreePath(config.worktreeBaseDir, taskId, "code")); + cleaned++; + } + + if (cleaned > 0) { + console.log(`[daemon] Cleaned up ${cleaned} closed worktree(s)`); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -182,6 +216,7 @@ async function main(): Promise { } // Sweep pending decomposition sessions for tasks that went terminal or changed phase cleanupPendingDecompositions(core); + cleanupClosedTaskWorktrees(core, config); } catch (err) { console.error("[daemon] Tick exception:", err); } diff --git a/middle/http.ts b/middle/http.ts index b895dba..04be7a3 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -37,7 +37,7 @@ import type { Config } from "./config.js"; import { buildPrompt } from "./prompt.js"; import { commitJournal, createTaskBranch, getFailureSummaries, getJournalContent, mergeTaskBranch, taskBranch } from "./journal.js"; import { agentRole, loadRegistry, validateMetadataRoles, type Registry } from "./registry.js"; -import { createWorktree, getWorktreePath } from "./worktree.js"; +import { ensureWorktree, getWorktreePath, removeWorktree, writeTaskContext } from "./worktree.js"; import { verifyArtifacts } from "./finalize.js"; import { createOrFindPr } from "./github.js"; @@ -452,9 +452,7 @@ function ensureTaskWorkspaces(config: Config, task: Task): { const jBranch = taskBranch(task.id); const jPath = getWorktreePath(config.worktreeBaseDir, task.id, "journal"); createTaskBranch(config.journalRepoPath, task.id, task.parentId); - if (!fs.existsSync(jPath)) { - createWorktree(config.journalRepoPath, jPath, jBranch); - } + ensureWorktree(config.journalRepoPath, jPath, jBranch); journalWorktree = jPath; journalPath = `${path.join(jPath, "tasks", `T${task.id}`)}${path.sep}`; } catch (err) { @@ -471,9 +469,7 @@ function ensureTaskWorkspaces(config: Config, task: Task): { const baseBranch = parentCodeBranch ?? (task.metadata["base_branch"] as string | undefined) ?? "main"; - if (!fs.existsSync(cPath)) { - createWorktree(targetRepo, cPath, codeBranch, baseBranch); - } + ensureWorktree(targetRepo, cPath, codeBranch, baseBranch); codeWorktree = cPath; } catch (err) { warnings.push(`code worktree setup failed: ${String(err)}`); @@ -483,6 +479,41 @@ function ensureTaskWorkspaces(config: Config, task: Task): { return { journalWorktree, journalPath, codeWorktree, warnings }; } +function writeWorkspaceTaskContext( + task: Task, + workspaces: { + journalWorktree: string | null; + journalPath: string | null; + codeWorktree: string | null; + }, + sessionId: string, + fenceToken: number, + claimedAt: number, +): void { + const roots = [workspaces.journalWorktree, workspaces.codeWorktree].filter((root): root is string => !!root); + if (roots.length === 0 || !workspaces.journalPath) return; + + writeTaskContext({ + taskId: task.id, + phase: task.phase, + fenceToken, + sessionId, + journalPath: workspaces.journalPath, + codeWorktree: workspaces.codeWorktree, + claimedAt, + reviewNotes: [], + }, roots); +} + +function cleanupTaskWorkspaces(config: Config, task: Task): void { + removeWorktree(config.journalRepoPath, getWorktreePath(config.worktreeBaseDir, task.id, "journal")); + + const targetRepo = (task.metadata["repo"] as string | undefined) || config.defaultCodeRepo || undefined; + if (!targetRepo) return; + + removeWorktree(targetRepo, getWorktreePath(config.worktreeBaseDir, task.id, "code")); +} + function ensureJournalWorktreeForTask(config: Config, task: Task): { journalWorktree: string; taskDir: string; @@ -810,6 +841,7 @@ function handleClaimTask( // The daemon owns worktree creation so the CLI can stay a pure HTTP client. const workspace = ensureTaskWorkspaces(config, updated); + writeWorkspaceTaskContext(updated, workspace, sessionId, fenceToken, now); const parentJournal = updated.parentId ? getJournalContent(config.journalRepoPath, updated.parentId) : null; @@ -1418,10 +1450,10 @@ function handleStatusUpdate( return applyDoneTransition(core, config, task, fenceToken, ctx, now, b.evidence, b.stateRef); case "reject": - return applyRejectTransition(core, task, fenceToken, ctx, now, b.evidence); + return applyRejectTransition(core, config, task, fenceToken, ctx, now, b.evidence); case "blocked": - return applyBlockedTransition(core, task, now, b.blocker ?? b.evidence ?? "No reason provided", ctx); + return applyBlockedTransition(core, config, task, now, b.blocker ?? b.evidence ?? "No reason provided", ctx); case "pending": return applyChangesRequestedTransition(core, task, fenceToken, ctx, now, b.evidence); @@ -1433,7 +1465,7 @@ function handleStatusUpdate( return applyDecomposeTransition(core, task, fenceToken, ctx, now); case "cancel": - return applyCancelTransition(core, task, now, b.evidence, ctx); + return applyCancelTransition(core, config, task, now, b.evidence, ctx); default: return { status: 400, body: { error: "unknown_status", message: `Unknown status: ${b.status}` } }; @@ -1819,6 +1851,7 @@ function applyDoneTransition( notifyInformed(task, "✅ Done"); mergeCodeBranch(config, task); maybeCreatePr(config, task, core); + cleanupTaskWorkspaces(config, task); return { status: 200, @@ -1901,6 +1934,7 @@ function applyDoneTransition( notifyInformed(task, "✅ Done"); mergeCodeBranch(config, task); maybeCreatePr(config, task, core); + cleanupTaskWorkspaces(config, task); return { status: 200, @@ -1911,6 +1945,7 @@ function applyDoneTransition( /** reject: review.active → failed */ function applyRejectTransition( core: Core, + config: Config, task: Task, fenceToken: number, ctx: AgentContext, @@ -2013,6 +2048,7 @@ function applyRejectTransition( if (err) return err; notifyInformed(task, "❌ Rejected (final)", evidence ?? "Rejected by reviewer — all attempts exhausted"); + cleanupTaskWorkspaces(config, task); return { status: 200, @@ -2023,6 +2059,7 @@ function applyRejectTransition( /** blocked: any active state → TaskBlocked */ function applyBlockedTransition( core: Core, + config: Config, task: Task, ts: number, blocker: string, @@ -2051,6 +2088,7 @@ function applyBlockedTransition( if (err) return err; notifyInformed(task, "🚫 Blocked", blocker); + cleanupTaskWorkspaces(config, task); return { status: 200, @@ -2063,6 +2101,7 @@ function applyBlockedTransition( /** cancel: any non-terminal state -> TaskCanceled */ function applyCancelTransition( core: Core, + config: Config, task: Task, ts: number, evidence?: string, @@ -2079,6 +2118,7 @@ function applyCancelTransition( const err = submitOrError(core, canceled); if (err) return err; + cleanupTaskWorkspaces(config, task); return { status: 200, diff --git a/middle/journal.ts b/middle/journal.ts index bb77768..6f69d21 100644 --- a/middle/journal.ts +++ b/middle/journal.ts @@ -242,7 +242,7 @@ export function taskBranch(taskId: string): string { function branchExists(repoPath: string, branch: string): boolean { try { - git(repoPath, ["rev-parse", "--verify", `refs/heads/${branch}`]); + git(repoPath, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); return true; } catch { return false; @@ -255,6 +255,7 @@ function git(cwd: string, args: string[]): string { cwd, encoding: "utf-8", timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0", diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index f73e336..6786f64 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -1,4 +1,5 @@ import * as http from "node:http"; +import { execFileSync } from "node:child_process"; import { test, describe, beforeEach, afterEach } from "node:test"; import * as assert from "node:assert/strict"; import * as fs from "node:fs"; @@ -59,7 +60,7 @@ function request( async function setup(): Promise { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-test-")); dbPath = path.join(tmpDir, "test.db"); - port = 18800 + Math.floor(Math.random() * 1000); + port = 0; const journalRepoPath = path.join(tmpDir, "journal"); const worktreeBaseDir = path.join(tmpDir, "worktrees"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -96,7 +97,14 @@ async function setup(): Promise { server = createHttpServer(core, config); await new Promise((resolve) => { - server.listen(port, "127.0.0.1", resolve); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to bind ephemeral test port"); + } + port = address.port; + resolve(); + }); }); } @@ -110,6 +118,16 @@ async function teardown(): Promise { } } +function initRepo(repoPath: string): void { + fs.mkdirSync(repoPath, { recursive: true }); + execFileSync("git", ["init", "--initial-branch=main"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Taskcore Tests"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "taskcore-tests@example.com"], { cwd: repoPath, stdio: "ignore" }); + fs.writeFileSync(path.join(repoPath, "README.md"), "# test\n", "utf-8"); + execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath, stdio: "ignore" }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -462,6 +480,95 @@ describe("HTTP API", () => { assert.ok(readBody.content.includes("Starting implementation")); }); + test("claim refreshes stale .task metadata in an existing code worktree", async () => { + const repoPath = path.join(tmpDir, "code-repo"); + initRepo(repoPath); + + await request("POST", "/tasks", { + title: "Refresh stale context", + description: "Existing worktree context should be overwritten on claim", + assignee: "coder", + repo: repoPath, + skipAnalysis: true, + }); + + const codeWorktree = path.join(config.worktreeBaseDir, "code-T1"); + execFileSync("git", ["worktree", "add", "-b", "task/T1", codeWorktree, "main"], { + cwd: repoPath, + stdio: "ignore", + }); + fs.writeFileSync(path.join(codeWorktree, ".task"), JSON.stringify({ + taskId: "2123", + phase: "execution", + fenceToken: 1, + sessionId: "stale-session", + journalPath: "/tmp/stale/", + codeWorktree, + claimedAt: 1, + reviewNotes: [], + }, null, 2) + "\n", "utf-8"); + + const claimRes = await request("POST", "/tasks/1/claim", { + agentId: "coder", + source: "test", + }); + assert.equal(claimRes.status, 200); + + const claimBody = claimRes.body as { + sessionId: string; + fenceToken: number; + workspace?: { journalPath?: string | null; codeWorktree?: string | null }; + }; + const dotask = JSON.parse(fs.readFileSync(path.join(codeWorktree, ".task"), "utf-8")) as Record; + assert.equal(dotask["taskId"], "1"); + assert.equal(dotask["sessionId"], claimBody.sessionId); + assert.equal(dotask["fenceToken"], claimBody.fenceToken); + assert.equal(dotask["journalPath"], claimBody.workspace?.journalPath); + assert.equal(dotask["codeWorktree"], claimBody.workspace?.codeWorktree); + }); + + test("status done cleans up journal and code worktrees", async () => { + const repoPath = path.join(tmpDir, "code-repo"); + initRepo(repoPath); + + await request("POST", "/tasks", { + title: "Cleanup worktrees", + description: "Terminal tasks should remove their worktrees", + assignee: "coder", + repo: repoPath, + skipAnalysis: true, + }); + + const claimRes = await request("POST", "/tasks/1/claim", { + agentId: "coder", + source: "test", + }); + assert.equal(claimRes.status, 200); + + const claimBody = claimRes.body as { + workspace?: { + journalWorktree?: string | null; + codeWorktree?: string | null; + }; + }; + const codeWorktree = claimBody.workspace?.codeWorktree; + const journalWorktree = claimBody.workspace?.journalWorktree; + assert.ok(codeWorktree); + assert.ok(journalWorktree); + + fs.writeFileSync(path.join(codeWorktree!, "feature.txt"), "done\n", "utf-8"); + execFileSync("git", ["add", "feature.txt"], { cwd: codeWorktree!, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "task work"], { cwd: codeWorktree!, stdio: "ignore" }); + + const doneRes = await request("POST", "/tasks/1/status", { + status: "done", + evidence: "Work completed", + }); + assert.equal(doneRes.status, 200); + assert.equal(fs.existsSync(codeWorktree!), false); + assert.equal(fs.existsSync(journalWorktree!), false); + }); + test("status done completes execution task directly when no reviewer is configured", async () => { await request("POST", "/tasks", { title: "Direct completion", diff --git a/middle/test/worktree.test.ts b/middle/test/worktree.test.ts new file mode 100644 index 0000000..8fd5648 --- /dev/null +++ b/middle/test/worktree.test.ts @@ -0,0 +1,129 @@ +import { spawnSync } from "node:child_process"; +import * as assert from "node:assert/strict"; +import * as path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const journalModuleUrl = pathToFileURL(path.join(repoRoot, "middle/journal.ts")).href; +const worktreeModuleUrl = pathToFileURL(path.join(repoRoot, "middle/worktree.ts")).href; + +test("task workspace bootstrap stays silent on stderr for fresh branches", () => { + const script = [ + 'import * as fs from "node:fs";', + 'import * as os from "node:os";', + 'import * as path from "node:path";', + `import { initJournalRepo, createTaskBranch, taskBranch } from ${JSON.stringify(journalModuleUrl)};`, + `import { createWorktree } from ${JSON.stringify(worktreeModuleUrl)};`, + 'const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-noise-"));', + 'const repoPath = path.join(tmpDir, "journal");', + 'initJournalRepo(repoPath);', + 'createTaskBranch(repoPath, "1");', + 'createWorktree(repoPath, path.join(tmpDir, "journal-T1"), taskBranch("1"));', + ].join("\n"); + + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", script], { + cwd: repoRoot, + encoding: "utf-8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stderr.trim(), ""); +}); + +test("createWorktree falls back to repo HEAD when start point is missing", () => { + const script = [ + 'import { execFileSync } from "node:child_process";', + 'import * as fs from "node:fs";', + 'import * as os from "node:os";', + 'import * as path from "node:path";', + `import { createWorktree } from ${JSON.stringify(worktreeModuleUrl)};`, + 'const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-fallback-"));', + 'const repoPath = path.join(tmpDir, "repo");', + 'fs.mkdirSync(repoPath, { recursive: true });', + 'execFileSync("git", ["init", "--initial-branch=main"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.name", "Taskcore Tests"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.email", "taskcore-tests@example.com"], { cwd: repoPath, stdio: "ignore" });', + 'fs.writeFileSync(path.join(repoPath, "README.md"), "# test\\n", "utf-8");', + 'execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath, stdio: "ignore" });', + 'const worktreePath = path.join(tmpDir, "code-T1");', + 'createWorktree(repoPath, worktreePath, "task/T1", "missing-base");', + 'const branch = execFileSync("git", ["branch", "--show-current"], { cwd: worktreePath, encoding: "utf-8" }).trim();', + 'if (branch !== "task/T1") throw new Error(`unexpected branch ${branch}`);', + 'if (!fs.existsSync(path.join(worktreePath, "README.md"))) throw new Error("README.md missing from fallback worktree");', + ].join("\n"); + + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", script], { + cwd: repoRoot, + encoding: "utf-8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("ensureWorktree recreates a clean mismatched worktree", () => { + const script = [ + 'import { execFileSync } from "node:child_process";', + 'import * as fs from "node:fs";', + 'import * as os from "node:os";', + 'import * as path from "node:path";', + `import { createWorktree, ensureWorktree } from ${JSON.stringify(worktreeModuleUrl)};`, + 'const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-reuse-"));', + 'const repoPath = path.join(tmpDir, "repo");', + 'fs.mkdirSync(repoPath, { recursive: true });', + 'execFileSync("git", ["init", "--initial-branch=main"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.name", "Taskcore Tests"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.email", "taskcore-tests@example.com"], { cwd: repoPath, stdio: "ignore" });', + 'fs.writeFileSync(path.join(repoPath, "README.md"), "# test\\n", "utf-8");', + 'execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath, stdio: "ignore" });', + 'const worktreePath = path.join(tmpDir, "code-T1");', + 'createWorktree(repoPath, worktreePath, "task/T999", "main");', + 'ensureWorktree(repoPath, worktreePath, "task/T1", "main");', + 'const branch = execFileSync("git", ["branch", "--show-current"], { cwd: worktreePath, encoding: "utf-8" }).trim();', + 'if (branch !== "task/T1") throw new Error(`unexpected branch ${branch}`);', + ].join("\n"); + + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", script], { + cwd: repoRoot, + encoding: "utf-8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("ensureWorktree rejects dirty mismatched worktrees", () => { + const script = [ + 'import { execFileSync } from "node:child_process";', + 'import * as fs from "node:fs";', + 'import * as os from "node:os";', + 'import * as path from "node:path";', + `import { createWorktree, ensureWorktree } from ${JSON.stringify(worktreeModuleUrl)};`, + 'const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-worktree-dirty-"));', + 'const repoPath = path.join(tmpDir, "repo");', + 'fs.mkdirSync(repoPath, { recursive: true });', + 'execFileSync("git", ["init", "--initial-branch=main"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.name", "Taskcore Tests"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["config", "user.email", "taskcore-tests@example.com"], { cwd: repoPath, stdio: "ignore" });', + 'fs.writeFileSync(path.join(repoPath, "README.md"), "# test\\n", "utf-8");', + 'execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });', + 'execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath, stdio: "ignore" });', + 'const worktreePath = path.join(tmpDir, "code-T1");', + 'createWorktree(repoPath, worktreePath, "task/T999", "main");', + 'fs.writeFileSync(path.join(worktreePath, "README.md"), "# dirty\\n", "utf-8");', + 'try {', + ' ensureWorktree(repoPath, worktreePath, "task/T1", "main");', + ' throw new Error("expected ensureWorktree to fail");', + '} catch (err) {', + ' if (!String(err).includes("local changes")) throw err;', + '}', + ].join("\n"); + + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", script], { + cwd: repoRoot, + encoding: "utf-8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/middle/worktree.ts b/middle/worktree.ts index 2a4d959..ac7caaa 100644 --- a/middle/worktree.ts +++ b/middle/worktree.ts @@ -2,6 +2,17 @@ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; +export interface TaskContextFile { + taskId: string; + phase: string | null; + fenceToken: number; + sessionId: string; + journalPath: string; + codeWorktree: string | null; + claimedAt: number; + reviewNotes: string[]; +} + // --------------------------------------------------------------------------- // Git worktree lifecycle // --------------------------------------------------------------------------- @@ -23,13 +34,20 @@ export function createWorktree( // the worktree doesn't have the keys yet. const useGitCrypt = fs.existsSync(path.join(repoPath, ".git", "git-crypt")); const noCheckout = useGitCrypt ? "--no-checkout" : null; + const resolvedStartPoint = startPoint && refExists(repoPath, startPoint) + ? startPoint + : undefined; + const createBranch = !branchExists(repoPath, branch); const addArgs = (withNewBranch: boolean): string[] => { const args = ["worktree", "add"]; if (noCheckout) args.push(noCheckout); + if (withNewBranch) { + args.push("-b", branch); + } args.push(worktreePath); - if (withNewBranch && startPoint) { - args.push("-b", branch, startPoint); + if (withNewBranch) { + if (resolvedStartPoint) args.push(resolvedStartPoint); } else { args.push(branch); } @@ -38,11 +56,11 @@ export function createWorktree( const tryAdd = (): void => { try { - git(repoPath, addArgs(!!startPoint)); + git(repoPath, addArgs(createBranch)); } catch (err) { const msg = String(err); // Branch may already exist — retry without -b - if (startPoint && msg.includes("already exists")) { + if (createBranch && msg.includes("already exists")) { git(repoPath, addArgs(false)); } else { throw err; @@ -74,6 +92,37 @@ export function createWorktree( return worktreePath; } +/** + * Ensure the expected worktree exists and points at the expected branch. + * If the path exists but is not the requested worktree and has no local edits, + * recreate it so agents do not inherit stale task state. + */ +export function ensureWorktree( + repoPath: string, + worktreePath: string, + branch: string, + startPoint?: string, +): string { + if (!fs.existsSync(worktreePath)) { + return createWorktree(repoPath, worktreePath, branch, startPoint); + } + + const currentBranch = getCurrentBranch(worktreePath); + if (currentBranch === branch) { + applyGitCryptSymlink(repoPath, worktreePath); + return worktreePath; + } + + if (isWorktreeReusable(worktreePath)) { + removeWorktree(repoPath, worktreePath); + return createWorktree(repoPath, worktreePath, branch, startPoint); + } + + throw new Error( + `existing worktree ${worktreePath} is on ${currentBranch ?? "unknown"} with local changes; expected ${branch}`, + ); +} + /** * Remove a worktree. Idempotent — no-op if already removed. */ @@ -122,6 +171,18 @@ export function getWorktreePath( return path.join(baseDir, `${type}-T${taskId}`); } +export function writeTaskContext( + taskContext: TaskContextFile, + roots: string[], +): void { + const content = JSON.stringify(taskContext, null, 2) + "\n"; + for (const root of roots) { + if (!root) continue; + ensureDir(root); + fs.writeFileSync(path.join(root, ".task"), content, "utf-8"); + } +} + /** * If the main repo uses git-crypt, symlink its keys into the worktree's * git directory so encrypted files can be read. @@ -205,12 +266,52 @@ export function cleanupStaleWorktrees( // Helpers // --------------------------------------------------------------------------- +function branchExists(repoPath: string, branch: string): boolean { + return refExists(repoPath, `refs/heads/${branch}`); +} + +function refExists(repoPath: string, ref: string): boolean { + try { + execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + cwd: repoPath, + encoding: "utf-8", + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + }); + return true; + } catch { + return false; + } +} + +function getCurrentBranch(worktreePath: string): string | null { + try { + const branch = git(worktreePath, ["branch", "--show-current"]).trim(); + return branch || null; + } catch { + return null; + } +} + +function isWorktreeReusable(worktreePath: string): boolean { + try { + return git(worktreePath, ["status", "--porcelain"]).trim().length === 0; + } catch { + return true; + } +} + /** Run a git command. */ function git(cwd: string, args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0", diff --git a/scripts/cleanup-stale-worktrees.ts b/scripts/cleanup-stale-worktrees.ts new file mode 100644 index 0000000..9687200 --- /dev/null +++ b/scripts/cleanup-stale-worktrees.ts @@ -0,0 +1,260 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { loadConfig } from "../middle/config.js"; +import { getWorktreePath, removeWorktree, writeTaskContext } from "../middle/worktree.js"; + +type Kind = "journal" | "code"; + +interface TaskRecord { + id: string; + phase: string | null; + condition: string | null; + terminal: string | null; + currentFenceToken?: number; + updatedAt?: number; + metadata?: Record; +} + +interface AuditRow { + name: string; + worktreePath: string; + kind: Kind; + taskId: string; + status: string; + dotask: string; + branch: string | null; + classes: string[]; +} + +function normalizeTaskId(value: string): string { + return value.replace(/^T/i, ""); +} + +function readArg(name: string): string | null { + const idx = process.argv.indexOf(name); + if (idx === -1) return null; + return process.argv[idx + 1] ?? null; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(name); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function taskStatus(task: TaskRecord | null): string { + if (!task) return "not_found"; + return task.terminal ?? `${task.phase ?? "null"}.${task.condition ?? "null"}`; +} + +function readDottaskTaskId(worktreePath: string): string { + const dotaskPath = path.join(worktreePath, ".task"); + if (!fs.existsSync(dotaskPath)) return "missing"; + try { + const parsed = JSON.parse(fs.readFileSync(dotaskPath, "utf-8")) as Record; + const taskId = String(parsed["taskId"] ?? "").trim(); + return taskId || "empty"; + } catch { + return "invalid"; + } +} + +function currentBranch(worktreePath: string): string | null { + if (!fs.existsSync(worktreePath)) return null; + try { + const branch = execFileSync("git", ["branch", "--show-current"], { + cwd: worktreePath, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return branch || null; + } catch { + return null; + } +} + +async function fetchTasks(apiBase: string): Promise { + const res = await fetch(`${apiBase}/tasks?full=true`); + if (!res.ok) { + throw new Error(`failed to fetch tasks: ${res.status} ${res.statusText}`); + } + const body = await res.json() as { tasks?: TaskRecord[] }; + return Array.isArray(body.tasks) ? body.tasks : []; +} + +function existingRoots(config: ReturnType, taskId: string): string[] { + return [ + getWorktreePath(config.worktreeBaseDir, taskId, "journal"), + getWorktreePath(config.worktreeBaseDir, taskId, "code"), + ].filter((root) => fs.existsSync(root)); +} + +function repairTaskContext( + config: ReturnType, + task: TaskRecord, +): boolean { + const roots = existingRoots(config, task.id); + if (roots.length === 0) return false; + + const claimedAt = + Date.parse(asString(task.metadata?.["claimedAt"]) ?? "") || + asNumber(task.updatedAt) || + Date.now(); + const sessionId = asString(task.metadata?.["claimSessionId"]) ?? `recovered-${task.id}`; + const journalRoot = getWorktreePath(config.worktreeBaseDir, task.id, "journal"); + const journalPath = `${path.join(journalRoot, "tasks", `T${task.id}`)}${path.sep}`; + + writeTaskContext({ + taskId: task.id, + phase: task.phase, + fenceToken: asNumber(task.currentFenceToken) ?? 0, + sessionId, + journalPath, + codeWorktree: fs.existsSync(getWorktreePath(config.worktreeBaseDir, task.id, "code")) + ? getWorktreePath(config.worktreeBaseDir, task.id, "code") + : null, + claimedAt, + reviewNotes: [], + }, roots); + return true; +} + +function removeAuditedWorktree( + config: ReturnType, + row: AuditRow, + task: TaskRecord | null, +): boolean { + if (row.kind === "journal") { + removeWorktree(config.journalRepoPath, row.worktreePath); + return true; + } + + const targetRepo = + asString(task?.metadata?.["repo"]) || + asString(config.defaultCodeRepo); + if (targetRepo) { + removeWorktree(targetRepo, row.worktreePath); + return true; + } + + fs.rmSync(row.worktreePath, { recursive: true, force: true }); + return true; +} + +async function main(): Promise { + const config = loadConfig(); + const apiBase = readArg("--api-base") ?? `http://127.0.0.1:${config.port}`; + const apply = hasFlag("--apply"); + const jsonMode = hasFlag("--json"); + + const liveTasks = await fetchTasks(apiBase); + const taskIndex = new Map(liveTasks.map((task) => [normalizeTaskId(task.id), task])); + const rows: AuditRow[] = []; + + if (fs.existsSync(config.worktreeBaseDir)) { + for (const entry of fs.readdirSync(config.worktreeBaseDir)) { + const match = /^(journal|code)-T(.+)$/.exec(entry); + if (!match) continue; + + const kind = match[1] as Kind; + const taskId = normalizeTaskId(match[2]!); + const worktreePath = path.join(config.worktreeBaseDir, entry); + const task = taskIndex.get(taskId) ?? null; + const dotask = readDottaskTaskId(worktreePath); + const branch = kind === "code" ? currentBranch(worktreePath) : null; + const classes: string[] = []; + + if (!task) { + classes.push("ORPHANED"); + } else if (task.terminal !== null) { + classes.push("STALE"); + } else { + classes.push("ACTIVE"); + } + + if (dotask === "missing" || dotask === "invalid" || dotask === "empty") { + classes.push("BROKEN_DOTASK"); + } else if (normalizeTaskId(dotask) !== taskId) { + classes.push("MISMATCHED"); + } + + if (kind === "code" && branch && branch !== `task/T${taskId}`) { + classes.push("BRANCH_SUSPECT"); + } + + rows.push({ + name: entry, + worktreePath, + kind, + taskId, + status: taskStatus(task), + dotask, + branch, + classes, + }); + } + } + + let removed = 0; + let repaired = 0; + if (apply) { + for (const row of rows) { + const task = taskIndex.get(row.taskId) ?? null; + if (row.classes.includes("ORPHANED") || row.classes.includes("STALE")) { + if (removeAuditedWorktree(config, row, task)) removed++; + continue; + } + if (row.classes.includes("BROKEN_DOTASK") || row.classes.includes("MISMATCHED")) { + if (task && repairTaskContext(config, task)) repaired++; + } + } + } + + const summary = { + total: rows.length, + stale: rows.filter((row) => row.classes.includes("STALE")).length, + orphaned: rows.filter((row) => row.classes.includes("ORPHANED")).length, + active: rows.filter((row) => row.classes.includes("ACTIVE")).length, + mismatched: rows.filter((row) => row.classes.includes("MISMATCHED")).length, + brokenDottask: rows.filter((row) => row.classes.includes("BROKEN_DOTASK")).length, + branchSuspect: rows.filter((row) => row.classes.includes("BRANCH_SUSPECT")).length, + removed, + repaired, + sample: rows.slice(0, 25), + }; + + if (jsonMode) { + process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); + return; + } + + process.stdout.write(`Worktrees: ${summary.total}\n`); + process.stdout.write(`Active: ${summary.active}\n`); + process.stdout.write(`Stale: ${summary.stale}\n`); + process.stdout.write(`Orphaned: ${summary.orphaned}\n`); + process.stdout.write(`Mismatched .task: ${summary.mismatched}\n`); + process.stdout.write(`Broken .task: ${summary.brokenDottask}\n`); + process.stdout.write(`Branch suspect: ${summary.branchSuspect}\n`); + if (apply) { + process.stdout.write(`Removed: ${removed}\n`); + process.stdout.write(`Repaired: ${repaired}\n`); + } else { + process.stdout.write("Dry run only. Re-run with --apply to remove stale/orphaned worktrees and repair active .task metadata.\n"); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((err) => { + process.stderr.write(String(err) + "\n"); + process.exit(1); + }); From f911fd0b614dd2a4c295ccd4e76b45e0b4ff3d85 Mon Sep 17 00:00:00 2001 From: "codex.41 [OpenClaw]" Date: Wed, 18 Mar 2026 18:29:52 +0000 Subject: [PATCH 2/2] Fix typecheck for completion and artifact evidence --- core/types.ts | 4 +++- middle/http.ts | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/types.ts b/core/types.ts index 97ba289..266fee2 100644 --- a/core/types.ts +++ b/core/types.ts @@ -462,10 +462,12 @@ export interface ReviewPolicyMet extends BaseEvent { // Completion verification // --------------------------------------------------------------------------- -export type ArtifactKind = "journal" | "code" | "pr"; +export type ArtifactKind = "journal" | "code" | "pr" | "file"; export interface ArtifactEvidence { kind: ArtifactKind; + path?: string; + sizeBytes?: number; repo?: string; branch?: string; baseRef?: string | null; diff --git a/middle/http.ts b/middle/http.ts index 04be7a3..7d99553 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -1843,7 +1843,6 @@ function applyDoneTransition( taskId: task.id, ts: ts + 1, stateRef: stateRef ?? defaultStateRef(), - source: { type: "agent", id: ctx.agentId }, }; const err = submitOrError(core, completed); if (err) return err; @@ -1926,7 +1925,6 @@ function applyDoneTransition( taskId: task.id, ts: ts + 2, stateRef: stateRef ?? defaultStateRef(), - source: { type: "agent", id: ctx.agentId }, }; err = submitOrError(core, completed); if (err) return err; @@ -2042,7 +2040,6 @@ function applyRejectTransition( whatWasLearned: "Reviewer rejected the submission. All review attempts exhausted.", artifactRef: null, }, - source: { type: "agent", id: ctx.agentId }, }; err = submitOrError(core, failed); if (err) return err;